diff --git a/workspace/a2a_mcp_server.py b/workspace/a2a_mcp_server.py index b3255bf0..eb4402aa 100644 --- a/workspace/a2a_mcp_server.py +++ b/workspace/a2a_mcp_server.py @@ -149,6 +149,30 @@ async def handle_tool_call(name: str, arguments: dict) -> str: _CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel" +def _build_initialize_result() -> dict: + """MCP initialize handshake result. + + The ``experimental.claude/channel`` capability declaration is what + tells Claude Code's MCP client to route our + ``notifications/claude/channel`` emissions as conversation + interrupts (push UX). Without it the notification arrives over the + wire but is silently dropped instead of becoming a ```` + tag in the next agent turn — matching the + "Notification arrives but Claude Code doesn't surface it" failure + mode anticipated in molecule-core#2444. Mirrors the contract + declared by the molecule-mcp-claude-channel bun bridge + (server.ts:374). + """ + return { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {"listChanged": False}, + "experimental": {"claude/channel": {}}, + }, + "serverInfo": {"name": "a2a-delegation", "version": "1.0.0"}, + } + + def _build_channel_notification(msg: dict) -> dict: """Transform an ``InboxMessage.to_dict()`` into the MCP notification envelope expected by Claude Code's channel-bridge contract. @@ -246,11 +270,7 @@ async def main(): # pragma: no cover await write_response({ "jsonrpc": "2.0", "id": req_id, - "result": { - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": "a2a-delegation", "version": "1.0.0"}, - }, + "result": _build_initialize_result(), }) elif method == "notifications/initialized": diff --git a/workspace/tests/test_a2a_mcp_server.py b/workspace/tests/test_a2a_mcp_server.py index b08dd3a8..fdd1251d 100644 --- a/workspace/tests/test_a2a_mcp_server.py +++ b/workspace/tests/test_a2a_mcp_server.py @@ -237,3 +237,44 @@ def test_build_channel_notification_handles_missing_fields_gracefully(): assert meta["activity_id"] == "" assert meta["peer_id"] == "" assert meta["kind"] == "" + + +# ============== initialize handshake — capability declaration ============== +# Without `experimental.claude/channel`, Claude Code's MCP client drops +# our notifications/claude/channel emissions instead of routing them as +# inline conversation interrupts. Anticipated as a failure mode in +# molecule-core#2444 ("notification arrives but Claude Code doesn't +# surface it"). Pin the declaration here so a refactor of +# _build_initialize_result can't silently strip the flag. + + +def test_initialize_declares_experimental_claude_channel_capability(): + """Without this capability the push-UX bridge ships, the + notifications fire, and nothing happens in the host — silent. This + is the contract that flips Claude Code's routing on.""" + from a2a_mcp_server import _build_initialize_result + + result = _build_initialize_result() + experimental = result["capabilities"].get("experimental", {}) + + assert "claude/channel" in experimental, ( + "experimental.claude/channel capability is required for Claude " + "Code to surface our notifications/claude/channel emissions as " + "conversation interrupts (issue #2444 §2). Removing this would " + "regress live push UX while leaving every unit test green." + ) + + +def test_initialize_keeps_tools_capability(): + """Pin the tools capability too — losing it would break tools/list.""" + from a2a_mcp_server import _build_initialize_result + + assert "tools" in _build_initialize_result()["capabilities"] + + +def test_initialize_protocol_version_is_pinned(): + """MCP protocol version is part of the handshake contract; bumping + it changes what fields the host expects.""" + from a2a_mcp_server import _build_initialize_result + + assert _build_initialize_result()["protocolVersion"] == "2024-11-05"