Every multi-tenant MCP deployment starts with the same instinct: put a tenant_id in the JWT, scope every tool call to it, and call the isolation problem solved. It isn't. The gap between "scoped by claim" and "actually isolated" is exactly where these deployments fail in production — not in testing, where a single code path is exercised, but in production, where a new tool handler forgets the filter, a cache key drops the tenant dimension, or an agent legitimately holds a valid token but aggregates a result that happened to include data from a neighboring tenant.
This article is about what real isolation looks like across three architectural patterns, which layer each one enforces, and how to test the boundary rather than just build it.
Three patterns, not one
Multi-tenant MCP deployments settle into three recognizable shapes. The pattern a platform needs isn't primarily a technical decision — it's downstream of what its most demanding tenant's compliance posture actually requires.
| Pattern | Isolation layer | Cost profile | Fits |
|---|---|---|---|
| Shared instance, claim-scoped | Application/data layer | Lowest | Self-serve SaaS, low-sensitivity data |
| Dedicated compute per tenant | Infrastructure layer (namespace, network, compute) | Highest | SOC 2, HIPAA, FedRAMP tenants |
| Hybrid — shared compute, per-tenant credential vault | Credential layer + claim-scoping | Moderate | Most production SaaS |
Forcing both a self-serve SaaS product and its first enterprise customer onto pattern 1 purely for engineering simplicity is the decision that surfaces as a failed security questionnaire months later, at exactly the point in a sales cycle where it's most expensive to revisit.
Pattern 1: shared instance, claim-scoped
The critical detail the "put tenant_id in the JWT" version of this pattern usually gets wrong is treating claim extraction as authorization. It's only identification — the actual boundary has to be enforced at the data layer, on every path a request can take:
async function handleToolCall(req: McpRequest): Promise<McpResponse> {
const claims = await verifyToken(req.headers.authorization);
const tenantId = claims.tenant_id;
// tenantId passed explicitly — no ambient global a new handler could forget
const tools = await toolRegistry.listFor(tenantId);
if (!tools.find(t => t.name === req.params.name)) {
throw new McpError("tool_not_found", 404); // not 403 — don't confirm existence
}
const credentials = await credentialStore.get(tenantId, req.params.name);
return executeTool(req.params.name, req.params.arguments, credentials, tenantId);
}
Two details matter more than they look. Returning tool_not_found rather than 403 avoids confirming to a caller that a tool exists at all — the difference between an isolation boundary and one that leaks information about what's on the other side. And passing tenantId explicitly through every function call, rather than reading it from a request-scoped global, is deliberate: an ambient "current tenant" is exactly the kind of implicit state that a new tool handler, added six months later by someone who didn't write this code, can forget to consult.
This pattern also depends on the MCP spec's OAuth 2.1 + PKCE baseline and Resource Indicators (RFC 8707) — which closes the specific failure mode where a token issued for one MCP server gets replayed against a different one. None of that is optional hardening; it's the baseline the pattern depends on to be a security boundary at all.
Pattern 2: dedicated compute per tenant
For tenants under SOC 2, HIPAA, or FedRAMP obligations, claim-scoping inside a shared process is rarely sufficient — the compliance requirement is usually for a demonstrable infrastructure-level boundary. This is where namespace-and-labeling conventions stop being hygiene and start being the actual isolation mechanism:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-cross-tenant
namespace: mcp-tenant-acme
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels:
tenant: acme
egress:
- to:
- namespaceSelector:
matchLabels:
tenant: acme
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
The NetworkPolicy is the actual isolation enforcement: pods in mcp-tenant-acme can only reach other pods labeled with the same tenant, plus an explicit DNS exception. A ResourceQuota alongside it addresses the noisy-neighbor problem — without it, one tenant's traffic spike can starve every other tenant even inside dedicated namespaces. The pattern only holds if quota, network policy, and RBAC are applied consistently across every tenant namespace — which is exactly the kind of thing worth generating from a template rather than hand-writing per tenant as the customer count grows.
Sandboxing the execution layer
Namespace-level isolation stops one tenant's pod from reaching another over the network. It does nothing about a single pod's own execution boundary. Standard containers share the host kernel — a RuntimeClass backed by gVisor interposes a user-space kernel so agent-executed code never touches host syscalls directly:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
Worth being specific about what gVisor buys versus what it doesn't. A runsc-backed pod still shares the Kubernetes control plane and — absent the NetworkPolicy above — the node's network stack. The syscall interception layer closes one specific class of escape (a kernel exploit reached through a container's syscall surface); it says nothing about a request that legitimately reaches the pod over the network and abuses an application-level bug once inside. Pattern 2's NetworkPolicy and this section's RuntimeClass are answering two different questions — who can reach this pod, and what can code running inside this pod reach past the kernel — and a deployment that only implements one of them has a boundary with a specific, known hole in it.
The hybrid most production platforms actually run
In practice, the pattern that shows up most in production is shared compute for the MCP server fleet combined with a per-tenant credential vault — so a compromise of the shared fleet doesn't automatically compromise every tenant's downstream credentials at once:
async function getCredential(tenantId: string, toolName: string): Promise<Credential> {
const vaultPath = `tenants/${tenantId}/tools/${toolName}`;
return vaultClient.read(vaultPath); // short-lived, tenant-scoped token
}
The credential vault (HashiCorp Vault, AWS Secrets Manager with per-tenant IAM policies, or Azure Key Vault with per-tenant access policies) becomes the actual blast-radius boundary. A bug in the shared server's tenant-scoping logic is a serious incident, but it isn't automatically a credential-exfiltration incident for every tenant simultaneously — because the credentials were never loaded into a shared, unscoped in-memory store to begin with. This is the pattern worth defaulting to unless a specific tenant's compliance requirement forces pattern 2's dedicated compute.
Testing the boundary, not just building it
An isolation pattern that hasn't been tested for cross-tenant leakage is a claim, not a guarantee:
TOKEN_A=$(get_token_for_tenant acme)
TOKEN_B=$(get_token_for_tenant globex)
TOOL_ID=$(curl -s -X POST https://mcp.example.com/mcp \
-H "Authorization: Bearer $TOKEN_A" \
-H "Mcp-Method: tools/call" -H "Mcp-Name: create-record" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create-record"}}' \
| jq -r '.result.id')
# Tenant B attempting to read Tenant A's record must fail
curl -s -X POST https://mcp.example.com/mcp \
-H "Authorization: Bearer $TOKEN_B" \
-H "Mcp-Method: tools/call" -H "Mcp-Name: get-record" \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"get-record\",\"arguments\":{\"id\":\"$TOOL_ID\"}}}" \
| jq -e '.error.code == "tool_not_found" or .error.code == "not_found"'
Wiring this into CI means an accidental regression in tenant-scoping logic — a new tool handler that forgets the filter, a cache key that drops the tenant dimension — fails a build instead of surfacing as a support ticket from a customer who noticed data that wasn't theirs.
Read the Full Article
This summary covers the three patterns and their core mechanics. The full article includes:
- Why
tool_not_foundvs403matters for information leakage at the isolation boundary - The full
ResourceQuota+NetworkPolicy+ RBAC combination for dedicated-compute tenants - When to use gVisor vs Kata Containers vs Firecracker, and how to match sandboxing depth to actual tool risk
- The OAuth 2.1 + Resource Indicators baseline that pattern 1 depends on to be a real security boundary
- Why the hybrid pattern captures most of pattern 2's blast-radius protection at close to pattern 1's cost
👉 Multi-Tenant MCP: Isolation Patterns That Hold Up — Full Article