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 ee9046d..97945ef 100644 --- a/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java +++ b/bridged/src/main/java/dev/ltms/bridged/mcp/BridgeMcp.java @@ -33,6 +33,7 @@ import jakarta.servlet.http.HttpServlet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -151,8 +152,8 @@ public final class BridgeMcp { Runnable onAccepted = () -> primaryRegistry.recordDelegation(target, caller); // wait defaults to true (block for the reply); wait:false is fire-and-poll. return Boolean.FALSE.equals(a.get("wait")) - ? sendAsync(messages, target, content, onAccepted) - : send(messages, target, content, timeoutMs(a), onAccepted); + ? sendAsync(messages, target, content, onAccepted, workers.profiles()) + : send(messages, target, content, timeoutMs(a), onAccepted, workers.profiles()); }) // bridge_reply's identity is the CONNECTION, never an argument — so the authz check // is "is this caller a worker at all", and it can only ever reply as itself. @@ -390,10 +391,20 @@ public final class BridgeMcp { * a BUSY interloper never claims a turn it did not win. {@code null} disables recording. */ static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, - Long timeoutMs, Runnable onAccepted) { + Long timeoutMs, Runnable onAccepted) { + return send(messages, sessionId, content, timeoutMs, onAccepted, Set.of()); + } + + /** As above, rejecting a configured profile name before accepting a send to it. */ + static McpSchema.CallToolResult send(MessageService messages, String sessionId, String content, + Long timeoutMs, Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } + McpSchema.CallToolResult targetError = profileTargetError(sessionId, profiles); + if (targetError != null) { + return targetError; + } long timeout = clamp(timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs); try { return formatReply(messages.send(sessionId, content, timeout, onAccepted), timeout); @@ -473,14 +484,33 @@ public final class BridgeMcp { * (CB-548) so an async flooding send records delegator ownership exactly once it is accepted. */ static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, - Runnable onAccepted) { + Runnable onAccepted) { + return sendAsync(messages, sessionId, content, onAccepted, Set.of()); + } + + /** As above, rejecting a configured profile name before handing out an async ticket. */ + static McpSchema.CallToolResult sendAsync(MessageService messages, String sessionId, String content, + Runnable onAccepted, Set profiles) { if (isBlank(sessionId) || isBlank(content)) { return error("sessionId and content are required"); } + McpSchema.CallToolResult targetError = profileTargetError(sessionId, profiles); + if (targetError != null) { + return targetError; + } String ticket = messages.sendAsync(sessionId, content, onAccepted); return text("accepted — task delegated. Poll bridge_poll with ticket=" + ticket); } + /** A configured profile is never a send target; other unknown values may be herdr-owned panes. */ + private static McpSchema.CallToolResult profileTargetError(String sessionId, Set profiles) { + if (profiles.contains(sessionId)) { + return error("unknown send target \"" + sessionId + "\": it is a configured profile name, not a " + + "session id. Call bridge_list to find a member or lead sessionId."); + } + return null; + } + /** {@code bridge_poll}: check an async delegation by ticket, or drain a worker's inbox by target. */ static McpSchema.CallToolResult poll(MessageService messages, String ticket, String target) { if (!isBlank(target)) { 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 ab264ae..f1bad0f 100644 --- a/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/mcp/BridgeMcpTest.java @@ -53,6 +53,18 @@ class BridgeMcpTest { return ((McpSchema.TextContent) r.content().getFirst()).text(); } + private void assertSendRoundTrips(String target, Set profiles) throws Exception { + CompletableFuture send = CompletableFuture.supplyAsync( + () -> BridgeMcp.send(messages, target, "hi", 4000L, null, profiles)); + long deadline = System.currentTimeMillis() + 3000; + while (!rendezvous.isWaiting(target) && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertTrue(rendezvous.isWaiting(target), "send should be accepted for " + target); + BridgeMcp.reply(messages, target, "received"); + assertEquals("received", textOf(send.get(6, TimeUnit.SECONDS))); + } + private static ClaudeCodeLauncher workerService(FakeHerdr h, String baseUrl, Set allow) { BridgedConfig.Profile cfg = new BridgedConfig.Profile( "ltms-local", baseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null, @@ -140,6 +152,31 @@ class BridgeMcpTest { assertTrue(BridgeMcp.send(messages, "term_a", " ", null).isError()); } + @Test + void sendRejectsAConfiguredProfileNameBeforeAcceptingIt() { + McpSchema.CallToolResult blocking = BridgeMcp.send(messages, "sol", "hi", 100L, null, Set.of("sol")); + McpSchema.CallToolResult async = BridgeMcp.sendAsync(messages, "sol", "hi", null, Set.of("sol")); + + assertTrue(blocking.isError()); + assertTrue(async.isError()); + assertTrue(textOf(blocking).contains("sol")); + assertTrue(textOf(blocking).contains("configured profile name")); + assertTrue(textOf(blocking).contains("bridge_list")); + assertFalse(textOf(async).contains("ticket=")); + } + + @Test + void sendAllowsPeerLeadMemberAndUnclassifiedTargets() throws Exception { + Set profiles = Set.of("sol"); + + assertSendRoundTrips("term_peer_lead", profiles); + assertSendRoundTrips("term_live_member", profiles); + + // A herdr-owned pane outside the bridge roster cannot be classified at accept time. + McpSchema.CallToolResult result = BridgeMcp.send(messages, "external-pane", "hi", 10L, null, profiles); + assertFalse(result.isError(), "an unclassified target must not be rejected at acceptance time"); + } + @Test void replyWithNoPendingSendIsQueuedNotError() { // CB-307: a reply with no open send is now queued in the inbox, not an error.