The claim that isn’t the boundary

Put a tenant_id in the JWT, scope every tool call and credential lookup to it, and it’s tempting to call the isolation problem solved. It isn’t, and the gap between “scoped by claim” and “actually isolated” is exactly where multi-tenant MCP deployments tend to fail in production rather than in testing.

A tenant_id claim is an assertion the server chooses to honor. It works right up until one code path forgets to check it — a new tool added without the same row-level filter as the rest, a cached tool-list response served to the wrong tenant because a cache key didn’t include the tenant boundary, or an agent that legitimately holds a valid token for tenant A but, mid-reasoning, aggregates a result that happened to include data from tenant B because nothing at the execution layer prevented it from seeing that data in the first place. None of these are exotic; they’re the ordinary consequence of enforcing a boundary in exactly one layer of the stack and assuming that’s the whole boundary.

Deploying MCP Servers on Kubernetes set up a labeling convention — team/tenant keys on every Deployment and Service — specifically so this article wouldn’t have to start from a flat, unlabeled cluster. This article is about what actually gets built on top of that convention: three architectural patterns for multi-tenant MCP, the layer each one enforces isolation at, and which combination of them a given compliance posture actually requires.

Three patterns, not one

Multi-tenant MCP deployments in production settle into three recognizable shapes. They aren’t mutually exclusive — the pattern most SaaS platforms actually run is a hybrid of the first two — but it’s worth understanding each on its own terms before combining them.

Pattern Isolation layer Cost profile Fits
Shared instance, claim-scoped Application/data layer (row-level security, tenant_id on every query) Lowest — one fleet serves every tenant Self-serve SaaS, low-sensitivity data, cost-sensitive scale
Dedicated compute per tenant Infrastructure layer (namespace, network, compute boundary) Highest — replicas and overhead per tenant Enterprise/regulated customers — SOC 2, HIPAA, FedRAMP
Hybrid — shared compute, per-tenant credential vault Credential layer, with claim-scoping underneath Moderate — shared fleet, isolated secrets Most production SaaS: cost of shared compute, blast-radius limit of isolated credentials

The pattern chosen isn’t primarily a technical decision — it’s downstream of what a given customer segment’s compliance posture actually demands, which makes it worth settling explicitly and early rather than discovering under audit that the shared-instance pattern a platform quietly grew into doesn’t satisfy a contract signed with an enterprise customer. It’s also worth resisting the temptation to standardize on one pattern platform-wide purely for engineering simplicity — a self-serve SaaS product and its first enterprise customer routinely need different answers to this question, and forcing both onto pattern 1 to avoid maintaining two deployment shapes tends to be 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

This is where nearly every multi-tenant MCP deployment starts, because it’s the cheapest to run and the fastest to build. The critical detail the “put tenant_id in the JWT” version of this pattern usually gets wrong is treating claim extraction as authorization, when it’s only ever identification — the actual boundary has to be enforced at the data layer, on every single path a request can take, not asserted once at the gateway and trusted afterward:

// Tenant context extracted once, but enforced on every data access —
// not trusted after extraction.
async function handleToolCall(req: McpRequest): Promise<McpResponse> {
  const claims = await verifyToken(req.headers.authorization);
  const tenantId = claims.tenant_id;

  // Every downstream call carries tenantId explicitly — no ambient
  // "current tenant" global that a new code path could forget to check.
  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 here matter more than they look. Returning tool_not_found rather than a 403 when a tool genuinely exists but belongs to a different tenant avoids confirming to a caller that the tool exists at all — a small thing, but the difference between an isolation boundary and an isolation boundary that leaks information about what’s on the other side of it. And passing tenantId explicitly through every function call, rather than reading it from a request-scoped global or thread-local, 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 getting the authorization framework right at the protocol level, not just the application level. The specification requires OAuth 2.1 with PKCE for remote server authentication, explicitly prohibits token passthrough, and requires each token to be scoped to one specific MCP server using Resource Indicators (RFC 8707) — which closes a specific failure mode where a token issued for one MCP server gets replayed against a different one it was never meant to authorize. None of that is optional hardening on top of this pattern; it’s the baseline the pattern depends on to be a security boundary at all rather than a convention everyone happens to follow.

Pattern 2: dedicated compute per tenant

For tenants under SOC 2, HIPAA, or FedRAMP obligations, claim-scoping inside a shared process is rarely sufficient on its own — the compliance requirement is usually for a demonstrable infrastructure-level boundary, not just an application-level one. This is where the namespace-and-labeling convention from the previous article stops being forward-compatible hygiene and starts being the actual isolation mechanism:

apiVersion: v1
kind: Namespace
metadata:
  name: mcp-tenant-acme
  labels:
    tenant: acme
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-quota
  namespace: mcp-tenant-acme
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "20"
---
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: {}   # allow DNS/control-plane egress separately
          podSelector:
            matchLabels:
              k8s-app: kube-dns

The ResourceQuota addresses the noisy-neighbor problem this pattern would otherwise still share with pattern 1 — without it, one tenant’s traffic spike can starve every other tenant even inside dedicated namespaces, if those namespaces still compete for the same underlying node pool. 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, closing the lateral-movement path a compromised pod would otherwise have into a neighboring tenant’s namespace. RBAC bindings scoped to the namespace complete the picture, but the pattern only holds if all three — 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 itself

Namespace-level isolation stops one tenant’s pod from reaching another tenant’s pod over the network. It does nothing about a single pod’s own execution boundary — if an agent within that pod runs arbitrary code or shells out to execute a tool, a container escape via a kernel vulnerability still exposes the whole node, tenant labeling notwithstanding. Standard Docker/containerd containers share the host kernel; a RuntimeClass backed by gVisor interposes a user-space kernel so agent-executed code never touches host syscalls directly, trading some syscall-emulation overhead for a real boundary against kernel-level escape:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
  namespace: mcp-tenant-acme
spec:
  template:
    spec:
      runtimeClassName: gvisor
      containers:
        - name: mcp-server
          # ...as in the previous article

This is worth reserving for the tools that actually warrant it — an MCP server whose tools only make outbound API calls to well-known SaaS endpoints has a much smaller execution-risk surface than one that runs arbitrary code, shells out to a subprocess, or interprets tenant-supplied scripts. Applying gVisor uniformly to every deployment regardless of what the server’s tools actually do adds latency for a boundary that pattern 1’s tools may not need; reserving it for the specific tool set that executes untrusted logic keeps the overhead proportional to the actual risk.

It’s worth being specific about what gVisor buys here versus what it doesn’t. A runsc-backed pod still shares the Kubernetes control plane, the node’s cgroup accounting, and — absent the NetworkPolicy from the previous section — the node’s network stack with every other pod scheduled onto that node. 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. Treating gVisor as a substitute for network-layer isolation rather than a complement to it is a common enough misreading of what the sandbox actually protects that it’s worth naming directly: 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.

For workloads where even gVisor’s syscall-level boundary isn’t strong enough — genuinely untrusted, tenant-supplied code rather than a fixed set of vetted tools — Kata Containers or Firecracker microVMs go further, giving each sandbox a dedicated kernel rather than an interposed one. That’s meaningfully more overhead per pod, and it’s a decision worth making deliberately for the specific tool category that needs it rather than applying uniformly: a code-execution tool that runs tenant-submitted scripts is a different risk category from a tool that calls a fixed, vetted set of third-party APIs, and the sandboxing strategy should track that distinction rather than treat every tool in a server’s catalog identically.

The hybrid most production platforms actually run

In practice, few platforms sit purely in pattern 1 or purely in pattern 2 — the pattern that shows up most in production is shared compute for the MCP server fleet itself, combined with a per-tenant credential vault so that a compromise of the shared fleet doesn’t automatically compromise every tenant’s downstream credentials at once:

// Credentials never touch the shared server's memory unscoped —
// each lookup returns a credential already bound to one tenant.
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 MCP 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 themselves 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 — it captures most of pattern 2’s blast-radius protection at close to pattern 1’s cost profile.

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 — and the way to close that gap is the same one used for the transport-compliance smoke test in the previous article: a scripted check that runs on every deploy rather than a manual review that happens once.

# Create two tenants, get scoped tokens, attempt cross-tenant read
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 the same CI pipeline that already runs the transport smoke test 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.

📌 Key Takeaway: A tenant_id claim identifies a request; it doesn’t isolate one. Real multi-tenant MCP isolation is layered — data-layer scoping, network-layer boundaries, execution-layer sandboxing, and credential-layer separation each close a different failure mode, and which combination a given deployment needs is set by the compliance posture of its most demanding tenant, not by whichever pattern was easiest to stand up first.