Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 25: Multi-Tenancy

In June 2025, Asana took its MCP server offline after finding a tenant-scoping flaw that could have shown one organization’s data to users from other organizations; follow-up reporting put the potential exposure at roughly 1,000 organizations. Nobody bypassed authentication. Every request carried a valid identity, and the server still did work for the wrong tenant. Chapter 21 covers the incident; this chapter covers the design that prevents it.

Multi-tenancy is where an MCP server stops being an integration and becomes shared platform infrastructure. Tenant scope is not a feature you add to a single-tenant design. It is a security property that has to survive every request path, cache, handle, queue, log line and downstream call.

The rule for this chapter: the server derives the tenant once, from the verified token, and every value the model hands back must prove it belongs to that tenant and that user. Nothing the model says, and nothing the model carries, decides scope.

Choose Isolation by Who Reviews the Shared Parts

There are three normal isolation models.

ModelStrengthsWeaknessesBest fit
Dedicated server per tenantClear trust boundary, easier reasoning, simpler incident containmentHigher infrastructure and rollout costHigh-sensitivity tenants, heavy customization, regulated environments
Shared server with tenant-aware policyEfficient, flexible, easier fleet managementHigher risk if identity or scoping bugs existLarge shared platforms with disciplined controls
HybridStronger isolation for sensitive tenants, efficiency for the restMore operational variation to manageMixed-risk portfolios, staged enterprise growth

Default to a shared server only if you can name the person who reviews every new cache key, queue and handle format for tenant scope. If you cannot, start dedicated and consolidate later. Consolidating a proven dedicated deployment is a migration; un-leaking a shared one is an incident.

Tenant Context Comes From the Token

The model may mention a tenant in natural language. That is useful for UX. It is not a security boundary.

The tickets server builds one AuthContext per request, {subject, clientId, tenantId, scopes, roles}, from the validated access token (Chapter 19 maps provider claims such as tid into tenantId). No tool has a tenant or owner argument, and a contract test asserts that none ever appears (Chapter 12). Prompts, free-form arguments and remembered conversation can help confirm intent. They never decide access.

Handles Are Tenant-Scoped Capabilities

Under the 2026-07-28 revision there are no protocol sessions. State that must survive across calls, such as a result page, a staged change or a job, travels as a server-minted handle that the model passes back as an ordinary tool argument (Chapter 7).

That makes every handle model-controlled input. The model can copy it into another conversation. A prompt-injected document can plant one it saw elsewhere. A user can paste one from a colleague’s transcript. If the handle is a bare database offset or row ID, whoever presents it gets what it points to.

Treat a handle as a capability, not a pointer: it is only valid for the tenant and principal it was minted for, and only until it expires. Bind all three with an HMAC, and verify against the caller’s authenticated context, never against claims inside the handle:

import base64
import hashlib
import hmac
import json
import time

from mcp.server.mcpserver.exceptions import ToolError

HANDLE_KEY = b"load-from-your-secret-store"  # one key per deployment; rotate like any signing key


def _sign(tenant: str, principal: str, expires: int, body: str) -> str:
    msg = f"{tenant}|{principal}|{expires}|{body}".encode()
    return base64.urlsafe_b64encode(hmac.new(HANDLE_KEY, msg, hashlib.sha256).digest()).decode()


def mint_handle(auth, payload: dict, ttl_s: int = 900) -> str:
    expires = int(time.time()) + ttl_s
    body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
    return f"{body}.{expires}.{_sign(auth.tenantId, auth.subject, expires, body)}"


def open_handle(auth, handle: str) -> dict:
    """Verify against the caller's authenticated tenant and subject, never the handle's own claims."""
    try:
        body, expires_s, sig = handle.split(".")
        expires = int(expires_s)
    except ValueError:
        raise ToolError("That page handle is malformed. Run the search again.") from None
    if not hmac.compare_digest(sig, _sign(auth.tenantId, auth.subject, expires, body)):
        raise ToolError("That page handle was not issued to you. Run the search again.")
    if expires < time.time():
        raise ToolError("That page handle has expired. Run the search again.")
    return json.loads(base64.urlsafe_b64decode(body))

search_tickets uses it for paging:

@mcp.tool()
async def search_tickets(
    status: Literal["open", "closed", "pending"] = "open", page: str | None = None
) -> dict:
    """Search support tickets in your workspace. To continue, pass `page` from the previous result."""
    auth = whoami()
    offset = open_handle(auth, page)["offset"] if page else 0
    rows = await api.fetch_tickets(tenant=auth.tenantId, status=status, offset=offset, limit=PAGE + 1)
    result = {"tickets": rows[:PAGE]}
    if len(rows) > PAGE:
        result["page"] = mint_handle(auth, {"offset": offset + PAGE})
    return result

Three details matter. The tenant and subject are never stored in the handle, so a forger has nothing to edit; they are recomputed from the token on every call. The expiry is inside the signed message, so extending it breaks the signature. And the query still filters by auth.tenantId: the handle narrows what the caller may ask for, it does not replace the scope check.

The payload is signed, not encrypted. The model can read {"offset": 20}. If a handle must carry something the user should not see, encrypt it too. For the requestState of multi round-trip requests, the Python SDK already seals and principal-binds the value for you; your own tool-argument handles are your job.

Where Cross-Tenant Leaks Actually Happen

The query filter is the obvious control, and teams harden it first. The leaks come from everywhere else:

  • Caches that do not key on tenant. f"tickets:{status}" serves one tenant’s results to the next caller. The key is f"{auth.tenantId}:search_tickets:{args_hash}".
  • Background jobs that lose the tenant after the initial request, then write results to a default scope.
  • Cursors and handles that point at data without proving who may follow them (the section above).
  • Shared catalog caches. Since 2026-07-28, tools/list results carry ttlMs and cacheScope. "public" tells the client, a shared gateway or any caching proxy that it may serve the cached list to any user. "private" means the cache must not be shared across authorization contexts. If one tenant’s catalog differs from another’s (feature flags, tier, per-tenant tools), a "public" scope lets a shared gateway hand tenant A’s tool list to tenant B. The official Python SDK defaults to "private". Keep it there unless the list is identical for every caller, and never treat cacheScope as access control: the spec requires per-primitive checks regardless.
  • Logs and analytics that aggregate payloads across tenants.
  • Object-storage paths built from a tenant name the caller supplied.
  • Downstream APIs that accept a tenant identifier without checking that the caller may act for it.

If you only harden the database query, you have not solved multi-tenancy.

Enforce Scope at More Than One Layer

This table is the book’s reference for which layer owns which tenant control. Chapter 26 builds the gateway row.

LayerExample controls
IdentityToken validation, principal-to-tenant binding, environment restrictions
GatewayPer-tenant tool allowlist, tenant budgets, rate limits (Chapter 26)
Server logicTenant from AuthContext, mandatory scope checks, bound handles, per-tool authorization
Data layerSchema separation, row-level security, tenant-aware indexes or buckets
Cache and task layerTenant-keyed caches, "private" cache scope for per-tenant lists, queue partitioning
ObservabilityTenant labels in metrics, logs and traces without sensitive payloads

A single layer of isolation fails because the rest of the platform quietly routes around it. The row-level security policy does not see the cache. The cache does not see the handle.

Per-Tenant Variation Belongs in Configuration

Real platforms need tenant-specific behaviour: quotas, region routing, tier-gated tools, custom downstream credentials, compliance restrictions. Keep it in policy or configuration with a named owner, reviewed like code and covered by tests that prove the configured behaviour matches policy. Do not bury it in handler branches that only long-tenured engineers remember. Every per-tenant tool difference is also a per-tenant catalog, which brings back the cache-scope question above.

Hostile Tests, Not Happy Tests

Multi-tenant tests should attack the server. These run against the tickets server in-process with two tenants and three users:

ALICE = AuthContext("u_alice", "c1", "acme")
CAROL = AuthContext("u_carol", "c1", "acme")  # same tenant, different user
BOB = AuthContext("u_bob", "c1", "globex")


async def call(who, tool, args):
    async with Client(build_server(TwoTenantAPI(), whoami=lambda: who)) as client:
        result = await client.call_tool(tool, args)
    return result.is_error, result.content[0].text


async def test_foreign_ticket_looks_missing():
    foreign = await call(ALICE, "get_ticket", {"ticket_id": "G-1"})    # globex's ticket
    missing = await call(ALICE, "get_ticket", {"ticket_id": "A-999"})  # does not exist
    assert foreign[0] and missing[0]
    assert foreign[1].replace("G-1", "?") == missing[1].replace("A-999", "?")


async def test_handle_replayed_by_another_tenant_is_refused():
    _, text = await call(ALICE, "search_tickets", {})
    is_error, message = await call(BOB, "search_tickets", {"page": json.loads(text)["page"]})
    assert is_error and "not issued to you" in message


async def test_handle_replayed_by_same_tenant_other_user_is_refused():
    _, text = await call(ALICE, "search_tickets", {})
    is_error, _ = await call(CAROL, "search_tickets", {"page": json.loads(text)["page"]})
    assert is_error

The first test pins a subtle property: a foreign ID must be indistinguishable from a missing one, or the error message itself becomes an oracle for which IDs exist in other tenants. The third test catches the bug where a handle is bound to the tenant but not the user, which is enough to leak one user’s private queue to a colleague.

Also test containment. It is not enough to know a leak is possible; you need to know whether you can isolate one tenant or whether the architecture forces a full-service shutdown.

Failure Modes

  1. Unbound handle. A page or job handle is a bare offset or ID. A handle copied from one conversation into another’s tool call returns the first tenant’s data. Detect it with the replay tests above; they fail on the first run.
  2. Public cache scope on a per-tenant catalog. Per-tenant feature flags ship, tools/list still says "public", and a shared gateway cache serves one tenant’s tools to another. Detect it by listing tools as two tenants through the gateway and diffing the results.
  3. Existence oracle. “Ticket G-1 belongs to another organization” confirms G-1 exists. Return the same error for foreign and missing IDs.
  4. Tenant lost in the background. A job started under tenant A retries without its context and writes to a default scope. Carry AuthContext in the job payload, and fail closed when it is absent.
  5. Handle key never rotated. One HMAC key signs every handle for years, and a leaked key forges any tenant’s handle. Rotate it on the same schedule as other signing keys, accepting old and new keys during the overlap.

Implementation Checklist

  • AuthContext comes from the verified token; no tool schema has a tenant, owner or user argument.
  • Every cross-call handle is HMAC-bound to tenant, subject and expiry, and verified against the caller’s context.
  • Every cache key starts with tenantId; per-tenant list results use cacheScope: "private".
  • Queue messages and job records carry the full AuthContext.
  • Hostile tests cover foreign IDs, cross-tenant and cross-user handle replay, and tampered handles.
  • One tenant can be rate-limited, disabled or have its credentials rotated without touching the others.
  • Before converting an existing server to shared tenancy, audit identity resolution, single-workspace assumptions, shared caches and queues, and logs that may already hold mixed-tenant data. Narrow the tool surface first.

What Comes Next

Every layer should answer the same question the same way: which tenant is this request acting for, and why is that answer trustworthy? If one layer cannot answer, that is where the next incident starts. The layer most teams put in front of all of this is a gateway, and in MCP a gateway can see the tenant and the tool name on every call without reading the body. Chapter 26 builds the per-tenant tool firewall on top of the identity this chapter established.