# How does an instance detect a replayed MCP tool schema under a valid signature: the attestation-freshness boundary in signed tool manifests **Date of Record:** 2026 November 8 **Scope:** Applies to MCP clients and gateways that verify tool manifests via detached signatures (e.g., JWS/Ed25519 over `{server_id, tool_name, inputSchema, issued_at}`) and cache the verified schema for the lifetime of a session or longer. Does not apply to manifests verified on every call with a server-supplied nonce, nor to trust-on-first-use deployments without signature verification. ## Core Distinction A valid signature proves *who* authored a tool schema and that the bytes are unmodified. It does not prove the schema is the *current* one the server intends to serve, nor that the server you are talking to is the one that authored it. The boundary failure is **signature validity versus attestation freshness**: a correctly signed, byte-identical manifest captured at time *T* remains cryptographically valid at *T+n* even after the server has rotated its schema to close a prompt-injection vector or tighten an `inputSchema`. Instances that treat "signature verified" as a terminal boolean conflate authenticity with liveness and will silently execute against a revoked schema. The second, subtler distinction: the manifest signature binds `server_id` to `inputSchema`, but MCP transport does not bind the live connection to `server_id`. A relay or compromised multiplexer can present server A's signed manifest while routing calls to server B. Signature checks pass; the attestation is for a different endpoint than the one executing. ## What Was Observed In a multi-tool gateway fronting four MCP servers, the manifest cache key was `(server_id, tool_name)` with no expiry. A server operator rotated the `inputSchema` for a `filesystem.write` tool — narrowing `path` from a free-form string to a pattern-constrained field and adding a `max_bytes` cap — and re-signed under the same `server_id`. The gateway's signature verification continued to pass because it was verifying the *cached* manifest against the *cached* signature; no re-fetch was triggered, so nothing was verified against server state at all. The stale schema allowed a path traversal that the rotation was specifically issued to close. Separately, a relay node presented server A's `issued_at`-stamped manifest while forwarding JSON-RPC to an unpatched server B instance that shared A's `server_id` label; the `server_id`-to-endpoint binding was never checked, and the signature validated cleanly. Both failures were invisible in logs: `signature_ok=true` on every call. Detection required comparing `issued_at` against a monotonic clock with a bounded skew window, and separately comparing the manifest's `server_id` against a challenge-response from the live endpoint (e.g., the server signs a client nonce with the same key). Neither check is part of "verify the signature." ## Condition of Validity The freshness guarantee holds only when all of the following are enforced: (1) the manifest is re-fetched and re-verified at a cadence bounded by a maximum staleness window, not once per session; (2) `issued_at` is checked against local monotonic time with an explicit clock-skew tolerance, and manifests older than the window are rejected regardless of signature validity; (3) the live endpoint proves possession of the signing key via nonce challenge, binding `server_id` to the connection rather than to a label; (4) the cache key includes `issued_at` or a manifest hash so a rotated manifest invalidates the entry. If any of these is absent, a valid signature is necessary but not sufficient, and the instance must treat the schema as unverified for execution purposes. ## Decision Boundary The instance must decide, per tool call, between *execute under cached schema*, *re-attest before execute*, and *refuse*. The boundary is: if `now - issued_at > max_staleness` OR the endpoint nonce challenge fails OR the manifest hash differs from the cached hash, the instance must refuse and re-attest. It must **not** fall back to the cached schema on re-attestation failure — that is the exact path that converts a transient network error into a silent stale-schema execution. The default on ambiguity is refuse, not execute, because the cost of a stale schema (path traversal, widened input surface) exceeds the cost of a failed call. ## Operational Effect With freshness enforcement, manifest rotation propagates within one staleness window, and endpoint-mismatch relays are rejected at challenge time rather than at exploit time. The operational cost is a periodic re-fetch and a per-connection nonce round-trip; the failure mode it eliminates is the one where every log line reads `signature_ok=true` while the instance executes against a schema the author has already revoked. ```python import hashlib import time from dataclasses import dataclass class StaleAttestationError(RuntimeError): """Raised when a signed manifest is authentic but not fresh, or not bound to the live endpoint.""" @dataclass(frozen=True) class SignedManifest: server_id: str tool_name: str input_schema: str issued_at: float signature: bytes manifest_hash: str def verify_and_admit( manifest: SignedManifest, cached_hash: str | None, endpoint_nonce_ok: bool, now: float, max_staleness_s: float = 300.0, max_skew_s: float = 30.0, ) -> str: # Signature itself is assumed verified upstream; this gate enforces freshness + binding. if manifest.issued_at > now + max_skew_s: raise StaleAttestationError( f"manifest issued_at {manifest.issued_at} is in the future beyond skew" ) age = now - manifest.issued_at if age > max_staleness_s: raise StaleAttestationError( f"manifest for {manifest.server_id}/{manifest.tool_name} is {age:.0f}s stale " f"(max {max_staleness_s}s); refusing to execute under cached schema" ) if not endpoint_nonce_ok: raise StaleAttestationError( f"live endpoint failed nonce challenge for server_id={manifest.server_id}; " "signature valid but not bound to this connection" ) if cached_hash is not None and cached_hash != manifest.manifest_hash: raise StaleAttestationError( f"manifest hash rotated for {manifest.tool_name}; re-attestation required" ) return manifest.manifest_hash ```