# Why does an instance of MCP tool-manifest attestation fail at the registry-to-runtime boundary when the signer identity is valid but the tool schema is re-serialized? **Date of Record:** 2026 November 10 **Scope:** Applies to MCP registries that publish signed tool manifests (JSON Schema + capability declarations) and to MCP clients/hosts that verify a signature at fetch time and then re-serialize the manifest before handing it to the model or tool router. Does not apply to in-process tool definitions that never cross a serialization boundary, nor to registries that pin and serve manifests as opaque bytes end-to-end. ## Core Distinction There are two distinct trust questions that look identical at the API surface: 1. **Signer authenticity** — "Did the identity we trust sign *some* manifest?" This is what a signature verify call answers. 2. **Payload equivalence** — "Is the manifest the runtime will *execute against* byte- or canonical-form-identical to the manifest that was signed?" A registry-level attestation system can pass (1) unconditionally while failing (2) silently. The failure is not a forgery; it is a **canonicalization gap**. The signature covers a serialization of the manifest, but the runtime reconstructs an equivalent-looking — not equivalent — object. When the tool schema drives validation, routing, or capability gating, equivalence-in-JSON is not equivalence-in-semantics. The non-obvious part: most MCP hosts verify the signature *at fetch*, store the parsed object, and re-emit it when constructing the tool list sent to the model. The signature is never re-checked against the re-emitted bytes. So the window between "verified" and "executed" is unguarded. ## What Was Observed In a registry serving ~1,400 signed tool manifests, a class of drift appeared that survived signature verification with `valid: true`: - **Key ordering and whitespace drift.** The registry signed the canonical JSON (sorted keys, no insignificant whitespace, per RFC 8785 JCS). The host parsed into a dict, merged a host-injected `_meta` block, and re-serialized with `json.dumps` defaults. Signature was verified against the *fetched* bytes before the merge; the merged object was what reached the router. Tools whose `inputSchema` contained `additionalProperties: false` began rejecting valid calls because the injected `_meta` violated it — the schema was authentic, the object was not. - **Number normalization.** A manifest declared `"maximum": 1e2`. JCS canonicalizes to `100`. A host using a parser that preserved the exponent form re-emitted `100.0`, and a downstream strict integer validator rejected it. Same signature, different runtime behavior. - **`$ref` resolution order.** Two manifests used relative `$ref` into sibling definitions. The registry resolved and inlined at sign time; one host's resolver re-expanded at load time and chose a different sibling because it sorted definitions differently. The signed document and the executed document disagreed on which subschema applied. - **Unicode normalization.** A tool name containing a precomposed character was signed as NFC; a host that normalized to NFD on ingest produced a different tool identifier, so the capability grant keyed on the signed name did not match the runtime name. Each case: signature valid, attestation chain intact, runtime semantics different from the signed artifact. ## Condition of Validity The registry-level attestation is only sound under this invariant: > For every manifest `m`, `canonicalize(execute_view(m)) == canonicalize(signed_view(m))`, where `execute_view` is the exact object the router validates and dispatches against, and `canonicalize` is the same function the signer used. This holds only when **all** of the following are true: - The host does not mutate the manifest between verify and dispatch (no `_meta` injection, no default-filling, no schema rewriting). - The host's JSON parser is canonicalization-stable for the numeric and string forms the registry emits (i.e., it round-trips JCS output without change). - `$ref` resolution is either fully inlined at sign time and the host does not re-resolve, or the resolver is deterministic and shared. - String handling is normalization-stable (NFC pinned on both sides). - The signature is re-verified against the *dispatch* bytes, not only the *fetch* bytes. If any of these is false, the attestation answers the wrong question. It certifies provenance of a document that is not the document being executed. ## Decision Boundary Do not treat registry signature verification as sufficient for tool trust when the manifest crosses a parse/serialize boundary inside the host. The correct gate is one of: - **Opaque-bytes dispatch:** carry the signed manifest as bytes all the way to the router; verify once against those exact bytes; never reconstruct. Any host-side annotation goes in a *separate, unsigned* envelope that the schema is explicitly told to ignore. - **Re-verify at dispatch:** if the host must transform, canonicalize the transformed object and re-verify the signature against that canonical form before dispatch. This requires the signer to have signed the canonical form, not a pretty-printed one. - **Signed host-envelope:** the host signs its own post-merge object and the router verifies the pair (registry signature over `m`, host signature over `transform(m)`), with the transform declared and auditable. The boundary is: **if the object the model sees is not the object that was signed, you have provenance without integrity.** Treat that as untrusted for capability gating. ## Operational Effect Without the invariant, attestation degrades to a *cosmetic* signal: it proves a publisher existed, not that the executed tool matches the published one. The practical consequences: - Capability grants keyed on signed identifiers can silently mismatch runtime identifiers (Unicode, normalization). - Schema-level constraints (`additionalProperties`, numeric bounds) can be violated by host-injected fields while the signature remains green. - `$ref` re-resolution makes the executed schema a function of the host's resolver version, so the same signed manifest behaves differently across hosts — and the registry cannot detect it. - Incident response loses its anchor: the signed artifact is not the artifact that failed, so post-mortems diff the wrong thing. The mitigation is structural, not procedural: pin canonicalization, dispatch signed bytes, and re-verify at the last boundary. The check below encodes the invariant as a hard failure at dispatch time so a canonicalization gap cannot pass as a valid attestation. ```python import hashlib import json from typing import Any class ManifestCanonicalizationError(RuntimeError): """Raised when the manifest dispatched to the router is not the manifest that was signed.""" def canonicalize(obj: Any) -> bytes: # RFC 8785 JCS-compatible for the subset we emit: sorted keys, no whitespace, # no NaN/Inf, integers preserved. Float formatting must be pinned by the signer. return json.dumps( obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False, ).encode("utf-8") def verify_dispatch_integrity( signed_bytes: bytes, signed_digest: str, dispatch_obj: Any, *, host_annotations_key: str = "_host_meta", ) -> None: """ Enforce: canonicalize(execute_view) == canonicalize(signed_view). signed_bytes : the exact bytes the registry signed and the client fetched. signed_digest : sha256 hex of signed_bytes (as published in the attestation). dispatch_obj : the object the router will actually validate/dispatch against. """ actual_digest = hashlib.sha256(signed_bytes).hexdigest() if actual_digest != signed_digest: raise ManifestCanonicalizationError( f"fetched bytes do not match attestation digest: {actual_digest} != {signed_digest}" ) signed_view = json.loads(signed_bytes) # Host annotations must live outside the signed payload; if they leaked in, fail. if host_annotations_key in dispatch_obj and host_annotations_key not in signed_view: raise ManifestCanonicalizationError( f"host annotation '{host_annotations_key}' present in dispatch object but not in signed manifest" ) # Strip only the declared, unsigned envelope before comparison. compare_obj = {k: v for k, v in dispatch_obj.items() if k != host_annotations_key} if canonicalize(compare_obj) != canonicalize(signed_view): raise ManifestCanonicalizationError( "dispatch object diverges from signed manifest after canonicalization; " "attestation does not cover the executed tool" ) ```