The previous post describes the design: the security core (cryptographic delegation through a Vault), the four constraints it realises, and the two carriers that move a signed approval to the human and back, single-domain (a single MCP agent) and multi-domain (A2A across agents). This post walks the reference implementation. Each section names the relevant file and the relevant property, in order.

The repo (a2a-mcp-bridge-reference, Apache-2.0) runs locally with pip install -e . && pytest.

The Vault Protocol

The two-method Vault Protocol in bridge/vault/interface.py is the seam the rest of the design rests on.

class Vault(Protocol):
    def mint(self, signed: SignedAuthorizationDetails) -> MintedCredential: ...
    def consume(self, credential: str, command: str, args: dict) -> MintedCredential: ...

mint is “human signature in, machine credential out.” consume is “machine credential + live request in, executed action out.” Tier 1 and Tier 2 both implement this exact contract. The dispatcher does not know which one it holds.

The typed exceptions alongside the Protocol (SignatureMismatch, CredentialDrift, CredentialReplay, CredentialExpired, UnknownIssuer, WrongAudience, PayloadDriftAtMint) form the vocabulary of failure. Every layer raises from this set. The dispatcher’s reject-path is a single except VaultError as exc: return ApprovalRequired(reason=type(exc).__name__). Calling code does not need to know which layer rejected - the exception type identifies it.

The Protocol is what makes tier graduation a configuration change rather than a refactor. Tier 1 → Tier 2 is one substitution at the construction site. No call sites change.

The multi-domain carrier: A2A↔MCP translation

bridge/translation/a2a_mcp.py is the carrier for the multi-domain path: when a destructive action is proposed by a remote agent, it translates A2A’s auth_required event onto an MCP elicitation so the human’s host can approve it. About 200 lines, pure data, no I/O, no cryptography. Two directions:

  • a2a_auth_required_to_mcp_elicitation takes an A2A auth_required event with structured parts and produces an MCP elicitation/create request in URL mode, preserving the authorization_details byte-for-byte.
  • mcp_elicitation_response_to_a2a_resume takes the MCP elicitation/response (carrying the signed payload from the consent page) and produces an A2A resume call with the signed payload attached.

The tests under tests/unit/test_translation.py lock the byte-identity property: round-tripping authorization_details through translation does not mutate a single key, value, or ordering. The property matters for Layer 1: if translation mutated args even subtly, through whitespace, key reordering, or type coercion, the human’s signature would fail to verify at the Vault even when the human approved correctly. Translation has to be transparent.

The module is small because it only handles data. The URL-mode constraint is a one-line policy: every elicitation the bridge emits for consent is mode="url", never mode="form". The MCP spec requires URL mode for sensitive consent - the bridge enforces it unconditionally rather than choosing based on a heuristic.

The single-domain carrier: MCP elicitation emission

The multi-domain carrier needs the translation module because the action originates in another agent. The single-domain case does not: one MCP agent owns the whole flow, so it emits the elicitation itself. bridge/mcp/hitl.py holds the gate that does it, and bridge/mcp/server.py wires it to the tool-call handler.

On a HITL-gated tools/call, the dispatcher returns ApprovalRequired. The handler then takes one of two branches:

if result.approval_required and gate is not None:
    token = gate.try_resume(command=cmd, args=cmd_args, caller_id=caller_id)
    if token is not None:
        result = invoker.invoke(spec, arguments, approval_token=token, caller=caller)
    else:
        elicit = gate.begin(command=cmd, args=cmd_args,
                            caller_id=caller_id, binding_message=binding)
        raise UrlElicitationRequiredError([elicit], message=binding)

begin creates a consent session and returns a URL-mode ElicitRequestURLParams pointing at it. Raising UrlElicitationRequiredError surfaces the MCP URL_ELICITATION_REQUIRED error (-32042) to the client, which opens the URL out-of-band. The human approves at the consent server, and the client retries the same tools/call. On the retry, try_resume finds the approved session, mints a Vault credential from the human’s signed payload, and the handler re-dispatches with that credential, which executes.

The retry has no A2A context_id to correlate on, so the consent-session id is derived deterministically from (caller, command, args) (consent_session_id). The retry recomputes the same id and finds its own pending consent, with no client-side echo. SignatureReplay, the consent-atomicity guard, stops the retry from minting twice.

tests/e2e/test_mcp_elicitation_emission.py drives this through the actual MCP server over the SDK’s in-memory client/server harness: emit the -32042, approve at the consent server, retry, target deleted and bystander untouched. This is the path a single MCP agent runs with no A2A, and it is the one the reference exercises end to end.

One contract change makes it possible. The MCP surface used to be read-only, filtering HITL-gated tools out entirely. Exposing delete_task for the elicitation flow uses an explicit MCP_HITL_ALLOWLIST: a gated tool surfaces only when the gate is wired and only if it is on that allowlist. With no gate the surface stays read-only, and a gated tool on neither allowlist never surfaces.

The core, Tier 1: InProcessVault

bridge/vault/in_process.py, about 150 lines.

canonical_authorization_bytes sits at the top of the module and is the first function to read. It carries the cross-language signer contract and is deliberately simple: a json.dumps with sort_keys=True, separators=(",", ":"), ensure_ascii=True, and an integer exp. Six fields go into the encoded object: cmd, args, rar_type, exp, approver_id, and binding_message. The function rejects float values anywhere in args before encoding, because float repr differs across language implementations and would silently break byte-identity. The CANONICAL.md document in bridge/vault/ is the contract for non-Python signers; a test fixture pins the byte-exact output for a non-ASCII case (ï, U+00EF) so a JavaScript signer has a concrete target.

InProcessVault.mint is the Layer-1 implementation in Tier 1:

def mint(self, signed):
    canonical = canonical_authorization_bytes(...)
    expected = hmac.new(self._secret, canonical, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signed.signature):
        raise SignatureMismatch(...)
    if time.time() > signed.exp:
        raise CredentialExpired(...)
    if signed.exp - time.time() > self._max_signed_payload_ttl_seconds:
        raise PayloadDriftAtMint(...)

    # Signature-replay check + record, then mint
    signature_hash = hashlib.sha256(canonical).hexdigest()
    with self._lock:
        if signature_hash in self._consumed_signatures:
            raise SignatureReplay(...)
        self._consumed_signatures.add(signature_hash)
        ...
        self._issued[jti] = minted
    return minted

Four checks before mint: signature, expiry, TTL bound, signature-replay. The TTL bound exists because without it a misbehaving signer could request a long exp and the Vault would oblige. The signature-replay check (_consumed_signatures) closes constraint 2 (consent atomicity): one signed payload mints at most one credential. The check runs after structural validation so an invalid payload cannot poison the set, and sits under the same lock as _issued so two concurrent presentations cannot both succeed.

InProcessVault.consume performs the same parameter-binding check the Tier-2 RS does, in-process. Drift raises CredentialDrift. Replay raises CredentialReplay. The exception types are identical to those of Tier 2.

Tier 1 closes:

  • Prompt injection mutating args. The bridge cannot sign on the LLM’s behalf - the signing surface sits behind a user gesture.
  • Parameter drift between approval and execution. The signed payload binds to specific bytes - mutating them invalidates the signature.
  • Mint replay. A captured signed payload cannot be replayed to mint a second credential within the TTL - SignatureReplay fires on the second presentation.
  • Replay at consume. The _issued set tracks credentials by internal ID; a second consume fails.

Tier 1 does not close agent-process compromise. The HMAC secret lives in the bridge process. An attacker who controls the process can sign arbitrary actions. That is the threat Tier 2 addresses.

Tier 1 is sufficient wherever the threat model is “the LLM may be tricked, but the process running it is trustworthy.” Many production deployments fit that shape.

The core, Tier 2: OAuthVault and the separated JwtResourceServer

bridge/vault/oauth.py is Tier 2’s Vault. Same Vault Protocol, different implementation. mint produces a JWT instead of an internal credential ID:

def mint(self, signed):
    # ... same four checks as InProcessVault: signature, rar_type, exp, signature-replay ...
    with self._lock:
        if signature_hash in self._consumed_signatures:
            raise SignatureReplay(...)
        self._consumed_signatures.add(signature_hash)
    claims = {
        "iss": self._issuer,
        "aud": self._audience,
        "exp": signed.exp,
        "jti": secrets.token_hex(8),
        "authorization_details": [{
            "type": signed.rar_type,
            "command": signed.command,
            "args": signed.args,
        }],
    }
    token = jwt_encode(claims, self._mint_secret)
    return MintedCredential(credential=token, jti=claims["jti"], ...)

The _consumed_signatures set tracks the same canonical-bytes hashes Tier 1 tracks, with the same check-and-add semantics under the same lock. Tier 2’s structural difference is downstream: the credential it produces is a JWT bound to authorization_details rather than an in-process record. Consent atomicity (constraint 2) sits at the same call site in both tiers.

The JWT is HS256 in the demo, which is a limitation: RS compromise yields mint capability. This is an HS256-specific weakness that production RS256/ES256 deployments do not have. The module docstring at the top of oauth.py describes the swap.

Two design choices in OAuthVault:

Algorithm pinning. jwt_decode rejects alg=none and alg=RS256 before attempting HMAC verification. Both attacks (the unsigned-token trick and the RS256↔HS256 key-confusion family) are well-documented JWT failure modes. Pinning at the decoder closes them structurally rather than relying on the verifier to fail. See tests/unit/test_oauth_vault.py::test_alg_none_rejected and test_alg_rs256_rejected.

aud claim shape. RFC 7519 permits aud to be a string or an array of strings. Production AS implementations (Keycloak, Authlete) commonly emit array shape; OAuthVault mints scalar. The RS accepts both, so the “production swap doesn’t change call sites” property does not quietly break at the audience check. See tests/e2e/test_three_layer_enforcement.py::test_rs_accepts_token_when_aud_is_array.

The separated resource server lives in bridge/rs/jwt_resource_server.py. It is Layer 3 in code:

class JwtResourceServer:
    def execute(self, command, args, credential):
        claims = jwt_decode(credential, self._verification_secret)  # own key
        self._validate_claims(claims)                                # own iss/aud/exp
        jti = self._consume_authorization_details(claims, command, args)  # own state + binding
        return self._execute_command(command, args, jti)

Four steps, each independent of the Vault’s consume: own verification key, own iss/aud/exp checks, own consumed-jti set, own binding check between the live (command, args) and the JWT’s authorization_details.

The _consume_authorization_details method is where the binding check lives:

ad = claims["authorization_details"][0]
if ad["command"] != command:
    raise CredentialDrift(...)
if ad["args"] != args:
    raise CredentialDrift(...)

The live (command, args) must match the credential’s authorization_details byte-for-byte. The args comparison is exact: no key normalization, no value coercion. If the human signed {"task_id": "t-42"} and the RS sees {"task_id": "t-99"}, that is drift.

Layer 2: the dispatcher pass-through

bridge/core/dispatcher.py. The dispatcher’s job is to hold the HITL gate and route execution. _execute_via_rs is the relevant method:

def _execute_via_rs(self, command, args, approval_token):
    outcome = self._resource_server.execute(command, args, approval_token)
    if isinstance(outcome, RsRejected):
        return ApprovalRequired(reason=outcome.reason, detail=outcome.detail)
    if isinstance(outcome, RsSuccess):
        return CommandSuccess(items=outcome.items, meta=outcome.meta)
    return outcome  # RsError pass-through

Four lines that touch the credential. No transformation step, no re-signing step. The token the bridge received is the token the RS sees.

Layer 2’s claim, “bridge cannot alter the minted claim,” is a structural property of this code shape. No dynamic test can prove it, since no dynamic test can prove “the dispatcher does not mutate the credential”; the right test is reading the code. What tests/e2e/test_three_layer_enforcement.py::test_layer3_rs_catches_credential_mutation_in_transit demonstrates is the defence-in-depth fallback. If anything between mint and RS were to mutate the credential (a future refactor introducing a transformation, a buggy middleware), the RS would catch it. Layer 3 is the safety net for Layer 2’s structural promise.

The asymmetry described in the previous post shows up here too. A bug in _execute_via_rs that mutated the credential would be caught at Layer 3. A bug in OAuthVault.mint that minted without verifying the human signature would not. The RS validates the JWT, but the human-signature verification is upstream of the JWT. Layers 2 and 3 catch each other; Layer 1 stands alone.

Scope enforcement at the dispatcher

Dispatcher.execute runs one check before HITL routing happens. ToolSpec.required_scopes declares the scopes a caller must hold to attempt the tool; the dispatcher reads the caller’s CallerIdentity.scopes and refuses with Unauthorized if any are missing.

spec = SPECS_BY_CLI_NAME.get(command_name)
if caller is not None and spec is not None and spec.required_scopes:
    missing = [s for s in spec.required_scopes if s not in caller.scopes]
    if missing:
        return Unauthorized(
            command=command_name,
            required_scopes=tuple(spec.required_scopes),
            caller_scopes=tuple(sorted(caller.scopes)),
            ...
        )

The check sits before the HITL gate by design. A read-scoped caller calling a write tool fails here whether or not the tool is HITL-gated, which separates “which actions is this caller allowed to attempt” from “for this attempt, did a human sign the specific bytes.” Both questions have answers in the same dispatcher, in that order, and Unauthorized and ApprovalRequired are distinct outcomes so the audit log can tell scope refusals apart from approval refusals.

The reference’s task-tracker tools declare scopes in their ToolSpec definitions: list_tasks and get_task require tasks.read; create_task, update_task, and delete_task require tasks.write. The MCP surface passes the caller identity through to the dispatcher unchanged, so a tasks.read bearer attempting create_task over MCP fails at the dispatcher rather than at the (non-existent) HITL gate. The seven cases this property has to satisfy live in tests/e2e/test_scope_enforcement.py.

Structural binding at the bridge

bridge/consent/url_mode.py defines the ProposedAction dataclass:

@dataclass(frozen=True)
class ProposedAction:
    session_id: str
    command: str
    args: Mapping[str, object]  # MappingProxyType, deep-copied
    ...

frozen=True blocks action.command = "delete-something-else". The MappingProxyType wrapper blocks action.args["task_id"] = "t-99". Between the moment the bridge emits an elicitation and the moment the human signs, the action description is structurally immutable. No code path can mutate the proposed action without raising.

This is the bridge-side enforcement of the “human signs what they saw” property. Layer 1 verifies the signature - the dataclass design ensures the bytes the human signed are the bytes the bridge displayed. The two together are what makes “the credential is bound to the parameters the human approved” structural rather than aspirational.

The multi-domain path: sub-agents and no bearer-passing

A2A’s task-lifecycle is what carries the human’s approval across agents without inventing a new propagation protocol. The safety comes from the Vault, not from A2A: the Vault and RS code does not know or care how deep the calling chain is, and only sees a signed payload to mint against. A2A moves the message, and the core holds the trust.

A user-facing agent A receives a request that requires calling a remote sub-agent B for part of the work, reaching B over A2A. When B’s execution hits a destructive action, B emits its own auth_required event into its A2A task stream. A receives B’s auth_required event as part of B’s task output, translates it via bridge.translation.a2a_mcp into an MCP elicitation, and surfaces it to the human. The human reads B’s proposed action with B’s specific arguments, approves, and the resulting signed payload travels back through A to B. B presents the signed payload to its Vault, which mints a credential bound to B’s action.

Nothing in this flow has A handing B a bearer token. B never inherits A’s credentials, and the human’s approval names B’s specific action rather than A’s. Audit attribution names B’s client_id as the agent, names the human as the sub of B’s per-action token, and records B’s authorization_details. The audit log row reads “the human authorized B to delete task t-42,” not “agent swarm did something.”

A compromised intermediate agent cannot escalate this pattern. If A is malicious and tries to forward its own credential to B’s RS in B’s name, the RS rejects: the credential’s aud is A’s resource server, not B’s. If A is malicious and tries to fabricate signed payloads on the human’s behalf, the Vault rejects: A does not hold the human’s signing key. The cryptographic chain runs from the human to the leaf RS, with intermediate agents in the data path but not the trust path.

The same shape holds at any chain depth. A → B → C → D, where D is the one with the destructive action, just bubbles the auth_required up three protocol hops instead of one. Each hop is an A2A SSE stream, each translation-only. The signed payload at the top of the chain is bound to D’s action by canonical bytes.

Most published agent frameworks address the multi-agent case by sharing a session token. The audit trail collapses to “the agent system did this,” and B’s prompt-injection vulnerability becomes A’s audit problem. A2A’s task lifecycle is the alternative carrier, and the human’s cryptographic intent flows the full depth of the chain without ever being borrowable along the way.

Reading order in the repo

If you want to read the implementation cold:

  1. bridge/vault/interface.py: the Protocol and exception vocabulary
  2. bridge/vault/in_process.py: Tier 1 Vault, around 150 lines, including canonical_authorization_bytes
  3. bridge/vault/oauth.py: Tier 2 Vault, JWT helpers, algorithm pinning
  4. bridge/rs/jwt_resource_server.py: Layer 3 in code
  5. bridge/core/dispatcher.py: the HITL gate and the Layer-2 pass-through
  6. bridge/mcp/hitl.py + bridge/mcp/server.py: the single-domain carrier (URL-mode elicitation emission + resume)
  7. bridge/translation/a2a_mcp.py: the multi-domain carrier (A2A↔MCP translation)
  8. bridge/consent/url_mode.py: URL-mode consent server, frozen ProposedAction
  9. bridge/walkthrough.py: narrated end-to-end run (bridge walkthrough --tier 2)
  10. bridge/vault/CANONICAL.md: cross-language signer contract

Tests that exercise the publishable claims:

  • tests/e2e/test_three_layer_enforcement.py: Layers 1 and 3 tested through real code; Layer 2 catches via Layer 3 fallback. Includes test_vault_rejects_binding_message_swap covering the cosmetic-drift case.
  • tests/e2e/test_dispatcher_vault_integration.py::test_tier2_attacker_without_user_secret_cannot_forge_a_new_signature: the production-shape Zero-Trust property.
  • tests/e2e/test_scope_enforcement.py: dispatcher-level scope refusal before HITL routing.
  • tests/e2e/test_mcp_elicitation_emission.py: the single-domain path through the actual MCP server (emit, approve, retry, execute). tests/unit/test_mcp_hitl_gate.py covers the gate’s emit/resume primitive directly.
  • tests/e2e/test_mcp_hitl_building_blocks.py: the core plus the carriers composed by hand.

139 tests total. Run with pytest. The [mcp] extra is needed for the protocol-level tests - the Vault/RS/dispatcher tests run on stdlib.

What this reference is not

The repo deliberately leaves several things out. Each is recorded in the README, the architecture doc, SECURITY.md, and the relevant module docstrings.

  • Not a production OAuth AS. OAuthVault is an HS256 in-process stand-in. Production deployments substitute Keycloak, Authlete, Auth0, Curity, or an equivalent.
  • Not a durable single-use store. Both _consumed sets are in-process Python sets. Production needs sqlite or Redis with TTL.
  • Not a client-side signing custodian. demo_signer.py runs server-side because the demo is self-contained. Production must move signing client-side (WebAuthn / Passkey) and remove the user signing key from the bridge process.
  • Single-domain consent surface still runs in-process. The MCP server now emits URL-mode elicitations and resumes (the single-domain path runs end to end in code), but the consent surface it points at runs on the bridge process for self-containedness. The “watch but not subvert” property needs that surface in a separate trust domain plus a faithful MCP host. The in-process demo delivers the mechanics, not the trust separation.
  • Untrusted MCP hosts only partially addressed. The elicitation_id carries a process-local HMAC tag so a host that doesn’t hold the tag secret cannot fabricate IDs; multi-replica or cross-host deployments still need a shared tag secret or a different carrier.

The reference is a starting point. The OAuth AS swap, the durable store, and the client-side custodian are deployment-specific.


Repo: a2a-mcp-bridge-reference. Apache-2.0. The test suite is the spec.