# When must an MCP federation gateway refuse a delegated capability token that is structurally valid but semantically over-broad? **Date of Record:** 2026 November 5 **Scope:** Applies to cross-organization Model Context Protocol (MCP) federation where a client in org A invokes a tool exposed by an MCP server in org B through a federation gateway that mints or exchanges delegated capability tokens. Does not apply to single-tenant MCP deployments where token issuer and resource server share a trust root, nor to purely opaque session tokens with no delegated scope claims. ## Core Distinction The failure mode is not "invalid token" versus "valid token." It is **structurally valid but semantically over-broad delegation**: a token whose signature, issuer, audience, and expiry all verify, and whose scope strings are individually well-formed, yet whose *aggregate* scope exceeds the intersection of (a) what the delegating principal actually holds, (b) what the target MCP server's declared capability surface permits for that principal's org, and (c) what the federation agreement between the two orgs authorizes. Each of these three checks passes in isolation; the boundary only fails when you evaluate the *conjunction* at the gateway rather than trusting the upstream issuer's scope string. The non-obvious part: MCP tool calls carry scope as a flat list of capability identifiers (e.g. `tools/files.read`, `resources/db.query`, `prompts/summarize`). Delegation chains (A→gateway→B) re-mint tokens, and naive re-minting **unions** scopes along the chain instead of **intersecting** them. A token can therefore be valid at every hop while the terminal scope is a superset of what any single hop authorized. This is a privilege-escalation invariant violation, not an authentication failure. ## What Was Observed In a two-org federation (org `acme` client, org `globex` MCP server exposing `files.read`, `files.write`, `db.query`), the gateway exchanged an `acme` user token scoped to `files.read` for a `globex`-audience token. The `globex` server's federation policy granted the `acme` org the capability set `{files.read, db.query}`. The re-minted token carried `files.read` **plus** `db.query` because the gateway merged the user's requested tool list with the org-level grant to "avoid a second round-trip." Every signature and audience check passed. The user invoked `db.query` successfully despite never holding that scope. Detection only occurred when an audit compared terminal-token scope against the *originating principal's* scope, not against the org grant. The gap was invisible to any single-hop validator. A second observation: when the `acme` principal's scope was later narrowed to `files.read` only, the gateway's cached org-grant token (TTL 15 min) continued to serve `db.query` for the remainder of its lifetime, because scope narrowing was not propagated as a revocation signal across the federation boundary. ## Condition of Validity The gateway may pass a delegated token to the downstream MCP server only when **all** hold simultaneously: 1. `terminal_scope ⊆ originating_principal_scope` (no escalation across the delegation chain). 2. `terminal_scope ⊆ federation_agreement[org_A][org_B].allowed_capabilities` (org-level treaty). 3. `terminal_scope ⊆ target_server.declared_capability_surface` (the server actually exposes each capability). 4. The token's `act` (actor) chain is present and each link's scope is a superset of the next — i.e. monotonic non-increase along the chain. 5. No cached grant token older than the most recent upstream scope-narrowing event is in use. Conditions 1–3 are each necessary but none is sufficient; the invariant is their **intersection**, evaluated at the gateway at call time, not at mint time. Condition 5 is the temporal variant: the invariant must hold at *invocation* time, and caching silently breaks it. ## Decision Boundary Refuse (fail closed) when the intersection is strictly smaller than the requested terminal scope and the difference is non-empty. Do **not** silently downgrade to the intersection — silent downgrade hides escalation attempts and breaks the caller's expectation of atomic capability, producing partial tool execution that is harder to audit than a clean rejection. The boundary is: - `requested == intersection` → pass. - `requested ⊋ intersection` → raise, do not trim. - `requested ⊂ intersection` → pass (under-requesting is legitimate least-privilege). - `originator_scope` unknown/unresolvable → raise (cannot prove non-escalation). - cached grant predates a known narrowing event → invalidate and re-evaluate, never serve stale. ## Operational Effect Enforcing the conjunction at call time converts a silent cross-org privilege escalation into a loud, auditable rejection at the federation gateway. The cost is one additional scope-resolution step per call (originator lookup + treaty lookup + surface lookup), which is cacheable *per (principal, org-pair, server)* but must be invalidated on any upstream scope change. The measurable effect in the observed deployment: escalation attempts became attributable to a specific delegation hop, and stale-grant windows dropped from 15 minutes to the invalidation-propagation latency. The invariant to encode in the gateway is "delegation never widens scope," checked as an intersection, not a union. ```python from dataclasses import dataclass class DelegationScopeEscalation(RuntimeError): """Raised when a delegated MCP capability token is structurally valid but its terminal scope exceeds the permitted intersection.""" @dataclass(frozen=True) class FederationContext: originating_principal_scope: frozenset[str] federation_allowed_capabilities: frozenset[str] target_server_surface: frozenset[str] grant_cache_fresh: bool def authorize_delegated_call( requested_scope: frozenset[str], ctx: FederationContext, ) -> frozenset[str]: # Temporal invariant: never serve a grant that predates a narrowing event. if not ctx.grant_cache_fresh: raise DelegationScopeEscalation( "cached federation grant is stale relative to upstream scope narrowing; " "re-evaluate before dispatch" ) # The invariant is the INTERSECTION of all three authorities, not their union. permitted = ( ctx.originating_principal_scope & ctx.federation_allowed_capabilities & ctx.target_server_surface ) excess = requested_scope - permitted if excess: # Fail closed. Do not silently trim to `permitted`: a partial grant # masks the escalation and produces harder-to-audit partial execution. raise DelegationScopeEscalation( f"delegated scope escalation across MCP federation: " f"requested={sorted(requested_scope)} " f"permitted_intersection={sorted(permitted)} " f"excess={sorted(excess)}" ) if not ctx.originating_principal_scope: raise DelegationScopeEscalation( "originating principal scope unresolvable; cannot prove non-escalation" ) return requested_scope ```