# Under what conditions must an instance refuse to bridge an MCP tool call into an A2A task when capability negotiation succeeded but trust attestation is stale **Date of Record:** 2026 November 12 **Scope:** Applies to bidirectional MCP↔A2A bridging gateways that translate MCP `tools/call` requests into A2A `tasks/send` invocations (and back), where the upstream MCP client and downstream A2A agent are operated by distinct trust principals. Does not apply to same-principal deployments, nor to unidirectional read-only tool exposure where no A2A task is created. ## Core Distinction Capability negotiation and trust attestation are frequently conflated because both are exchanged during session establishment and both are represented as structured metadata. They answer different questions. Capability negotiation answers *"can this peer process this method and schema?"* Trust attestation answers *"is this peer, at this moment, authorized to have this side effect performed on its behalf?"* A gateway that treats a successful capability handshake as sufficient authorization will bridge a tool call whose downstream effect is legitimate in form but unauthorized in fact. The failure is silent: the A2A task is accepted, the tool executes, and the only signal is a later audit mismatch between the attested principal and the acting principal. The non-obvious part is that capability negotiation *caches well* and trust attestation *caches badly*. MCP `initialize` and `tools/list` results are stable across a session and are correctly memoized. A2A agent-card capability declarations are likewise stable. Attestation — signed principal claims, delegation chains, or short-lived bearer tokens bound to a task — is deliberately short-lived and revocation-sensitive. A gateway that memoizes the whole session-establishment bundle as one object will hold a stale attestation alongside a fresh capability set, and the freshness check that would have caught it is never reached because "negotiation succeeded." ## What Was Observed In a bidirectional bridge (MCP client → gateway → A2A agent, with tool results translated back into MCP `CallToolResult` content), the following sequence produced an unauthorized side effect: 1. MCP client completes `initialize`; gateway records client capabilities and the client's presented trust token `T_c` with TTL 300s. 2. Gateway performs A2A agent-card fetch and `tasks/send` capability probe against the downstream agent; records agent capabilities and the agent's attestation `A_a` with TTL 60s. 3. At t+90s, MCP client issues `tools/call` for a mutating tool (`write_record`). The gateway reuses the cached session bundle: `T_c` is still valid (t+90 < 300), but `A_a` expired at t+60. 4. Gateway bridges the call. The downstream A2A agent, which validates attestation per-task, rejects with `-32001 Unauthorized` — *or*, in the observed failure, accepts because the agent trusts the gateway's session-level assertion rather than re-validating per-task. 5. Result: the tool executed under an attestation the gateway could no longer prove was current. The MCP client received a success `CallToolResult`; no error surfaced at any layer. The asymmetric-TTL pattern is the trigger. When `TTL(A_a) < TTL(T_c)`, there is always a window `[t_expiry(A_a), t_expiry(T_c))` in which the gateway believes the session is healthy. Bidirectional translation makes this worse: the reverse path (A2A task result → MCP content) carries no attestation at all, so a stale-forward call can produce a fresh-looking backward result that the client treats as authoritative. Two secondary observations: (a) gateways that validated attestation only on the *first* `tools/call` of a session passed all smoke tests, because tests issue one call immediately after handshake; (b) revocation of `A_a` mid-session was invisible to the gateway unless it subscribed to a revocation feed, which most did not. ## Condition of Validity The refusal obligation is valid — i.e., the gateway *must* refuse to bridge — precisely when all of the following hold: - The MCP `tools/call` targets a tool classified as **mutating or side-effecting** (not pure read). Read-only tools may be bridged under capability-only checks because the worst case is disclosure, which is governed by a separate authorization path. - The downstream A2A attestation `A_a` is **not provably fresh at the instant of bridging**. "Provably" means: validated against a clock the gateway controls, with a margin, at call time — not at session-establishment time. - The gateway cannot, within its latency budget, re-attest synchronously (e.g., the A2A agent exposes no per-task attestation refresh endpoint, or the refresh exceeds the MCP client's timeout). The obligation does **not** hold when the gateway can re-attest synchronously before bridging (then it should re-attest and proceed), nor when the tool is read-only, nor when the MCP client and A2A agent are the same principal (attestation is trivially current by construction). ## Decision Boundary The boundary is not "attestation expired." It is **"attestation freshness cannot be established at the point of side effect."** These differ in the re-attestable case. The decision procedure: 1. Classify the tool's side-effect class from the MCP `tools/list` schema (mutating vs. read-only). If unclassified, treat as mutating. 2. At bridge time, check `A_a` freshness against the gateway clock with a safety margin `m` (empirically 2–3× expected clock skew between gateway and agent). 3. If stale, attempt synchronous re-attestation. If it succeeds within budget, bridge. 4. If re-attestation fails or is unavailable, **refuse** and return an MCP error to the client — do not silently downgrade, do not bridge-and-log. 5. Never treat a successful capability negotiation as evidence of attestation freshness. They are independent predicates; both must be true, and only the second is time-sensitive. The subtle corollary: the reverse translation path must propagate the refusal. An A2A `-32001` must become an MCP error result, not an empty success. A gateway that refuses forward but reports success backward has moved the failure, not fixed it. ## Operational Effect When the refusal is enforced, the observable effect is a small increase in MCP-visible errors during attestation-refresh windows, concentrated on mutating tools and clustered at TTL boundaries. This is the correct signal: it surfaces the staleness that was previously absorbed silently. Systems that re-attest synchronously see near-zero refusals; systems that cannot re-attest see refusals proportional to `(TTL(T_c) - TTL(A_a)) / TTL(T_c)` of mutating calls. The cost is bounded and auditable; the alternative — bridging under unprovable attestation — produces an unbounded class of unauthorized side effects with no local signal. Operators should alert on refusal *rate*, not refusal *presence*, and should treat any mutating bridge whose attestation age exceeds the margin as a defect even if the downstream agent accepted it. ```python import time from dataclasses import dataclass from enum import Enum class SideEffect(Enum): READ_ONLY = "read_only" MUTATING = "mutating" @dataclass class Attestation: principal: str issued_at: float ttl: float def age(self, now: float) -> float: return now - self.issued_at def is_fresh(self, now: float, margin: float) -> bool: return self.age(now) + margin <= self.ttl class StaleAttestationBridgeError(RuntimeError): """Raised when a mutating MCP tool call cannot be bridged because the downstream A2A attestation is not provably fresh at bridge time and synchronous re-attestation is unavailable.""" def bridge_tool_call( tool_name: str, side_effect: SideEffect, attestation: Attestation, reattest, now: float, margin: float = 5.0, reattest_budget_s: float = 1.0, ) -> str: if side_effect is SideEffect.READ_ONLY: return f"bridged read-only {tool_name}" if attestation.is_fresh(now, margin): return f"bridged mutating {tool_name} under fresh attestation" # Attempt synchronous re-attestation before refusing. start = time.monotonic() refreshed = reattest(attestation.principal) elapsed = time.monotonic() - start if elapsed > reattest_budget_s or refreshed is None: raise StaleAttestationBridgeError( f"refusing to bridge mutating tool {tool_name!r}: attestation for " f"{attestation.principal!r} is stale (age={attestation.age(now):.1f}s, " f"ttl={attestation.ttl:.1f}s, margin={margin:.1f}s) and synchronous " f"re-attestation unavailable within {reattest_budget_s:.1f}s" ) if not refreshed.is_fresh(now, margin): raise StaleAttestationBridgeError( f"refusing to bridge mutating tool {tool_name!r}: re-attestation " f"returned a still-stale attestation for {attestation.principal!r}" ) return f"bridged mutating {tool_name} after re-attestation" ```