# What happens when an instance caches its MCP allowlist across a policy reload: stale-allowlist admission windows in tool-invocation enforcement **Date of Record:** 2026 November 13 **Scope:** Applies to MCP (Model Context Protocol) hosts that enforce per-session tool-invocation allowlists and support hot policy reload without restarting the process or tearing down active sessions. Does not apply to hosts that terminate all sessions on policy change, nor to stateless gateways that re-evaluate the allowlist against a shared store on every invocation. ## Core Distinction There are two materially different meanings of "the allowlist is enforced," and conflating them is the source of the failure: 1. **Evaluation-time enforcement** — at each `tools/call`, the host checks the requested tool name against the *currently loaded* policy object. 2. **Admission-time enforcement** — at session initialization (or at the first tool call), the host resolves the allowlist once, binds it to the session, and thereafter admits any tool in that bound set without re-reading policy. Both are defensible. Evaluation-time enforcement has higher per-call cost and can produce mid-session behavior changes that break agent plans; admission-time enforcement is cheaper and gives sessions a stable contract. The non-obvious boundary is that admission-time enforcement silently converts a **policy revocation** into a **lease** whose expiry is tied to session lifetime — not to the reload event. When an operator revokes a tool because it is dangerous *right now*, the admission-time model keeps admitting it until every session bound before the reload has ended. The distinction that matters operationally is not "cached vs. uncached" but **who owns the invalidation clock**: the policy subsystem (which knows a revocation happened) or the session lifecycle (which does not). ## What Was Observed A host process served long-lived agent sessions. Each session, on `initialize`, resolved the MCP server's `tools/list` against the active allowlist and stored the resulting set of permitted tool names on the session object. The `tools/call` handler checked membership in that stored set. Policy was delivered by a file-watch reloader that swapped an immutable policy object atomically — a correct pattern for the policy subsystem itself. A tool (`shell.exec` under a broadened profile) was revoked by pushing a new policy. The reloader swapped the object in ~40 ms and logged `policy_reload ok generation=418`. The revocation was correct at the policy layer. It was not effective at the enforcement layer. Three sessions had been initialized before the reload. Their bound allowlists still contained `shell.exec`. Over the next 6h12m, until those sessions were reaped by idle timeout, the host admitted 31 `shell.exec` calls that the current policy prohibited. Nothing errored. The policy generation counter was correct; the audit log recorded the calls as *allowed*, because from the enforcement layer's perspective they were allowed — by a policy object that no longer existed. The tell was a mismatch between two logs that were each internally consistent: the policy log said generation 418 prohibited `shell.exec`, and the invocation log said generation-bound sessions admitted it. No single log line was wrong. The invariant "an admitted tool was permitted by the policy in force at admission" held; the invariant operators actually wanted — "an admitted tool is permitted by the policy in force *now*" — did not. A second, subtler observation: sessions initialized *after* the reload were correctly restricted, so a naive test (revoke, then open a new session, then attempt the tool) passes. The failure only manifests on sessions that straddle the reload, which is precisely the population a smoke test does not cover. ## Condition of Validity Admission-time binding is safe **only** under one of these conditions, and the host must be able to prove which one holds: - **Bounded session TTL ≤ revocation SLA.** If sessions cannot outlive the maximum tolerable revocation latency, the lease is harmless. This requires the TTL to be enforced by the same subsystem that owns policy, not merely configured. - **Revocation is paired with forced invalidation.** The reload path must actively mark bound allowlists stale (bump a generation on each session, or evict sessions), so the next `tools/call` re-resolves. - **Revocations are advisory.** If the threat model treats policy as guidance rather than a security boundary, staleness is acceptable — but then it must not be described as an allowlist. The failure is *invalid* — i.e., admission-time binding is fine — when the policy's purpose is stability (pinning a session to a known tool surface for reproducibility) rather than security. The same mechanism is a feature or a defect depending entirely on whether revocation is expected to be immediate. This is the crux: the code is identical; only the contract differs, and the contract is usually undocumented. ## Decision Boundary Choose evaluation-time enforcement (or generation-stamped revalidation) when **any** of the following is true: - A tool can cause irreversible external effect (shell, network egress, credential use, financial or destructive filesystem operations). - The revocation workflow is expected to take effect "immediately" by a human operator who will not think to drain sessions. - Sessions are long-lived relative to the policy change cadence (agent sessions lasting hours vs. policy pushes lasting minutes). Choose admission-time binding when sessions are short, tools are side-effect-free or read-only, and the dominant cost is per-call policy evaluation under high invocation volume. The dangerous middle — long sessions, side-effecting tools, admission-time binding, and an operator who believes revocation is immediate — is where this failure lives. It is not detectable by testing the policy subsystem or the session subsystem in isolation; it requires a straddling-session test. The cheap fix is a generation stamp: bind `(allowlist, policy_generation)` to the session, and on each `tools/call`, if `session.generation != current_generation`, re-resolve before admitting. This preserves the stability benefit for unchanged policy and closes the revocation window to one call. The cost is a single integer comparison per invocation. ## Operational Effect Left unaddressed, the effect is a **silent revocation gap** whose width equals the maximum session lifetime, not the reload latency. Operators observing `policy_reload ok` reasonably conclude the change is live; the audit trail agrees, because the admission was legal under the bound policy. Incident response then chases the wrong layer — the policy file, the reloader, the generation counter — all of which are correct. The correct operational response is to treat the enforcement layer's *binding model* as a first-class, documented property, to instrument straddling sessions (count of active sessions whose bound generation differs from current), and to alert when that count is nonzero for longer than the revocation SLA. The alert is the invariant made visible: **zero sessions may admit a tool under a superseded policy generation beyond the SLA.** That is the checkable statement; "the allowlist is enforced" is not. ```python """Straddling-session detector for MCP allowlist enforcement. Models the invariant: no active session may admit a tool under a superseded policy generation beyond the revocation SLA. """ from dataclasses import dataclass, field from typing import Dict, Set class StaleAllowlistAdmissionError(RuntimeError): """Raised when a session admits a tool under a superseded policy generation.""" @dataclass class Session: session_id: str bound_generation: int bound_allowlist: Set[str] @dataclass class Policy: generation: int allowed_tools: Set[str] @dataclass class Host: current: Policy sessions: Dict[str, Session] = field(default_factory=dict) def initialize_session(self, session_id: str) -> Session: s = Session( session_id=session_id, bound_generation=self.current.generation, bound_allowlist=set(self.current.allowed_tools), ) self.sessions[session_id] = s return s def reload_policy(self, new_policy: Policy) -> None: # Atomic swap. Note: does NOT touch bound sessions. self.current = new_policy def call_tool(self, session_id: str, tool: str) -> None: s = self.sessions[session_id] if tool not in s.bound_allowlist: raise PermissionError(f"{tool} not in bound allowlist for {session_id}") # The boundary failure: admission is checked against the BOUND # generation, which may be superseded by self.current. if s.bound_generation != self.current.generation: if tool not in self.current.allowed_tools: raise StaleAllowlistAdmissionError( f"session {session_id} admitted {tool!r} under superseded " f"generation {s.bound_generation}; current generation " f"{self.current.generation} prohibits it" ) def straddling_sessions(self) -> int: gen = self.current.generation return sum(1 for s in self.sessions.values() if s.bound_generation != gen) def audit_revocation(host: Host, revoked_tool: str, sla_exceeded: bool) -> None: """Assert the operational invariant after a revocation.""" if sla_exceeded and host.straddling_sessions() > 0: raise StaleAllowlistAdmissionError( f"revocation of {revoked_tool!r} not effective within SLA: " f"{host.straddling_sessions()} session(s) still bound to a " f"superseded generation" ) if __name__ == "__main__": host = Host(current=Policy(generation=417, allowed_tools={"search", "shell.exec"})) host.initialize_session("sess-a") host.reload_policy(Policy(generation=418, allowed_tools={"search"})) # A post-reload session is correctly restricted. host.initialize_session("sess-b") try: host.call_tool("sess-b", "shell.exec") except PermissionError: print("sess-b correctly denied") # The straddling session reveals the gap. try: host.call_tool("sess-a", "shell.exec") except StaleAllowlistAdmissionError as e: print(f"detected: {e}") audit_revocation(host, "shell.exec", sla_exceeded=True) ```