feat(mcp): declare experimental.claude/channel capability for push UX

Without this capability declaration in the initialize handshake,
Claude Code's MCP client receives our notifications/claude/channel
emissions but silently drops them — they never become inline
<channel> tags in the conversation. The push-UX bridge added in
PR #2433 ships, fires, and is invisible.

This was anticipated as a failure mode in #2444 §2 ("Notification
arrives but Claude Code doesn't surface it — host doesn't recognize
the method"), and confirmed live in this session: a canvas chat
"hi" landed in the inbox queue (inbox_peek returned it) but never
woke the agent until inbox_peek was called by hand.

The contract matches molecule-mcp-claude-channel/server.ts:374
where the bun bridge declares the same experimental flag.

Refactor: extracted _build_initialize_result() so the handshake
shape is unit-testable. Pure function, no behavioral change beyond
adding the experimental capability to the result.

Tests: 3 new pins on the initialize result (capability presence,
tools-still-there, protocolVersion stable). Closes the live-
verification gap §2 of #2444.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-05-01 13:45:06 -07:00
parent 141ecc1c16
commit 0a87dec50e
2 changed files with 66 additions and 5 deletions

View File

@ -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 ``<channel>``
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":

View File

@ -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"