From 24559d81ac7706a3b2541fabd949ac16eb338ded Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 05:49:04 +0200 Subject: [PATCH] CB-573: require explicit capacity source --- .../main/java/dev/ltms/bridged/Bridged.java | 4 +- .../java/dev/ltms/bridged/mcp/BridgeMcp.java | 55 +++++++------------ .../ltms/bridged/mcp/BridgeMcpAuthzTest.java | 2 +- .../dev/ltms/bridged/mcp/BridgeMcpTest.java | 19 +++++-- 4 files changed, 38 insertions(+), 42 deletions(-) diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index f0de87c..85d057c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -383,11 +383,11 @@ public final class Bridged { } BridgeMcp mcp = new BridgeMcp(messages, workers, sessions, identity, presence, - primaryRegistry, callers, metrics, profile -> liveCountRef.get().apply(profile), + primaryRegistry, callers, metrics, new BridgeMcp.CapacitySource(profile -> liveCountRef.get().apply(profile), profile -> { var configured = config.get().profiles().get(profile); return configured == null ? null : configured.maxLoad(); - }, System::nanoTime, () -> config.get().profiles().keySet()); + }, () -> config.get().profiles().keySet(), System::nanoTime)); // CB-559: opt-in config reload. With no `configReload:` block nothing is constructed, so an // upgraded daemon behaves exactly as before — the file is read once at boot and never again. diff --git a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java index 7c5426a..0c960e4 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -76,19 +76,14 @@ public final class BridgeMcp { private final McpSyncServer server; private final CallerResolver authz; // CB-501: null → authorization not enforced (legacy) private final Metrics metrics; // CB-502: null → auth failures not counted - private final Function liveCount; - private final Function maxLoad; - private final LongSupplier clock; - private final Supplier> configuredProfiles; + private final CapacitySource capacity; - /** - * Legacy constructor — no authorization. Retained so existing tests exercise tool behaviour - * without an auth fixture. - */ - public BridgeMcp(MessageService messages, PeerLauncher workers, - SessionManager sessions, ConnectionIdentity identity, MemberPresence presence, - PrimaryRegistry primaryRegistry) { - this(messages, workers, sessions, identity, presence, primaryRegistry, null, null, _ -> 0, _ -> null, System::nanoTime, Set::of); + /** Capacity facts used by {@code bridge_list}; production must supply the placement live count. */ + public record CapacitySource(Function liveCount, Function maxLoad, + Supplier> configuredProfiles, LongSupplier clock) { + /** Inert test-only source. It omits capacity rather than inventing zero live counts. */ + public static CapacitySource none() { return new CapacitySource(_ -> 0, _ -> null, Set::of, System::nanoTime); } + boolean available() { return !configuredProfiles.get().isEmpty(); } } /** @@ -98,21 +93,10 @@ public final class BridgeMcp { * filter, so the REST guard does not cover it. * @param metrics registry for auth-failure counting; may be {@code null} */ - public BridgeMcp(MessageService messages, PeerLauncher workers, - SessionManager sessions, ConnectionIdentity identity, MemberPresence presence, - PrimaryRegistry primaryRegistry, CallerResolver callers, Metrics metrics) { - this(messages, workers, sessions, identity, presence, primaryRegistry, callers, metrics, - _ -> 0, _ -> null, System::nanoTime, Set::of); - } - public BridgeMcp(MessageService messages, PeerLauncher workers, SessionManager sessions, ConnectionIdentity identity, MemberPresence presence, PrimaryRegistry primaryRegistry, - CallerResolver callers, Metrics metrics, Function liveCount, - Function maxLoad, LongSupplier clock, Supplier> configuredProfiles) { - this.liveCount = liveCount; - this.maxLoad = maxLoad; - this.clock = clock; - this.configuredProfiles = configuredProfiles; + CallerResolver callers, Metrics metrics, CapacitySource capacity) { + this.capacity = capacity; McpJsonMapper json = new JacksonMcpJsonMapperSupplier().get(); this.transport = HttpServletStreamableServerTransportProvider.builder() .jsonMapper(json) @@ -226,7 +210,7 @@ public final class BridgeMcp { .toolCall(listTool(), (exchange, _) -> { McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); if (denied != null) return denied; - return listFleet(workers, sessions, messages, liveCount, maxLoad, clock, configuredProfiles.get(), + return listFleet(workers, sessions, messages, capacity, callers == null ? Map.of() : callers.leads(), callerTerminal(exchange)); }) @@ -737,12 +721,11 @@ public final class BridgeMcp { */ static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, Map leads, String selfTerm) { - return listFleet(workers, sessions, null, _ -> 0, _ -> null, System::nanoTime, Set.of(), leads, selfTerm); + return listFleet(workers, sessions, null, CapacitySource.none(), leads, selfTerm); } static McpSchema.CallToolResult listFleet(PeerLauncher workers, SessionManager sessions, MessageService messages, - Function liveCount, Function maxLoad, - LongSupplier clock, Set configuredProfiles, + CapacitySource capacity, Map leads, String selfTerm) { try { Map live = workers.list().stream() @@ -755,14 +738,16 @@ public final class BridgeMcp { .toList(); List roster = sessions.roster(); List> out = roster.stream() - .map(s -> memberCapacityView(s, live.get(s.terminalId()), messages, clock.getAsLong())) + .map(s -> memberCapacityView(s, live.get(s.terminalId()), messages, capacity.clock().getAsLong())) .toList(); - Set profiles = new java.util.TreeSet<>(configuredProfiles); + Set profiles = new java.util.TreeSet<>(capacity.configuredProfiles().get()); roster.stream().map(MemberSession::profile).forEach(profiles::add); - List> capacity = profiles.stream() - .map(profile -> capacityView(profile, liveCount, maxLoad, roster, messages, clock.getAsLong())) - .toList(); - return text(json(Map.of("leads", leadRows, "members", out, "capacity", capacity))); + Map result = new LinkedHashMap<>(); + result.put("leads", leadRows); result.put("members", out); + if (capacity.available()) result.put("capacity", profiles.stream() + .map(profile -> capacityView(profile, capacity.liveCount(), capacity.maxLoad(), roster, messages, + capacity.clock().getAsLong())).toList()); + return text(json(result)); } catch (HerdrException e) { return error("herdr error listing the fleet: " + e.getMessage()); } diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java index 96923cf..fb2e067 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpAuthzTest.java @@ -72,7 +72,7 @@ class BridgeMcpAuthzTest { new PrimaryRegistry(null), enforce ? CallerResolver.withLeadsAndMembers(identity, false, null, Map::of, new MemberRegistry(null)) : null, - metrics); + metrics, BridgeMcp.CapacitySource.none()); return mcp; } diff --git a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java index fef949f..b5b6b00 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -348,7 +348,8 @@ class BridgeMcpTest { SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); sessions.acquire("ltms-local", null, null, null); McpSchema.CallToolResult res = BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), - sessions, null, profile -> 2, profile -> 2, () -> 0, Set.of("ltms-local"), Map.of(), ""); + sessions, null, new BridgeMcp.CapacitySource(profile -> 2, profile -> 2, + () -> Set.of("ltms-local"), () -> 0), Map.of(), ""); String out = textOf(res); assertTrue(out.contains("\"maxLoad\":2"), out); assertTrue(out.contains("\"live\":2"), out); @@ -360,13 +361,23 @@ class BridgeMcpTest { FakeHerdr h = new FakeHerdr(); SessionManager sessions = new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))); String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), - sessions, null, profile -> 0, profile -> 2, () -> 0, Set.of("terra"), Map.of(), "")); + sessions, null, new BridgeMcp.CapacitySource(profile -> 0, profile -> 2, + () -> Set.of("terra"), () -> 0), Map.of(), "")); assertTrue(out.contains("\"profile\":\"terra\""), out); assertTrue(out.contains("\"live\":0"), out); assertTrue(out.contains("\"free\":2"), out); assertTrue(out.contains("\"reclaimable\":0"), out); } + @Test + void inertCapacitySourceOmitsCapacityBlock() { + FakeHerdr h = new FakeHerdr(); + String out = textOf(BridgeMcp.listFleet(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw")), + new SessionManager(workerService(h, "http://gx00.gw:8000", Set.of("gx00.gw"))), null, + BridgeMcp.CapacitySource.none(), Map.of(), "")); + assertFalse(out.contains("\"capacity\":"), out); + } + @Test void listReportsLeadsAndFlagsTheCallersOwnRow() { FakeHerdr h = new FakeHerdr(); @@ -639,8 +650,8 @@ class BridgeMcpTest { assertTrue(out.contains("\"role\":\"dev\""), out); assertTrue(out.contains("\"role\":\"reviewer\""), out); - assertEquals(3, out.split("\"profile\":\"ltms-local\"", -1).length - 1, - "two member rows and one capacity row share the profile: " + out); + assertEquals(2, out.split("\"profile\":\"ltms-local\"", -1).length - 1, + "inert capacity is omitted, leaving the two member rows: " + out); } @Test