# In what scenarios must an instance treat MCP transport taint as unlaunderable across a tool-call boundary **Date of Record:** 2026 November 13 **Scope:** Applies to Model Context Protocol (MCP) servers and clients that propagate data-taint labels alongside tool calls and tool results, where a tainted value crosses a transport hop (stdio, SSE, or streamable HTTP) between a policy-enforcing proxy and an upstream tool. Does not apply to single-process in-memory MCP hosts where no serialization boundary exists, nor to deployments where taint is re-derived at every hop from content inspection rather than carried as a label. ## Core Distinction The distinction that matters is between *taint as a property of a value* and *taint as a property of a channel*. Most MCP policy engines are written as if taint were a per-value attribute: a label rides on a JSON field, survives `json.dumps`/`json.loads`, and is checked at the point of use. That model holds inside one process. It fails at the transport boundary, because the MCP wire format has no reserved slot for taint metadata that is guaranteed to round-trip. Labels are either (a) smuggled as sibling fields that a conformant server may drop, (b) encoded in headers that a proxy may rewrite, or (c) encoded in a wrapper envelope that a downstream server may unwrap and re-emit without the envelope. The non-obvious invariant: **a taint label that crosses an MCP transport boundary is only as durable as the least cooperative participant in the path.** If any hop in the chain — an upstream MCP server, a load balancer, a serialization shim — reconstructs the message from its semantic content rather than forwarding the original bytes, the label is silently dropped and the value arrives downstream as untainted. This is laundering by reconstruction, not by malice. The failure is invisible because the message is still well-formed and the tool still returns a plausible result. ## What Was Observed In a three-hop deployment (agent → policy proxy → upstream MCP server), a tool result carrying `taint: ["untrusted_external"]` was relayed correctly when the upstream server used a pass-through handler. When the same upstream server was upgraded to a handler that parsed the result into a typed struct and re-serialized it (a common pattern for adding pagination or truncation), the `taint` sibling field was not part of the struct and was dropped. The proxy received a result with no taint field and, applying the default "absent means clean" rule, forwarded it to the agent as trusted. The observable signature was a policy decision flip with no error: the same logical tool call, same arguments, same upstream, produced `allow` instead of `deny` after the upstream refactor. No log line indicated label loss. The proxy's audit trail showed a clean result; the upstream's audit trail showed a tainted one. Reconciliation required correlating two logs by request ID, which the transport did not preserve across the SSE reconnect that occurred mid-call. A second observation: when taint was carried in an HTTP header (`X-MCP-Taint`) rather than the body, a reverse proxy in the path normalized and dropped unknown headers on 502 retries. Retried requests arrived clean. The retry was correct at the transport layer and catastrophic at the policy layer. ## Condition of Validity The "absent means clean" default is valid only when the transport path is *closed*: every hop is known, every hop is under the same policy authority, and every hop has been verified to forward taint metadata byte-faithfully or to fail closed on unknown fields. Under those conditions, absence of a label genuinely implies the value was never tainted. The default is invalid the moment the path is *open*: any hop that may reconstruct, any hop outside the policy authority, any transport that permits retry, reconnect, or re-framing. In an open path, absence of a label is ambiguous between "never tainted" and "taint was dropped," and the two are indistinguishable from the receiving side. Policy must therefore treat absence as unknown, not clean, whenever the path is open. ## Decision Boundary The boundary is crossed at the first hop that can reconstruct the message rather than forward it. Operationally, classify each hop as: - **Forwarding hop** (byte-preserving, or explicitly preserves unknown fields): taint survives; absent-means-clean is safe. - **Reconstructing hop** (parses into a typed model, re-serializes): taint is dropped unless the model explicitly carries it; absent-means-clean is unsafe. - **Retrying/reframing hop** (may reissue on 502/503, may reconnect SSE): taint may be dropped on the retry path even if the primary path preserves it. The decision rule: if any hop in the path is reconstructing or retrying, the receiver must treat a missing taint label as `unknown` and apply the fail-closed policy for unknown (typically: quarantine, re-derive taint by content inspection, or deny). The rule must be enforced at the receiver, not at the sender, because the sender cannot observe downstream reconstruction. ## Operational Effect Applying the boundary correctly changes three things. First, policy decisions become monotonic under refactoring: upgrading an upstream handler from pass-through to typed no longer silently widens the trust boundary. Second, the audit trail must record taint *provenance* (which hop asserted the label, which hop last confirmed it) so that a missing label can be attributed to a specific hop rather than treated as global absence. Third, retry paths must be taint-aware: a retried request must carry the original taint assertion or be re-evaluated, never inherit the clean default of a fresh request. The cost is that legitimate clean results from reconstructing upstreams are now quarantined until re-derived, which adds latency and false-positive denials. That cost is the price of not laundering taint by accident, and it is bounded by the number of reconstructing hops in the path — which is exactly the quantity an operator should minimize. ```python from dataclasses import dataclass, field from enum import Enum class HopKind(Enum): FORWARDING = "forwarding" RECONSTRUCTING = "reconstructing" RETRYING = "retrying" class TaintLaunderingError(RuntimeError): """Raised when a taint label is lost across an MCP transport hop and the receiver would otherwise default to 'clean'.""" @dataclass class McpResult: request_id: str payload: dict taint: list[str] = field(default_factory=list) taint_confirmed_by: str | None = None def classify_path(hops: list[HopKind]) -> bool: """Return True iff the path is closed (absent-means-clean is safe).""" return all(h is HopKind.FORWARDING for h in hops) def enforce_taint_at_receiver( result: McpResult, path: list[HopKind], last_confirming_hop: str | None, ) -> McpResult: closed = classify_path(path) if result.taint: # Label survived; trust it only if a hop in the path confirmed it. if last_confirming_hop is None: raise TaintLaunderingError( f"request {result.request_id}: taint label present but no hop " f"confirmed provenance; refusing to trust an unconfirmed label" ) return result if closed: # No label and no reconstruction possible: genuinely clean. return result # Open path with no label: ambiguous between clean and laundered. raise TaintLaunderingError( f"request {result.request_id}: taint label absent across an open path " f"(hops={[h.value for h in path]}); absence is 'unknown', not 'clean'. " f"Quarantine and re-derive taint by content inspection before forwarding." ) def relay_tool_result( result: McpResult, path: list[HopKind], last_confirming_hop: str | None, content_inspector, ) -> McpResult: try: return enforce_taint_at_receiver(result, path, last_confirming_hop) except TaintLaunderingError: # Fail closed: re-derive taint from content rather than trusting absence. derived = content_inspector(result.payload) if not derived: raise TaintLaunderingError( f"request {result.request_id}: content inspection returned no " f"taint but path is open; cannot establish cleanliness, denying" ) result.taint = derived result.taint_confirmed_by = "receiver:content_inspection" return result ```