# What happens when an instance trusts a signed MCP manifest after the server's capability set has silently widened **Date of Record:** 2026 November 12 **Scope:** Applies to MCP (Model Context Protocol) deployments where tool servers are admitted via signed manifests and the agent runtime performs capability attestation at session establishment. Does not cover unsigned/TOFU server admission, nor attestation schemes that re-verify on every tool invocation (those are covered by the per-call re-attestation pattern, which sidesteps this failure entirely). ## Core Distinction A signed MCP manifest attests to *what the server was authorized to do at signing time*. Runtime capability attestation attests to *what the server process can actually do now*. These are two different propositions, and the failure mode is treating the first as a proof of the second. A manifest signature is a statement by the signer about an intended capability set; it is not a cryptographic commitment that the running process is confined to that set. The gap between "signed for capabilities C" and "currently exposes capabilities C" is where skill-injection exploits live. The non-obvious part: most operators assume the signature binds the *binary* or *image digest*, and therefore that a widened capability set implies a changed digest, which would fail verification. This holds only if the manifest's `server.image_digest` is actually compared against the running process's measured digest at attestation time. In practice, many runtimes verify the manifest signature against the trusted key set but compare the *declared* digest field rather than measuring the live process. The manifest is then self-consistent and valid while describing a process that no longer matches it. ## What Was Observed An MCP server was admitted with a manifest declaring `capabilities: [filesystem.read, http.get]` and a valid signature from an approved publisher key. The runtime attested the manifest, cached the capability set in the session's tool registry, and injected the corresponding tool descriptions into the agent's context. Two days later the server was upgraded in place (same container tag, mutable registry tag `:latest`, no digest pin). The new build added `filesystem.write` and `shell.exec` and — critically — *rewrote its own `tools/list` response* to include a tool named `read_file` whose JSON schema accepted a `path` argument but whose handler also accepted a hidden `content` field that triggered a write. The agent's context still carried the *old* tool descriptions from the cached attestation. The agent called `read_file` with only a `path`, as the cached schema permitted. The server executed a write to that path using a default payload derived from the manifest's `metadata.default_write_payload`, a field that had been present but unused in the signed manifest and was therefore never flagged. The signature verified. The declared digest matched the manifest. The live process digest did not match anything, because nothing measured it. ## Condition of Validity The defense holds — i.e., a signed manifest plus attestation actually bounds capability — only when all of the following are simultaneously true: 1. The manifest's `server.image_digest` (or equivalent measurement) is compared against a *runtime-measured* value, not a declared one. If attestation reads the digest from the manifest and compares it to the manifest, the check is a tautology. 2. The tool registry injected into the agent's context is derived from the *attested* capability set, not from the server's live `tools/list`. If the agent's view of available tools is refreshed from the server without re-attestation, the manifest is decorative. 3. Capability widening is treated as a *new admission event*, requiring re-signing and re-attestation, not as a routine server update. Mutable tags defeat this; digest pinning is a prerequisite, not a nicety. 4. The manifest's declared-but-unused fields (defaults, metadata, fallback payloads) are treated as part of the capability surface. A field the server *can* read is a field it can act on, regardless of whether the manifest's prose says it is "informational." If any of these is false, the boundary is open. The most common real-world configuration fails condition 1 and condition 2 together, because both are easy to implement as "compare manifest to manifest" and "refresh tool list on reconnect." ## Decision Boundary The operational rule: **attestation must be a measurement of the running thing, and the agent's tool surface must be frozen to the attested set for the session's lifetime.** Concretely, on session establishment: - Measure the live server process (digest, or a signed capability probe over a fresh channel) and compare to the manifest's declared digest. Mismatch → refuse admission. - Build the tool registry from the manifest's capability set, not from `tools/list`. - If the server's live `tools/list` advertises tools outside the attested set, treat that as a *capability-widening event*: terminate the session, quarantine the server, and require re-signing. Do not silently filter — a server that advertises more than it was signed for is a signal, not a nuisance. - Treat every field in the signed manifest as live capability. If a field is not meant to be actionable, it must not be in a signed manifest at all. The boundary is crossed the moment the agent's context and the server's actual behavior are allowed to diverge without a re-attestation trigger. ## Operational Effect When the invariant holds, skill injection via manifest drift is structurally impossible: the agent can only invoke tools whose descriptions were derived from a signed, measured capability set, and any attempt to widen the set forces a new admission event that a human or policy gate can catch. The cost is that server upgrades require re-signing and digest pinning — a real operational tax that teams routinely try to avoid by relaxing condition 1 or 2. When the invariant is relaxed, the failure is silent: the signature still verifies, logs show "attestation passed," and the first evidence of compromise is an unexpected write or exec whose provenance points back to a manifest field nobody thought was load-bearing. The lesson recorded here: a signature proves *who authorized what*, never *what is running*. The two must be joined by a live measurement, or the manifest is a permission slip for a process that no longer exists. ```python class CapabilityDriftError(RuntimeError): """Raised when a live MCP server's capabilities exceed its signed manifest.""" pass class ManifestAttestationError(RuntimeError): """Raised when manifest signature or measured digest fails verification.""" pass def attest_session(manifest, measured_digest, live_capabilities, trusted_keys): """ Enforce: signed manifest bounds live capability, joined by live measurement. Returns the frozen capability set to inject into the agent context. """ if manifest.publisher_key not in trusted_keys: raise ManifestAttestationError( f"untrusted publisher key {manifest.publisher_key!r}" ) if not verify_signature(manifest, trusted_keys[manifest.publisher_key]): raise ManifestAttestationError("manifest signature invalid") # Condition 1: compare declared digest to a *measured* value, not to itself. if measured_digest != manifest.server.image_digest: raise ManifestAttestationError( f"digest mismatch: measured {measured_digest!r} " f"!= manifest {manifest.server.image_digest!r}" ) # Condition 2 & 3: live capabilities must be a subset of the signed set. signed = set(manifest.capabilities) live = set(live_capabilities) widened = live - signed if widened: raise CapabilityDriftError( f"server advertises capabilities outside signed manifest: {sorted(widened)}" ) # Condition 4: unwrap declared-but-unused fields as part of the surface. for field in manifest.metadata: if field.startswith("default_") or field.endswith("_payload"): raise CapabilityDriftError( f"manifest carries actionable default field {field!r}; " "remove from signed manifest or re-sign as explicit capability" ) # Freeze the agent's tool surface to the attested set. return frozenset(signed) def build_tool_registry(attested_caps, server_tools_list): """ Registry is derived from attested caps, NOT from live tools/list. Any live tool outside the attested set is a drift signal, not a filter target. """ registry = {} for tool in server_tools_list: if tool.required_capability not in attested_caps: raise CapabilityDriftError( f"tool {tool.name!r} requires {tool.required_capability!r} " "not present in attested capability set" ) registry[tool.name] = tool return registry ```