# What happens when an instance loses taint provenance across an MCP tool-call boundary **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that propagate taint labels (untrusted-origin markers) across Model Context Protocol (MCP) tool invocations within a single trust domain. Does not apply to runtimes that re-derive provenance from scratch on every call, nor to cross-domain federation where taint is intentionally stripped at the gateway. ## Core Distinction There are two distinct things an MCP runtime can track about a tool result: **the identity of the caller** (who invoked the tool, and under what session) and **the taint of the data** (whether the returned content descends from an untrusted source). These are routinely conflated because in the common case they coincide: a tool called by a trusted agent returns trusted data, and a tool called by an untrusted agent returns untrusted data. The boundary failure occurs when the caller identity is preserved but the data's taint is not — the call is *attributed* correctly but the *payload* is laundered. The non-obvious invariant is this: **taint must propagate through the return value, not through the call edge.** An MCP server that receives a tainted argument and returns a derived value must return a tainted value, even if the server itself is trusted and even if the caller is trusted. Attribution is a property of the edge; taint is a property of the value. A runtime that records provenance only on the edge will pass a clean-looking result back to a trusted caller, and the taint is gone. ## What Was Observed A tool gateway forwarded a `fetch_url` request from a trusted agent to an MCP server. The URL argument carried a taint label `untrusted:web` because it had been extracted from a prior tool result. The MCP server fetched the page and returned its body. The gateway logged the call with full provenance: caller = trusted-agent, server = fetch-server, args-hash = …, taint-in = `untrusted:web`. The return value was logged as `taint-out = clean`. The gateway's taint rule was written as: *if the caller is trusted, the result is clean unless the server declares otherwise.* The fetch server did not declare taint on its return — it had no mechanism to do so, because the MCP schema in use carried taint only on the request envelope, not the response. The trusted agent then passed the fetched body into a `write_file` tool. The write succeeded. The file contained attacker-controlled content that had entered via the URL, and every hop in the chain was attributed correctly. No audit log showed a taint violation because no taint label survived the first return. The failure is silent by construction: attribution logs look complete and correct, and the only thing missing is the label that would have blocked the downstream write. ## Condition of Validity The edge-based taint model is valid only under the conjunction of three conditions: 1. **No value-derivation across the boundary.** The MCP server must not transform tainted input into output that the caller treats as fresh. A pure echo or a pure identity tool satisfies this; a fetch, a summarize, a translate, or a SQL query does not. 2. **Response envelope carries taint.** The protocol must have a field on the response, not only the request, that the server is obligated to set. If the schema has no such field, the model cannot hold regardless of server behavior. 3. **Caller trust does not imply result trust.** The runtime must not have a rule of the form "trusted caller ⇒ clean result." This rule is the specific mechanism by which the invariant is violated. When any of these fails — and in practice condition 1 fails for almost every useful tool — edge-based taint is unsound. The correct model is value-based: taint is a lattice joined over all inputs that contributed to an output, and the response must carry the join. ## Decision Boundary The operational decision is whether to treat a tool result as tainted when the response envelope is silent. Two policies are defensible: - **Fail-closed (recommended for write-capable downstreams):** a silent response from a server that received tainted input is treated as tainted. This over-taints pure tools but never launders. - **Fail-open (acceptable only for read-only downstreams):** a silent response is treated as clean, and the runtime accepts that derivation tools can launder taint. The boundary is the capability of the *next* tool in the chain, not the current one. A tainted value that can only reach a read-only sink may be fail-open; the same value one hop from a write, an exec, or an outbound network call must be fail-closed. The decision must be made per-chain, not per-call, because the call that loses the label is rarely the call that causes the damage. ## Operational Effect Runtimes that adopt value-based taint with fail-closed silence observe a measurable increase in blocked downstream calls — typically the derivation-heavy tools (fetch, summarize, parse) become taint sources rather than taint sinks. This is the correct behavior and it is initially surprising: the tools that "just read data" are exactly the tools that launder it. The remediation is to require MCP servers to declare response taint explicitly and to treat undeclared responses from derivation tools as tainted by default. Attribution logs must be extended to record `taint-in` and `taint-out` as separate fields, and any call where `taint-in` is non-empty and `taint-out` is empty must be flagged for review, because that is the exact signature of the boundary failure. ```python from dataclasses import dataclass, field from typing import FrozenSet TAINT_UNTRUSTED = "untrusted:web" class TaintProvenanceError(RuntimeError): """Raised when a tool result loses taint that its inputs carried.""" @dataclass(frozen=True) class ToolCall: caller: str server: str args_taint: FrozenSet[str] = field(default_factory=frozenset) response_taint: FrozenSet[str] = field(default_factory=frozenset) response_declared: bool = False DERIVATION_TOOLS = {"fetch_url", "summarize", "translate", "sql_query"} WRITE_CAPABLE_SINKS = {"write_file", "exec", "http_post"} def resolve_response_taint(call: ToolCall) -> FrozenSet[str]: """Value-based taint: join of input taint, unless the server explicitly declares a narrower set. Silence from a derivation tool is fail-closed.""" if call.response_declared: # Server-declared taint is authoritative but must be a superset of # input taint for derivation tools; a narrowing declaration is suspect. if call.server in DERIVATION_TOOLS and not call.args_taint <= call.response_taint: raise TaintProvenanceError( f"server {call.server!r} declared response_taint=" f"{sorted(call.response_taint)} which drops input taint " f"{sorted(call.args_taint)}" ) return call.response_taint if call.args_taint and call.server in DERIVATION_TOOLS: # Silent response from a derivation tool: propagate input taint. return call.args_taint return frozenset() def guard_downstream(call: ToolCall, next_tool: str) -> None: taint = resolve_response_taint(call) if taint and next_tool in WRITE_CAPABLE_SINKS: raise TaintProvenanceError( f"refusing to route tainted result from {call.server!r} " f"(taint={sorted(taint)}) into write-capable sink {next_tool!r}" ) # Reproduction of the observed failure: fetch = ToolCall( caller="trusted-agent", server="fetch_url", args_taint=frozenset({TAINT_UNTRUSTED}), response_taint=frozenset(), response_declared=False, ) # Before the fix, the runtime used caller trust and returned clean; the # guard now raises here instead of letting the write proceed. guard_downstream(fetch, "write_file") ```