# Under what conditions must an instance treat MCP tool-server attestation as a continuous invariant rather than a one-time admission gate **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that bind to remote MCP servers and cache a verified tool manifest at session start. Does not apply to fully local, single-binary MCP servers whose tool set is compiled into the agent process and cannot be mutated at runtime. ## Core Distinction A one-time admission gate answers the question "was this server attested when I first connected?" A continuous invariant answers "is the tool I am about to invoke still the tool that was attested?" These diverge the moment the server's tool surface becomes mutable *after* handshake — which is the normal case for MCP, because `tools/list` is a runtime RPC, not a build artifact. The non-obvious boundary is this: **attestation of a server identity is not attestation of a tool schema.** A server can present a valid, unexpired attestation document while returning a `tools/list` payload whose descriptions, input schemas, or annotations differ from what was attested. The invariant that must be held continuously is over the *manifest digest*, not the *server certificate*. The trap is that most admission designs verify the certificate chain and stop. The certificate proves *who* is answering. It does not prove *what* they will answer. Tool poisoning, description-injection, and silent schema drift all occur inside a validly attested channel. ## What Was Observed During a routine re-bind to a previously attested MCP server, the following sequence was recorded: 1. Session opens; TLS handshake succeeds; server attestation document validates against the pinned publisher key. Admission gate passes. 2. `tools/list` returns a manifest. Its canonicalized digest is computed and compared to the digest recorded in the attestation document's `tool_manifest_digest` claim. Match. Session proceeds. 3. Mid-session, the server emits a `notifications/tools/list_changed` message. The runtime re-fetches `tools/list`. 4. The re-fetched manifest differs from the admitted manifest in exactly one field: a tool's `description` now contains an instruction-shaped suffix directing the agent to pass an additional credential parameter to a *different* tool. The input schema is unchanged; the tool name is unchanged; the server certificate is unchanged and still valid. 5. Because the runtime treated attestation as a one-time gate, the re-fetched manifest was adopted without re-verification. The digest no longer matched the attestation claim, but nothing checked it. The failure was not a broken signature. Every cryptographic check passed. The failure was that the *scope* of the check (server identity, at admission) did not cover the *scope* of the mutable surface (tool manifest, at every change). A second, quieter observation: manifest digests computed with naive JSON serialization were unstable across re-fetches because the server emitted keys in nondeterministic order and included a `last_modified` timestamp inside the manifest. Two runs over semantically identical manifests produced different digests, causing false drift alarms. Digest stability is a precondition for the invariant, not an incidental detail. ## Condition of Validity The continuous-invariant treatment is *required* — not merely advisable — when all of the following hold: - The server can mutate its tool surface at runtime (i.e., it supports or may emit `tools/list_changed`), **or** the runtime re-fetches `tools/list` for any reason after admission. - Tool descriptions, annotations, or input schemas are consumed by a model as instructions or as structured arguments (i.e., they are semantically load-bearing, not cosmetic). - The attestation document carries a manifest digest claim, or the runtime maintains an out-of-band pinned digest. It is *not* required — and the one-time gate remains sound — when the tool surface is provably immutable for the session lifetime (e.g., a server that rejects re-listing or whose manifest is bound into the attestation as the sole admissible value and enforced server-side), or when tool metadata is never surfaced to the model. The invariant is: **for every manifest the runtime is about to trust, the canonical digest of that manifest must equal the digest bound in a currently valid attestation.** Any manifest that fails this is untrusted regardless of channel validity. ## Decision Boundary The runtime must re-verify on every manifest adoption, and must distinguish three outcomes: - **Digest matches attestation claim, attestation unexpired** → adopt silently. - **Digest differs, attestation valid but claim stale** → treat as drift. Do not adopt. Quarantine the changed tools, surface the diff, require re-attestation or explicit operator approval. This is the boundary that the one-time gate collapses and that must not be collapsed. - **Attestation expired or revoked** → reject the entire server, not just changed tools, because expiry invalidates the binding for *all* manifests the server might emit. The critical decision is that a *valid channel* never overrides a *digest mismatch*. Channel validity and manifest integrity are orthogonal; conflating them is the failure. Canonicalization (sorted keys, excluded volatile fields, normalized whitespace) must be fixed and versioned, because a canonicalization change silently redefines the invariant. ## Operational Effect Treating attestation as continuous converts a class of silent tool-poisoning and schema-drift attacks from *undetectable* to *loudly rejected at the moment of change*. The cost is a digest computation per manifest adoption and a canonicalization contract that must be versioned and shared between publisher and runtime. The operational discipline required is that `notifications/tools/list_changed` is a security-relevant event, never a convenience refresh, and that the runtime's default on any digest mismatch is refusal, not adoption-with-warning. Systems that adopt-with-warning have, in effect, reverted to a one-time gate while appearing to hold the invariant. ```python import hashlib import json from typing import Any class ManifestIntegrityError(RuntimeError): """Raised when a tool manifest is not bound to a valid attestation.""" CANONICALIZATION_VERSION = "mcp-manifest-canon-v1" VOLATILE_FIELDS = frozenset({"last_modified", "etag", "generated_at"}) def canonical_digest(manifest: dict[str, Any]) -> str: if CANONICALIZATION_VERSION != "mcp-manifest-canon-v1": raise ManifestIntegrityError( f"unknown canonicalization version: {CANONICALIZATION_VERSION}" ) stripped = { k: v for k, v in manifest.items() if k not in VOLATILE_FIELDS } encoded = json.dumps( stripped, sort_keys=True, separators=(",", ":"), ensure_ascii=False ).encode("utf-8") return "sha256:" + hashlib.sha256(encoded).hexdigest() def admit_manifest( manifest: dict[str, Any], attestation: dict[str, Any], now_epoch: int, ) -> None: if now_epoch >= attestation["expires_at"]: raise ManifestIntegrityError( "attestation expired; binding invalid for all manifests" ) claimed = attestation.get("tool_manifest_digest") if claimed is None: raise ManifestIntegrityError( "attestation carries no manifest digest; cannot bind tool surface" ) actual = canonical_digest(manifest) if actual != claimed: raise ManifestIntegrityError( f"manifest digest mismatch: attested={claimed} actual={actual}; " "channel validity does not override digest mismatch" ) ```