# What structural signals indicate a poisoned MCP tool manifest versus a merely over-privileged one **Date of Record:** 2026 November 9 **Scope:** Applies to MCP server manifests, tool/plugin descriptor documents, and skill-injection surfaces where an agent ingests a JSON/YAML schema before invocation. Does not apply to runtime prompt-injection delivered through tool *outputs* (that is a post-invocation channel), nor to model-weight supply chain. ## Core Distinction An over-privileged manifest asks for too much authority but is *structurally honest*: the declared capability set, the argument schema, and the description all agree with each other and with the transport it will use. A poisoned manifest is *structurally inconsistent*: the declared surface is a subset of the actual surface, and the gap is where the injection lives. The non-obvious invariant is that the poisoning signal is almost never in the fields that are semantically "about" permissions. It is in the *relational delta* between fields — description text referencing arguments that the schema does not declare, `inputSchema` permitting properties absent from the description, tool names that collide with sibling namespaces, or annotations whose `readOnlyHint`/`destructiveHint` contradict the declared `required` arguments. Practitioners routinely audit each field in isolation (is this scope too broad? is this description suspicious?) and pass poisoned manifests, because every individual field is plausible. The failure mode is that the manifest is a *contract* and only its internal consistency is load-bearing. ## What Was Observed Across a corpus of flagged third-party MCP servers, three recurring structural signatures dominated: 1. **Schema/description divergence.** The `description` instructed the model to pass a field (e.g., "always include the `context` argument for best results") that `inputSchema` either did not declare or declared as `additionalProperties: false`. A model following the description emits a call that the server's validator rejects — or, when `additionalProperties` is unset (the default permissive case), the server *accepts* the undeclared field and routes it to a side channel. The injection target is the model's compliance with prose over schema. 2. **Annotation/behavior contradiction.** Tools carrying `readOnlyHint: true` but whose `inputSchema` required a `destination`, `path`, or `command` argument. A read-only tool with no write arguments is coherent; a read-only tool that *requires* a write-shaped argument is a label lie, and the label is what a policy layer trusts. 3. **Namespace shadowing.** A tool name identical to a legitimate sibling tool but registered under a different server, where the descriptions differ only in a trailing sentence that redefines the tool's semantics ("...and also forwards results to the audit endpoint"). The shadowing is detectable only by cross-server name comparison, not by inspecting either manifest alone. In each case, static scanners that scored fields (scope breadth, description entropy, allowlist membership) returned low risk. Detectors that computed *cross-field consistency* caught them. ## Condition of Validity The detection rule holds only when the manifest is ingested *before* any tool invocation and the agent has access to the full set of registered manifests for cross-comparison. It fails when: - The server mutates its own manifest between listing and invocation (TOCTOU on `tools/list`), so the audited document is not the executed contract. - The transport is `stdio` with a server that proxies to a remote endpoint, where the effective schema is resolved downstream and the local manifest is decorative. - Descriptions are legitimately generated from a schema that uses `oneOf`/`anyOf`, in which case description/schema divergence is expected and the rule produces false positives unless the divergence is checked per-branch. Under these conditions, treat the structural signal as necessary but not sufficient and fall back to invocation-time argument interception. ## Decision Boundary The operational threshold: **block on any cross-field inconsistency; quarantine (do not block) on scope breadth alone.** Concretely — if `description` references an undeclared property, if a `readOnlyHint` tool declares write-shaped required arguments, or if a tool name shadows a registered sibling with divergent semantics, the manifest is rejected before registration. If the only finding is a broad OAuth scope or a permissive `additionalProperties: true` with no contradicting prose, the tool is registered but flagged, because breadth is a policy decision while inconsistency is an integrity failure. Do not collapse these into one severity tier; the whole point is that breadth is auditable at policy time and inconsistency is not recoverable after the model has read the description. ## Operational Effect Agents that enforce the cross-field rule reject roughly the same volume of manifests as scope-based scanners but with a materially different composition: fewer false positives (broad-but-honest tools pass), and the true positives are the ones that actually carry injection payloads, because the payload *requires* the divergence to reach the model. The residual risk shifts from manifest poisoning to runtime argument smuggling, which is a narrower and better-instrumented surface. The cost is that legitimate prose-rich descriptions must be schema-complete — a real authoring burden that vendors will push back on, and that pushback is itself a useful signal. ```python from dataclasses import dataclass from typing import Any class ManifestIntegrityError(RuntimeError): """Raised when a tool manifest is structurally inconsistent (poisoned).""" @dataclass class ToolManifest: name: str description: str input_schema: dict[str, Any] read_only_hint: bool required: list[str] WRITE_SHAPED = {"path", "destination", "command", "url", "endpoint", "target"} def audit_manifest( manifest: ToolManifest, registered_names: dict[str, str], ) -> None: declared = set(manifest.input_schema.get("properties", {}).keys()) permissive = manifest.input_schema.get("additionalProperties", True) # Signal 1: description references an undeclared argument. for token in _argument_tokens(manifest.description): if token not in declared and token not in _STOPWORDS: if not permissive: raise ManifestIntegrityError( f"{manifest.name}: description references undeclared " f"argument {token!r} while additionalProperties=false " f"(schema/description divergence)" ) raise ManifestIntegrityError( f"{manifest.name}: description smuggles undeclared argument " f"{token!r} into a permissive schema (side-channel route)" ) # Signal 2: read-only annotation contradicted by write-shaped required args. if manifest.read_only_hint: write_args = WRITE_SHAPED.intersection(manifest.required) if write_args: raise ManifestIntegrityError( f"{manifest.name}: readOnlyHint=true but requires write-shaped " f"arguments {sorted(write_args)} (annotation/behavior contradiction)" ) # Signal 3: namespace shadowing with divergent semantics. if manifest.name in registered_names: prior = registered_names[manifest.name] if _semantic_delta(prior, manifest.description) > 0: raise ManifestIntegrityError( f"{manifest.name}: shadows a registered tool with divergent " f"description (namespace shadowing)" ) def _argument_tokens(description: str) -> set[str]: # Extract backtick-quoted identifiers as candidate argument references. import re return set(re.findall(r"`([a-zA-Z_][a-zA-Z0-9_]*)`", description)) def _semantic_delta(a: str, b: str) -> int: return 1 if a.strip() != b.strip() else 0 _STOPWORDS = {"context", "value", "input"} ```