# Why does an instance of a conformant MCP server fail A2A handoff after capability negotiation succeeds **Date of Record:** 2026 November 14 **Scope:** Applies to conformance fuzzing of servers that advertise both MCP (tool/resource surface) and A2A (agent-to-agent task surface) and are exercised through a shared capability-negotiation handshake. Does not apply to single-protocol deployments, nor to fuzzing that treats each protocol as an isolated test target with no shared session state. ## Core Distinction There are two distinct notions of "conformance" that fuzzers routinely conflate: 1. **Per-message schema conformance** — every individual JSON-RPC frame or A2A task envelope validates against its declared schema, and every declared capability has a matching handler. 2. **Cross-protocol state conformance** — the invariants that must hold *across* the boundary where an MCP-negotiated capability set is used to authorize an A2A task dispatch. A server can be fully conformant under (1) and still violate (2). The failure is not a schema error; it is an *invariant* error. The specific invariant that breaks is: **the capability set negotiated during MCP `initialize` must be a monotonic subset of the capability set the server will honor when an A2A `tasks/send` arrives on the same session.** Fuzzers that validate each frame in isolation never observe the violation, because no single frame is malformed. ## What Was Observed A fuzz campaign against a dual-protocol server (MCP stdio transport plus an A2A HTTP+SSE endpoint sharing a capability registry) produced a reproducible sequence: - Step 1: MCP `initialize` succeeds. Server returns `capabilities` including `tools.listChanged: true` and a custom `x-a2a-bridge` capability. Client and server agree on protocol version `2025-06-18`. - Step 2: Client issues `tools/list`. Server returns a tool set. All frames schema-valid. - Step 3: A `notifications/tools/list_changed` arrives (triggered by a benign concurrent registry mutation in the fuzzer's background thread). The client re-fetches `tools/list`. - Step 4: An A2A `tasks/send` is dispatched referencing a tool ID that was present in the *first* `tools/list` but absent from the *second*. The server accepted the A2A task, queued it, and then **hung** rather than returning a structured error. The A2A task entered `working` and never transitioned. No frame in the trace was malformed. The `list_changed` notification is legal MCP; the A2A task envelope is legal A2A; the tool ID was legal at the moment it was first observed. The failure lives entirely in the *temporal* gap between the two protocol surfaces. A second, subtler variant: the server's A2A task handler resolved the tool ID against a *snapshot* taken at MCP `initialize` time, not against the live registry. This meant a tool added *after* negotiation was dispatchable via A2A but invisible to MCP `tools/list` — a reverse violation of the monotonic-subset invariant. Fuzzers checking only "does the tool exist" pass; fuzzers checking "is the visible set identical across both surfaces at dispatch time" fail. ## Condition of Validity The invariant holds only under the following conditions, and the fuzz oracle is only meaningful when all hold: - The two protocols share a single capability registry (or a registry with a documented consistency window). If MCP and A2A read from independent stores with eventual consistency, the invariant must be restated as *bounded staleness*, and the fuzzer must inject clock skew. - `list_changed` notifications are delivered on the same session that will later carry A2A dispatch. If sessions are isolated, the cross-protocol check is vacuous. - The A2A task references capabilities by the same identifier namespace MCP exposes. If the bridge performs ID translation, the fuzzer must fuzz the *translation function*, not the raw IDs. - Fuzzer concurrency is controlled: the background mutation thread must be paused at the moment of assertion, otherwise the oracle races the mutation and produces false positives. When any of these is false, a "conformance failure" report is likely an artifact of the harness, not the server. ## Decision Boundary The operational decision is **where to place the oracle**: - *Frame-level oracle* (schema validation per message): cheap, fast, catches ~most bugs, never catches this class. - *Session-level oracle* (replay the full trace, assert cross-protocol invariants at each dispatch): expensive, requires trace retention and a pause-the-world hook, but is the only oracle that catches the monotonic-subset violation. The boundary: if the server under test exposes more than one protocol surface backed by shared mutable state, frame-level fuzzing is **insufficient as a conformance gate** and must be paired with a session-level invariant checker. If the surfaces are backed by independent immutable stores, frame-level is sufficient and the session-level oracle is wasted cycles. A practical heuristic: instrument the fuzzer to emit a `list_changed` (or equivalent registry-mutation notification) between every pair of protocol-crossing operations. If the server's behavior under that interleaving is not explicitly specified in its conformance profile, treat the interleaving as **unverified**, not **passing**. Absence of a spec is not evidence of conformance. ## Operational Effect Teams that ran only frame-level fuzzing shipped servers that passed CI and then deadlocked in production whenever a tool registry mutation coincided with an in-flight A2A task — a low-probability, high-severity failure that looks like a hang, not an error, and therefore evades alerting that keys on error codes. The fix at the server level is to make A2A dispatch resolve against the live registry under the same lock that serves `tools/list`, and to return a structured `task rejected: capability_no_longer_available` rather than queueing a task that cannot complete. The fix at the fuzzer level is the session-level oracle described above, plus a mandatory interleaving of registry mutations with cross-protocol dispatch. The general lesson: **conformance fuzzing must fuzz the seams between protocols, not just the frames within them.** The most dangerous failures are the ones where every message is valid and the system still deadlocks. ```python from dataclasses import dataclass, field from typing import Iterable class CrossProtocolInvariantError(RuntimeError): """Raised when an MCP-negotiated capability set is not honored by A2A dispatch.""" @dataclass class CapabilitySnapshot: session_id: str visible_tool_ids: frozenset[str] sequence: int @dataclass class ServerUnderTest: live_registry: set[str] = field(default_factory=set) _snapshots: list[CapabilitySnapshot] = field(default_factory=list) def mcp_initialize(self, session_id: str) -> CapabilitySnapshot: snap = CapabilitySnapshot( session_id=session_id, visible_tool_ids=frozenset(self.live_registry), sequence=len(self._snapshots), ) self._snapshots.append(snap) return snap def mutate_registry(self, add: Iterable[str] = (), remove: Iterable[str] = ()) -> None: self.live_registry.update(add) self.live_registry.difference_update(remove) def a2a_dispatch(self, session_id: str, tool_id: str) -> str: # A conformant server must resolve against the LIVE registry, not the # initialize-time snapshot, and must reject (not queue) unknown tools. if tool_id not in self.live_registry: raise CrossProtocolInvariantError( f"a2a dispatch of {tool_id!r} on session {session_id!r} references " f"a capability absent from the live registry; server must reject " f"with a structured error, not hang" ) return f"task-accepted:{tool_id}" def assert_monotonic_subset( server: ServerUnderTest, snapshot: CapabilitySnapshot, dispatched_tool_id: str, ) -> None: """The negotiated set must be a subset of what the server will honor at dispatch. A tool visible at negotiation time but removed before dispatch is a legitimate registry change; the *server* must reject the dispatch with a structured error. A tool present at dispatch but never visible at negotiation is a reverse violation (shadow capability) and is always a bug. """ live = server.live_registry if dispatched_tool_id in live and dispatched_tool_id not in snapshot.visible_tool_ids: raise CrossProtocolInvariantError( f"shadow capability: {dispatched_tool_id!r} dispatchable via A2A but " f"never advertised via MCP tools/list on session {snapshot.session_id!r}" ) if dispatched_tool_id not in live and dispatched_tool_id in snapshot.visible_tool_ids: # Server must return a structured rejection here; a hang is a failure. try: server.a2a_dispatch(snapshot.session_id, dispatched_tool_id) except CrossProtocolInvariantError: return # correct: structured rejection raise CrossProtocolInvariantError( f"stale capability {dispatched_tool_id!r} was accepted after removal; " f"server resolved against initialize-time snapshot instead of live registry" ) def fuzz_cross_protocol_seam(server: ServerUnderTest) -> None: session = "sess-001" server.mutate_registry(add={"search", "summarize"}) snap = server.mcp_initialize(session) # Interleave a registry mutation between negotiation and dispatch. server.mutate_registry(remove={"summarize"}, add={"translate"}) # Dispatch a tool that was visible at negotiation but is now gone. assert_monotonic_subset(server, snap, "summarize") # Dispatch a shadow capability that was added after negotiation. assert_monotonic_subset(server, snap, "translate") # Dispatch a still-valid capability. assert_monotonic_subset(server, snap, "search") ```