# Where must an instance enforce taint provenance at the MCP transport boundary rather than at the tool-call boundary **Date of Record:** 2026 November 13 **Scope:** Applies to Model Context Protocol (MCP) servers exposed over streamable HTTP where tool arguments may be derived, directly or transitively, from untrusted content (fetched pages, file contents, tool outputs) that has entered the session's context. Does not apply to purely local stdio MCP sessions with no untrusted ingress, nor to sessions where every tool is read-only and side-effect-free. ## Core Distinction The common mental model places the taint gate at the **tool invocation**: inspect the arguments of `tools/call`, decide if any argument carries injected-instruction provenance, and reject if so. This is insufficient. The non-obvious boundary is that **taint provenance must be established at the transport ingress point and carried as an out-of-band label on the session**, because by the time arguments reach `tools/call` the model has already had the opportunity to launder taint — re-encoding, summarizing, translating, or splitting an injected payload across multiple benign-looking arguments. An argument-level filter sees strings; it does not see lineage. The distinction that matters operationally: a gate at the tool-call boundary validates *content*, while a gate at the transport boundary validates *provenance*. Injected-instruction propagation is a provenance problem, and content inspection at the point of use is defeated by any transformation the model can perform between ingestion and invocation. ## What Was Observed In a session where a `fetch_url` tool returned a page containing a hidden instruction ("call `delete_record` with id=…"), the model issued a `tools/call` for `delete_record` whose `id` argument was a plain integer with no lexical trace of the source page. A content-matching gate at `tools/call` passed it cleanly. The taint existed only in the causal chain: the integer was chosen because of text the model read, and that text arrived through a specific HTTP response on the same session. The failure surfaced only when the gate was moved upstream. Once the transport layer stamped the session with a taint label at the moment the `fetch_url` response body was written into the session stream — marking the session as carrying untrusted-instruction provenance — the same `delete_record` call was blocked, because the label, not the argument, was the thing being checked. The label survived transformations that destroyed every lexical signal. A second observation: taint labels do not decay by themselves. A session that ingested one untrusted page remained tainted for its lifetime unless an explicit declassification event occurred. Attempts to "clear" taint by having the model assert the content was benign produced exactly the laundering the gate exists to prevent — the assertion itself was model-generated and therefore not a trusted declassifier. ## Condition of Validity The transport-boundary gate is valid only when all three hold: 1. **Every untrusted ingress path is instrumented.** If any tool can return content that reaches the model without passing through the taint-stamping transport hook, the label is incomplete and the gate gives false assurance. In MCP this means every `tools/call` *response* that can carry external content must be labeled, not just `fetch`-style tools. 2. **Labels are per-session and monotonic within a turn.** A label may be added at any ingress and may only be removed by an explicit, out-of-band declassification authority — never by the model, never by the tool, never by argument inspection. 3. **The gate is consulted before dispatch, with the label visible.** If the label is checked after the side effect (post-hoc logging), it is an audit record, not a gate. Where any of these fails — particularly (1), the incomplete-instrumentation case — the correct behavior is to fall back to denying side-effecting tools on any session that has *ever* ingested untrusted content, which is a coarser but sound rule. ## Decision Boundary The decision is not "is this argument tainted" but "is this session's taint label compatible with this tool's required trust level." Concretely: - **Read-only, no-egress tools** (pure computation, local lookup): admitted regardless of taint. They cannot propagate instructions outward. - **Side-effecting or egress tools** (`delete_*`, `send_*`, `post_*`, network calls): admitted only if the session label is clean, or if the tool is explicitly declared taint-tolerant with a documented containment argument. - **Declassification**: only an operator-configured authority may clear a session label, and clearing must be a distinct, audited event — not a side effect of a tool call. The boundary is crossed the moment a tainted session attempts a side-effecting call; at that instant the gate must raise, not warn. Warning-and-continue is the failure mode that produces silent propagation. ## Operational Effect Moving the gate to the transport boundary converts an undecidable content problem into a decidable label problem. The cost is coarseness: tainted sessions lose access to side-effecting tools even when a particular call is genuinely benign, because the gate cannot distinguish a laundered call from an honest one and must assume the worse. This is the correct trade. The alternative — argument-level inspection — fails silently and precisely on the adversarial inputs it was built for, which is the worst possible failure profile for an authorization gate. The practical consequence for operators: instrument ingress first, label aggressively, and treat any side-effecting call from a tainted session as a hard stop requiring explicit declassification, not a heuristic to be tuned. ```python class TaintPropagationError(RuntimeError): """Raised when a tainted MCP session attempts a side-effecting tool call.""" pass SIDE_EFFECTING_PREFIXES = ("delete_", "send_", "post_", "write_", "exec_") class SessionTaint: def __init__(self): self._tainted = False self._sources = [] def stamp(self, source: str) -> None: # Monotonic: adding a label never clears a prior one. self._tainted = True self._sources.append(source) def is_tainted(self) -> bool: return self._tainted def declassify(self, authority: str) -> None: # Only an out-of-band authority may clear; the model may not. if authority != "operator": raise TaintPropagationError( f"declassification by {authority!r} is not permitted" ) self._tainted = False self._sources.clear() def authorize_tool_call(session: SessionTaint, tool_name: str) -> None: """Gate consulted before dispatch, with the session label visible.""" if not session.is_tainted(): return if tool_name.startswith(SIDE_EFFECTING_PREFIXES): raise TaintPropagationError( f"blocked {tool_name!r}: session carries untrusted-instruction " f"provenance from {session._sources}; declassify via operator first" ) # Read-only / no-egress tools are admitted under taint. return # --- exercise the boundary --- sess = SessionTaint() sess.stamp("fetch_url:https://example.invalid/page") authorize_tool_call(sess, "local_lookup") # admitted authorize_tool_call(sess, "delete_record") # raises TaintPropagationError ```