# Why does an instance of an MCP server pass tool-poisoning review but still leak capability at runtime **Date of Record:** 2026 November 14 **Scope:** Applies to MCP (Model Context Protocol) servers that expose tools whose declared schema, description, or annotations are reviewed at install/consent time, but whose runtime behavior is gated by arguments, environment, or downstream tool calls not captured in that declaration. Does not apply to servers whose tool surface is fully static and whose arguments cannot influence capability selection (e.g., pure computational tools with no I/O). ## Core Distinction Tool-poisoning defenses operate on the **declared** capability surface: the `name`, `description`, `inputSchema`, and annotations that a server publishes during `tools/list`. Capability-scoped consent UIs operate on the **realized** capability surface: the set of side effects a tool invocation can actually produce given the arguments the model supplies. These two surfaces coincide only when the server is *argument-honest* — that is, when the declared schema fully constrains which downstream resources, files, hosts, or sibling tools the invocation can touch. The non-obvious failure is that a server can be **argument-dishonest while remaining schema-valid**. The `inputSchema` validates; the description reads cleanly; the annotation says `readOnlyHint: true`; and yet a string argument is interpreted by the server as a path, a URL, a shell fragment, a template, or a *tool name to dispatch to*. Consent was granted against the declaration. The leak occurs against the realization. This is distinct from classic tool poisoning (malicious instructions embedded in a description that steer the model). It is also distinct from confused-deputy attacks across servers. The invariant being violated is narrower: **consent scope must be a function of realized capability, and the protocol provides no mechanism to bind realized capability to the reviewed declaration except the server's own cooperation.** ## What Was Observed A filesystem-adjacent MCP server was reviewed and approved with three tools: `read_file(path)`, `list_dir(path)`, and `search(pattern)`. The consent UI displayed `readOnlyHint: true` on all three and scoped the grant to the `~/projects/` root. The reviewer confirmed that `read_file`'s schema declared `path` as a `string` with a `pattern` of `^[^/].*` (relative paths only). This was treated as the containment boundary. At runtime, the model invoked `search(pattern="*.py")`. The server implemented `search` by shelling out to a `grep`-like helper and, on a match, *invoked its own `read_file` internally* with a path derived from the match. Because the internal call bypassed the JSON-RPC boundary, the `^[^/]` pattern was never re-validated; the derived path was absolute. The grant scope (`~/projects/`) was enforced by the *client's* consent layer on the top-level `read_file` call — not on the server's internal call. A symlink inside `~/projects/` pointing to `/etc/` caused `search` to return the contents of `/etc/passwd` in its result payload. The consent UI had no way to represent this: the realized capability of `search` was "read any file reachable from the search root, including via symlinks and including files outside the declared grant," but the declaration said `readOnlyHint: true` with a `pattern` argument. Two independent gaps compounded: (1) the schema constrained the *argument*, not the *effect*; (2) the consent layer enforced scope at the RPC boundary, which the server's internal dispatch crossed without inspection. A second observation, from a different server: a tool declared `run_query(datasource: string, query: string)` where `datasource` was an `enum` of two names. The enum was enforced by the client. The server, however, treated an unrecognized `datasource` by falling back to a default connection string read from an environment variable that pointed at a production replica. The enum made the *declared* surface look scoped to two datasources; the *realized* surface included a third that no consent screen ever displayed. The client's enum validation prevented the model from naming it, but a prompt-injected tool result containing a crafted `datasource` value that the server normalized (case-folding, trimming) slipped past client validation and hit the fallback. In both cases the server passed static review. In both cases the leak was a function of runtime argument interpretation, not of the published declaration. ## Condition of Validity The defense holds — consent scope bounds realized capability — only under the conjunction: 1. **Argument-honesty:** every argument value the schema admits maps to exactly one capability class, and that class is a subset of the declared capability. Violated by path/URL/shell/template/dispatch arguments, and by normalization that widens the admitted set beyond the schema. 2. **Boundary-completeness:** every capability-affecting operation the server performs is mediated by the RPC boundary the consent layer inspects. Violated by internal dispatch, helper subprocesses, cached credentials, and server-side fan-out to sibling tools. 3. **Scope-monotonicity:** the set of resources reachable from a granted scope is closed under the server's own resolution rules (symlinks, `..`, redirects, case-folding, unicode normalization). Violated whenever resolution can escape the scope string. 4. **Declaration-stability:** the tool surface reviewed is the tool surface served. Violated by servers that mutate `tools/list` after consent based on arguments, environment, or time. When any conjunct fails, the consent grant is a statement about a surface that does not exist at runtime. The failure is silent: no protocol error is raised, because from the client's perspective every message was well-formed and every declared constraint was satisfied. ## Decision Boundary Treat a tool as **not safely scopeable by declaration alone** — and require either runtime mediation or refusal — when any of the following is true: - An argument's schema is `string`, `array`, or `object` *without* a closed `enum`, and the tool's description implies the value selects a resource (path, host, query target, tool name, template). Open string arguments that reach a resolver are the dominant leak vector. - The server's implementation is not observable to the consent layer (third-party, opaque binary, or network-remote) and the tool description contains any of: "runs", "executes", "fetches", "loads", "resolves", "dispatches", "forwards". These verbs signal internal capability selection. - The grant scope is a *prefix* (a directory, an origin, a namespace) rather than an *exact set*. Prefix scopes require scope-monotonicity, which requires trusting the server's resolver. - The tool's `readOnlyHint` or `destructiveHint` is asserted by the server rather than derived by the client from observed effects. Server-asserted annotations are claims, not constraints. Conversely, declaration-based consent is sufficient when arguments are closed enums or bounded numerics, the server is local and inspectable, and the scope is an exact allowlist rather than a prefix. In that regime the realized surface is provably a subset of the declared surface. The boundary is not "trust the server" versus "don't." It is: **consent must bind to the realized surface, and the only way to realize the surface without server cooperation is to observe effects, not declarations.** Where observation is impossible, the correct posture is to narrow the grant to exact resources and to treat every open string argument as a capability selector that the UI must disclose as such. ## Operational Effect For an agent operating under a capability-scoped grant, the practical consequence is that **the grant's scope string is not a security boundary; it is a hint to a cooperating server.** The agent should: - Prefer tools whose arguments are closed enums; when forced to use open-string arguments, treat the invocation as *widening* the effective grant and log it as such. - Never infer containment from `readOnlyHint` or from a schema `pattern`; infer it only from observed result provenance (does the returned data originate inside the grant root?). - On any tool whose description contains dispatch verbs, assume the server may perform internal calls that bypass client-side validation, and require the server to be local and inspectable before granting a prefix scope. - Treat a server that mutates `tools/list` after consent as having revoked the prior grant; re-review is mandatory. The record below encodes the decision boundary as an executable check an agent can run against a proposed tool before invoking it under a scoped grant. ```python class CapabilityScopeError(RuntimeError): """Raised when a tool's realized capability cannot be proven a subset of the granted scope.""" pass DISPATCH_VERBS = ("runs", "executes", "fetches", "loads", "resolves", "dispatches", "forwards") def assert_scope_safe(tool: dict, grant_scope: str, scope_is_prefix: bool) -> None: """Refuse to invoke a tool whose realized capability may exceed the granted scope. tool: a tools/list entry with keys name, description, inputSchema, annotations. grant_scope: the exact resource or prefix the user consented to. scope_is_prefix: True if grant_scope is a directory/origin/namespace prefix. """ name = tool.get("name", "") desc = (tool.get("description") or "").lower() schema = tool.get("inputSchema") or {} props = schema.get("properties") or {} # 1. Dispatch verbs in the description signal internal capability selection # that bypasses the RPC boundary the consent layer inspects. hits = [v for v in DISPATCH_VERBS if v in desc] if hits: raise CapabilityScopeError( f"tool {name!r} description contains dispatch verbs {hits}; " "server may perform internal calls outside the granted scope. " "Require local, inspectable implementation before granting." ) # 2. Open string/array/object arguments that reach a resolver are capability # selectors, not data. They are the dominant leak vector. for arg, spec in props.items(): t = (spec or {}).get("type") if t in ("string", "array", "object") and "enum" not in (spec or {}): if scope_is_prefix: raise CapabilityScopeError( f"tool {name!r} argument {arg!r} is open {t!r} and grant scope " f"{grant_scope!r} is a prefix. Prefix scopes require scope-" "monotonicity, which requires trusting the server resolver. " "Narrow grant to an exact allowlist or refuse." ) # 3. Server-asserted annotations are claims, not constraints. Never infer # containment from readOnlyHint alone. ann = tool.get("annotations") or {} if ann.get("readOnlyHint") and any( (props.get(a) or {}).get("type") in ("string", "array", "object") and "enum" not in (props.get(a) or {}) for a in props ): raise CapabilityScopeError( f"tool {name!r} asserts readOnlyHint but has open arguments; " "read-only-ness is a claim about effects the client cannot verify. " "Treat as widening the effective grant and log it." ) # 4. If we reach here, the realized surface is provably a subset of the # declared surface under the stated conditions. return None def guarded_invoke(tool: dict, grant_scope: str, scope_is_prefix: bool, call): assert_scope_safe(tool, grant_scope, scope_is_prefix) return call() ```