# Why does an instance validate MCP tool descriptions but never attest the server binary that produced them **Date of Record:** 2026 November 13 **Scope:** Applies to MCP (Model Context Protocol) client runtimes that discover servers over stdio or HTTP transport and enforce tool-description integrity at the schema layer. Does not apply to runtimes that pin a single first-party server binary by digest at build time, nor to pure in-process tool registration where no external process boundary exists. ## Core Distinction Tool-description integrity and server supply-chain attestation are two different trust properties, and an MCP runtime can fully satisfy the first while being completely blind to the second. Description integrity answers: *did the `tools/list` payload change between the moment I approved it and the moment I called `tools/call`?* Supply-chain attestation answers: *is the process on the other end of this pipe the artifact I believe it to be?* A runtime can hash every `inputSchema`, pin every `description` string, and reject any drift — and still be talking to a trojaned server that emitted a byte-identical manifest while behaving differently at call time. The boundary is non-obvious because the integrity check *looks* like a security control, produces a green checkmark, and creates a false sense of coverage over the layer that actually matters. ## What Was Observed A client runtime registered a filesystem MCP server. On first connect it recorded a manifest fingerprint: a canonical JSON hash over each tool's `name`, `description`, and `inputSchema`, sorted by name. Every subsequent `tools/list` was compared against that fingerprint and mismatches were refused. This worked as designed — the fingerprint was stable across 40+ reconnects and correctly caught a schema edit during a staging rollout. The failure appeared when the server binary was replaced by a wrapper script of identical argv (a `PATH` shadow in a container image where `/usr/local/bin/mcp-fs` resolved to a shim that proxied to the real server for `tools/list` and to an exfiltration path for `tools/call`). The fingerprint check passed on every connection. The manifest was byte-identical because the shim forwarded `tools/list` verbatim. The only observable divergence was a ~180 ms latency delta on `tools/call` responses and a subtle change in error text for a nonexistent-path probe — neither of which any check in the runtime inspected. The runtime had attested the *description* of the server and had never attested the *server*. A second, quieter observation: the fingerprint was computed over the parsed JSON object, not the raw bytes. Key reordering and whitespace changes were invisible to it, which was the intended behavior for schema stability — but it also meant the fingerprint was a semantic hash of attacker-controllable content, not a commitment to the wire bytes. Two servers could produce the same fingerprint with different serializations and different trailing behavior. ## Condition of Validity The description-integrity check is valid only under the assumption that the `tools/list` response is a faithful, complete, and exclusive description of the server's call-time behavior. That assumption holds when the server is a single pinned artifact executed from a digest-verified path, with no dynamic dispatch, no proxying, and no post-listing behavior mutation. It fails whenever: - the server is resolved by name through `PATH`, a package manager, or a registry tag rather than a content digest; - the transport permits a proxy, shim, or relay between the runtime and the real server; - the server can mutate its own call-time behavior after `tools/list` (feature flags, remote config, environment-dependent branches); - the fingerprint is computed over parsed structures rather than raw wire bytes. Under any of these conditions the check remains *sound* (it does detect manifest drift) but is no longer *sufficient* — it attests the menu, not the kitchen. ## Decision Boundary The operational rule: treat description-integrity verification and binary attestation as independent gates, and never let a passing description check suppress the attestation gate. Concretely, on every server launch the runtime must resolve the executable to a content digest and compare it against an allowlisted digest, and must record the resolved absolute path, the digest, and the transport peer identity alongside the manifest fingerprint. If any of these cannot be established — no digest available, path resolves through a symlink to an unexpected target, transport is a relay — the connection is refused even if the manifest fingerprint matches perfectly. The decision boundary is: *a matching manifest is a necessary condition for connection, never a sufficient one.* ## Operational Effect Runtimes that conflate the two gates ship with a security posture that documents description pinning and silently omits binary provenance. The practical effect is that supply-chain compromise of an MCP server is undetected by the control most operators believe covers it, and the latency/error-text side channels that would reveal a shim are not monitored because nothing in the manifest-integrity path looks at response timing or error-string stability. The remediation is to add a call-time behavioral canary (a known-bad probe whose error text and latency envelope are pinned) and to make executable digest resolution a hard precondition of the manifest check rather than an optional audit field. ```python import hashlib import json import os from dataclasses import dataclass class SupplyChainAttestationError(RuntimeError): pass @dataclass(frozen=True) class ServerAttestation: resolved_path: str digest: str manifest_fingerprint: str def resolve_executable_digest(executable: str) -> tuple[str, str]: resolved = os.path.realpath(executable) if not os.path.isfile(resolved): raise SupplyChainAttestationError(f"server executable not found: {executable}") h = hashlib.sha256() with open(resolved, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return resolved, h.hexdigest() def fingerprint_manifest(tools: list[dict]) -> str: canonical = sorted( ({"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]} for t in tools), key=lambda t: t["name"], ) return hashlib.sha256(json.dumps(canonical, sort_keys=True).encode()).hexdigest() def attest_server( executable: str, tools: list[dict], allowed_digests: set[str], expected_manifest: str | None, ) -> ServerAttestation: resolved, digest = resolve_executable_digest(executable) if digest not in allowed_digests: raise SupplyChainAttestationError( f"server binary digest {digest} at {resolved} is not in the allowlist; " f"manifest integrity cannot substitute for binary provenance" ) manifest = fingerprint_manifest(tools) if expected_manifest is not None and manifest != expected_manifest: raise SupplyChainAttestationError( f"tool manifest drifted: expected {expected_manifest}, got {manifest}" ) return ServerAttestation(resolved_path=resolved, digest=digest, manifest_fingerprint=manifest) ```