The first Claude Code run investigated a payment failure for tenant northstar. Its MCP client opened a long-lived session to the support server and established this context:
principal: support-agent-prod
tenant: northstar
workspace: incident-7421
access: read_incident_data
The run finished. The client returned the connection to its pool.
A second run started for tenant acme. Its visible tool request named acme, and the reviewer approved acme. The pool handed it the healthy session from the first run. On that server, tenant context lived in session state rather than in each request.
The backend read Northstar’s incident data.
The tool name was right. The request arguments were right. TLS and authentication were right. The session was wrong.
Connection health says nothing about authority
Connection pools normally group sessions by destination. If the host, port, and protocol match, a session looks reusable. That works for stateless requests. It fails when initialization, authentication, server-side state, or a negotiated workspace gives the connection an identity of its own.
An MCP session can retain more than a socket. It may carry a bearer token, delegated principal, tenant selection, working directory, repository checkout, feature flags, or server-side cursor. A ping only proves the server still answers. It does not prove the session belongs to the next run.
This is separate from binding approval to the normalized tool arguments. The new request can survive normalization unchanged and still execute inside stale session state.
Give the session a binding
When a client creates or initializes a session, record the authority and mutable context attached to it. The pool key must include that binding, not only the endpoint.
mcp_session_binding:
session_id: mcp-session-918
server_identity: support-mcp-prod
principal_id: support-agent-prod
credential_fingerprint: "sha256:71ab..."
tenant_id: northstar
workspace_id: incident-7421
policy_version: 38
revocation_epoch: 12
allowed_effects:
- read_incident_data
created_at: 2026-09-03T09:03:11Z
expires_at: 2026-09-03T09:18:11Z
Do not let Claude Code declare this record. The client or gateway that owns the session should derive it from authenticated identity, resolved tenant, policy state, and the server’s initialization response.
A connection is reusable only when every authority-bearing field matches the new run. If the tenant, principal, workspace, credential, policy version, or allowed effects differ, close the session and create another one. Resetting a field in the model’s prompt is not a session reset.
Partitioning the pool by tenant helps, but tenant alone is too coarse. Two runs for the same tenant may use different principals or permission sets. A revoked credential must not remain usable because the underlying connection still looks healthy.
Check again at dispatch
Pool admission is only the first check. Compare the active session binding with the canonical request and current policy immediately before sending each consequential call.
session_dispatch_gate:
run_id: cc-run-8842
tool_call_id: call-19
requested_tenant: acme
approved_tenant: acme
session_tenant: northstar
requested_workspace: incident-9104
session_workspace: incident-7421
current_policy_version: 38
session_policy_version: 38
binding_result: mismatch
request_sent: false
This gate must fail closed. Reinitializing the existing session is safe only if the protocol and server provide a verified way to clear all authority-bearing state. In most systems, closing it is cheaper than proving that no tenant context survived.
Keep the dispatch decision outside the model. Claude Code can retry after the client establishes a matching session, but it cannot decide that two customer contexts are close enough.
Record what the backend used
A blocked client request is useful evidence, but successful calls need an effect-side check too. The backend should return the tenant and principal it actually used, preferably through trusted metadata rather than response prose.
session_binding_receipt:
run_id: cc-run-8842
tool_call_id: call-20
session_id: mcp-session-922
binding_digest: "sha256:09de..."
requested_tenant: acme
approved_tenant: acme
backend_tenant: acme
principal_id: support-agent-prod
workspace_id: incident-9104
policy_version: 38
request_sent: true
boundary_match: true
status: allowed
Put that receipt in the review packet. It lets an operator distinguish a bad model choice from stale infrastructure state. It also gives incident responders the session IDs and binding digests they need to find every affected call.
Treat tenant and principal metadata as sensitive. Logs should preserve stable identifiers for investigation without copying customer data or credentials into the trace.
Test the pool, not only fresh sessions
A happy-path test that creates a new client for each case will miss this failure. Force reuse and rotation in the eval:
fixtures:
- case: same_tenant_same_principal_reuse
expect: dispatch_allowed
- case: tenant_changes_on_pooled_session
expect: old_session_closed
- case: principal_changes_within_tenant
expect: fresh_session_required
- case: permission_revoked_while_session_is_idle
expect: dispatch_blocked
- case: workspace_changes_after_checkout
expect: fresh_session_required
- case: expired_binding_on_healthy_connection
expect: dispatch_blocked
- case: backend_reports_different_tenant
expect: incident_and_run_stop
Run the cases against a backend canary that records which tenant received each request. The assertion that matters is not client returned forbidden. It is the wrong tenant received zero requests.
Long-lived sessions are useful, but reuse is an authority decision. Bind each MCP session to its principal, tenant, workspace, policy state, and expiry. Then make the client prove that the binding still matches before every dispatch.
Claude Code: Building Production Agents That Actually Scale covers the wider operating model for MCP boundaries, permissions, evals, observability, rollback, cost control, and review packets.