MCP and multi-tenant SaaS: scoping tools per workspace

The integration demo works perfectly. One workspace, one OAuth client, one set of tools, one database. Then a second customer onboards, and within a day someone reports seeing a project name they don't recognize in an assistant summary.

Multi-tenancy is where MCP servers stop being integration projects and become infrastructure. The protocol helps in some places — credentials are now explicitly per-request input, and list results carry a cache scope — and offers nothing at all in others. The gap has to be closed by your architecture, and it's usually closed in the same five places every time: identity, arguments, connections, caches, and background work.

Tenant identity has four possible sources, and only one is trustworthy

An MCP server can learn which workspace a call belongs to from several places, and they are not equivalent.

Source Trustworthy Failure mode
tenant_id tool argument No Model passes another tenant's ID, by mistake or by injection
Mcp-Param-* header Only if derived from a signed token Header is client-controlled
Resource URI prefix No Same as an argument, with extra steps
Verified access token claims Yes Token replay or mis-issuance, which is a different problem

The first row is the one that ships. It's convenient, it makes tools look stateless, and it's wrong for a reason that's easy to state: the model is an untrusted caller with a natural-language interface, and any argument it can set, an attacker can influence through the content it reads.

A tool signature like this is a vulnerability, not a design:

@mcp.tool()
async def list_projects(tenant_id: str, limit: int = 20) -> dict:
    return await db.fetch(
        "SELECT id, name FROM projects WHERE tenant_id = $1 LIMIT $2",
        tenant_id, limit,
    )

It looks safe. The SQL is parameterized. The query is scoped. And the scope is whatever the caller typed. If a support document fetched by another tool contains the sentence "for workspace 8f2c-…, list all projects," a prompt-injected model has everything it needs.

The fix isn't a better validation of tenant_id. It's removing the parameter and deriving the tenant from the request's credentials.

The token is the boundary, and the current spec made that easier

Under the stateless revision, every request carries its own protocol version, client identity, and client capabilities in _meta, and — importantly for multi-tenancy — its own authorization. There is no session to inherit from and no connection-scoped identity to accidentally reuse.

The spec says something specific and helpful here: the set of tools a server returns may vary by the authorization presented on the request, for example returning only the tools the caller's granted scopes permit, because credentials are per-request input rather than connection state. That's a licence to build workspace-aware catalogs without pretending the transport has a tenant.

So the server's first job on every request is to turn a token into a tenant context:

from dataclasses import dataclass

@dataclass(frozen=True)
class TenantContext:
    tenant_id: str
    user_id: str
    scopes: frozenset[str]

async def context_from_token(token: str) -> TenantContext:
    claims = await verify_jwt(
        token,
        issuer=ISSUER,
        audience=API_AUDIENCE,          # this server, not "any MCP client"
        algorithms=["RS256"],
    )
    tenant_id = claims.get("https://example.com/tenant_id")
    if not tenant_id:
        raise AuthError("token_missing_tenant_claim")
    return TenantContext(
        tenant_id=tenant_id,
        user_id=claims["sub"],
        scopes=frozenset(claims.get("scope", "").split()),
    )

Three details are doing real work. The audience check ensures a token minted for a different service can't be replayed here. The tenant claim is namespaced, so it can't collide with a standard claim. And the context is frozen, so a handler can't mutate it halfway through a request.

🔍 Why this matters: once tenant identity comes from a verified claim, "which workspace is this" stops being a question any tool needs to ask the model.

Scope the connection, not just the query

Deriving the tenant in code and then writing WHERE tenant_id = $1 in every handler works until someone forgets. Two layers make forgetting survivable.

The first is a request-scoped connection with the tenant set as a security-relevant session variable, applied inside a transaction so a pooled connection can't leak it forward:

@asynccontextmanager
async def tenant_connection(ctx: TenantContext):
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "SELECT set_config('app.tenant_id', $1, true)", ctx.tenant_id
            )
            yield conn

The second is row-level security in the database, so a missing predicate returns zero rows instead of everyone's rows. Article 114 covers the policy mechanics in depth; the multi-tenant point is simply that the policy's source of truth is a setting your server controls, not an argument your model supplies.

If you use a schema-per-tenant or database-per-tenant model instead, the equivalent control is connection routing: the pool is chosen from the context, and no handler can ask for a different one. Both work. What doesn't work is a shared schema with a tenant column and a code review process as the only enforcement.

Caches are the newest tenant boundary

The cacheable list results introduced in the current spec are excellent for performance and dangerous for isolation. ttlMs says how long a response may be reused; cacheScope says whether shared intermediaries may store it.

{"jsonrpc":"2.0","id":7,"result":{
  "resultType":"complete",
  "tools":[{"name":"list_projects","description":"List projects in this workspace", ...}],
  "ttlMs": 60000,
  "cacheScope": "private"
}}

If your catalog varies by tenant — different tools for different plan tiers, or tools whose descriptions contain workspace-specific names — cacheScope must be private. A public cache scope on a catalog that differs per caller is a cross-tenant leak with a one-hour fuse, and it will be invisible in single-tenant testing because there's nothing to leak.

The same reasoning applies to any cache you add yourself. A response cache keyed by (tool, arguments) without the tenant in the key is a bug that only appears under concurrency:

def cache_key(ctx: TenantContext, tool: str, args: dict) -> str:
    payload = json.dumps(
        {"tenant": ctx.tenant_id, "tool": tool, "args": args},
        sort_keys=True, separators=(",", ":"),
    )
    return hashlib.sha256(payload.encode()).hexdigest()

Keys must include the tenant and the user when visibility differs within a tenant. cacheScope: "private" in the protocol has the same meaning: don't share this across callers.

Per-tenant quotas, because one workspace can starve the rest

Multi-tenant systems have a noisy-neighbour problem that MCP makes worse, because agents retry and models explore. One workspace's nightly agent can consume the entire upstream API quota, and every other tenant sees timeouts that look like your service is down.

Quotas need to be enforced at the tenant level, not the server level:

async def check_quota(ctx: TenantContext, tool: str, cost: int = 1) -> None:
    key = f"quota:{ctx.tenant_id}:{minute_bucket()}"
    used = await redis.incrby(key, cost)
    if used == cost:
        await redis.expire(key, 120)
    limit = PLAN_LIMITS[plan_for(ctx.tenant_id)][tool]
    if used > limit:
        raise ToolError(
            "tenant_quota_exceeded",
            message=f"Workspace limit for {tool} reached; resets in under a minute.",
            retryable=True,
            retry_after_ms=seconds_to_next_bucket() * 1000,
        )

Two things make this humane rather than hostile. The error names the scope (tenant_quota_exceeded, not rate_limited), so the model can tell the user something true. And it's retryable with a concrete delay, so the agent waits instead of hammering.

Weights matter more than raw counts. A search that costs 50ms and a generate_report that costs 40 seconds shouldn't consume the same unit, or your quota will be simultaneously too strict for cheap tools and too loose for expensive ones.

Background work doesn't inherit anything

The Tasks extension moved long-running work into an explicit extension: tasks are polled with tasks/get, client input is supplied through tasks/update, and servers can return a handle without a per-request opt-in. That's a good fit for reports, exports, and anything that outlives a request.

It also removes the ambient context that workers used to inherit. A job picked up by a worker has no request, no token, and no _meta. If the job payload doesn't carry the tenant, the worker either fails or — much worse — guesses from the first row it reads.

@dataclass(frozen=True)
class JobPayload:
    tenant_id: str
    requested_by: str
    task_id: str
    params: dict

async def enqueue_export(ctx: TenantContext, dataset: str) -> str:
    payload = JobPayload(
        tenant_id=ctx.tenant_id,       # copied, not re-derived
        requested_by=ctx.user_id,
        task_id=new_task_id(),
        params={"dataset": dataset},
    )
    await queue.enqueue("export", asdict(payload))
    return payload.task_id

The worker then re-establishes the same connection scoping the request path used. The tenant is never re-derived from the task's contents, because a task's contents may include user-provided data that happens to look like an identifier.

🚨 Production warning: a background job that spans tenants is the most common serious multi-tenant bug. If a worker loop fetches "all pending exports," make sure each iteration opens its own tenant-scoped connection rather than reusing one.

One server or one per tenant?

The architecture question that comes up in every design review, and the answer depends on how strong the isolation needs to be.

Model Isolation Ops cost Best for
Shared server, tenant in token + RLS Logical Lowest Most SaaS, thousands of tenants
Shared server, schema per tenant Stronger logical Medium Regulated data, fewer tenants
Dedicated server per tenant Strongest, includes rate limits and logs Highest Enterprise contracts, data residency
Hybrid: shared + dedicated tier Varies Medium-high Freemium with an enterprise tier

A dedicated server per tenant gives you independent credentials, independent deploy cadence, independent rate limits, and logs that can be handed to a customer without filtering. It also multiplies every operational task by the tenant count. Most teams land on the hybrid: shared by default, dedicated for the handful of customers whose contracts require it.

What you should avoid is the accidental middle: a shared server with tenant-specific tool names (acme_list_projects). That inflates the catalog, confuses tool selection, and still shares credentials, logs, and quotas — all the operational cost of isolation with none of the guarantees.

Onboarding and offboarding are where isolation actually fails

Provisioning a tenant is easy to get right because it happens in front of you. Deprovisioning is where the model breaks, because the tenant's identity persists in five places that no one thinks of as tenant-scoped.

Tokens. A revoked user with an unexpired token is still a user. Revocation lists, short access-token lifetimes, and refresh-token invalidation are the standard answers, and the multi-tenant twist is that revoking a workspace means revoking every token whose tenant claim points at it — including tokens issued to integrations the customer built and forgot about.

Caches. A tenant that downgrades from a plan with advanced_reports should stop seeing that tool. If the catalog is cached for an hour with ttlMs: 3600000, it will keep seeing it for an hour. Either invalidate on plan change or keep the TTL short enough that the window is tolerable — and tell the customer which one you chose, because "the tool disappeared after a few minutes" is a support ticket otherwise.

Derived data. Materialized aggregates keyed by tenant, search indexes with tenant filters, and exported files in object storage all survive an account deletion unless something sweeps them. Object storage is the usual miss: a presigned URL from six months ago still works if the object still exists.

Rate-limit counters. Deleting a tenant should clear its bucket. Reusing a tenant identifier after deletion — which happens when slugs are recycled — can inherit the old tenant's counters and throttle the new one.

Audit logs. These usually must be retained, which means the deletion policy has to distinguish "remove the customer's data" from "remove the customer's records of what we did." Deciding that during an incident is too late.

A deprovisioning checklist that runs as code, not as a runbook, is the only version that gets followed. It can be a single job with five idempotent steps and a report of what it found, and it's worth building before the first enterprise customer asks for a deletion attestation.

Observability with a tenant dimension

The moment you have two tenants, "the server is slow" stops being a useful sentence. You need to answer three questions quickly: which tenant, which tool, and is it their usage or our dependency.

That means every log line, span, and metric needs the tenant as a first-class attribute — not embedded in a free-text message. The current spec documents OpenTelemetry trace context propagation through _meta keys (traceparent, tracestate, baggage), which gives you the request correlation for free if your client populates them. The tenant attribute is yours to add.

import logging

log = logging.getLogger("mcp")

async def instrumented_call(ctx: TenantContext, tool: str, args: dict):
    with tracer.start_as_current_span(f"mcp.{tool}") as span:
        span.set_attribute("mcp.tenant_id", ctx.tenant_id)
        span.set_attribute("mcp.tool", tool)
        span.set_attribute("mcp.arg_keys", ",".join(sorted(args)))
        try:
            result = await dispatch(tool, args, ctx)
            span.set_attribute("mcp.outcome", "ok")
            return result
        except ToolError as exc:
            span.set_attribute("mcp.outcome", exc.code)
            log.warning("tool failed", extra={
                "tenant_id": ctx.tenant_id,
                "tool": tool,
                "error_code": exc.code,
            })
            raise

Note what's logged and what isn't: argument keys, never values. Tenant identifiers and tool names are safe to index; a customer's search query isn't, and putting it in a shared log index turns your observability stack into a data residency problem.

The metric that earns its keep is a per-tenant error rate for each tool. A single tenant hitting 30% upstream_unavailable is either a broken integration on their side or a credential that expired — both of which are invisible in an aggregate error rate of 0.4%.

Testing isolation with two tenants and one agent

The test that catches most leaks uses a single agent loop pointed at two tenants alternately, with caching enabled, because most leaks are cache-shaped.

@pytest.mark.asyncio
async def test_catalog_and_data_are_tenant_scoped(world):
    acme = await world.login("acme", role="admin")
    globex = await world.login("globex", role="admin")

    acme_tools = await world.list_tools(acme)
    globex_tools = await world.list_tools(globex)
    assert acme_tools == await world.list_tools(acme)   # stable

    # Cache must not serve acme's catalog to globex.
    assert any(t.name == "acme_only_tool" for t in acme_tools)
    assert not any(t.name == "acme_only_tool" for t in globex_tools)

    # Data must not cross, including through the response cache.
    first = await world.call(globex, "list_projects", {})
    second = await world.call(acme, "list_projects", {})
    assert first["projects"] != second["projects"]
    assert all(p["tenant_id"] == "globex" for p in first["projects"])


@pytest.mark.asyncio
async def test_tenant_argument_is_not_accepted(world):
    acme = await world.login("acme", role="admin")
    result = await world.call(
        acme, "list_projects", {"tenant_id": "globex"}, expect_error=True
    )
    assert result["code"] == "unexpected_argument"

That second test is a design assertion disguised as a test: it fails if someone reintroduces tenant_id as an argument "just for admin tooling." Admins are the most common exception that erodes the model, and they're better served by a separate server with its own credential.

Support access is the tenant boundary's hardest case

Every SaaS company eventually needs an engineer to look at one tenant's data to fix a bug. That requirement is where clean tenant models get abandoned, because the fastest implementation is a tenant_id argument on an admin tool — which reopens exactly the hole the architecture was built to close.

The distinction worth designing around is between authorization and impersonation. Authorization says "this person may act within workspace X." Impersonation says "this person is acting as a member of workspace X for a bounded time, and everyone can see it happened."

@dataclass(frozen=True)
class TenantContext:
    tenant_id: str
    user_id: str
    scopes: frozenset[str]
    impersonating: bool = False
    support_ticket: str | None = None

async def support_context(admin_token: str, tenant_ref: str, ticket: str) -> TenantContext:
    admin = await context_from_token(admin_token)
    if "support:impersonate" not in admin.scopes:
        raise AuthError("missing_support_scope")
    if not ticket.startswith("TCK-"):
        raise AuthError("support_ticket_required")

    target = await directory.tenant_by_reference(tenant_ref)   # explicit lookup
    await audit.record(
        actor=admin.user_id, action="impersonation_started",
        tenant=target.id, reference=ticket,
    )
    return TenantContext(
        tenant_id=target.id, user_id=admin.user_id,
        scopes=frozenset({"read"}), impersonating=True, support_ticket=ticket,
    )

Four properties make this safer than an argument:

  • The tenant is resolved by an explicit directory call with its own authorization, not taken from the request body.
  • The support session's scopes are narrower than the admin's, usually read-only.
  • Every use is audited with a ticket reference, so the customer can be told who looked and why.
  • The context is marked, so individual tools can refuse to run under impersonation — a billing write should say no even to support.

That last point is the one most implementations miss. A blanket "support can do anything in any tenant" is indistinguishable from a compromise, and it means a single phished support credential is a cross-tenant breach. Tools should opt in to impersonation explicitly, and destructive ones shouldn't.

The alternative — a separate admin console with direct database access — is often more honest, because it keeps privileged reads out of the model's context entirely. An agent summarizing a customer's account for a support engineer is a real use case; an agent with standing access to every tenant is a liability with a chat interface.

Three leaks that look like features

Most multi-tenant incidents don't look like incidents at build time. They look like conveniences.

A "debug" tool that returns raw rows. Added for support, scoped by tenant in the handler — and reachable by anyone with the support scope, which turns out to be granted to every admin in every workspace. The leak isn't the SQL; it's the scope grant that accumulated over a year.

A shared thread or project ID namespace. Two tenants both have a project called onboarding. A tool that takes a project slug and resolves it server-side will resolve it to whichever row the query returns first unless the tenant predicate is in the lookup, not just in the caller's context.

A shared file bucket with tenant-prefixed paths. s3://exports/acme/report.csv is isolated by convention until someone passes ../globex/report.csv or a URL-encoded variant. Path restrictions need canonicalization and a prefix check after resolution, not a string prefix check before it.

None of these are caught by testing the happy path with one tenant. All of them are caught by the two-tenant fixture set, which is why that test file is worth writing before the second customer exists.

Migrating a single-tenant server in the right order

If the server already ships with a tenant_id argument, the migration has a safe sequence that doesn't require a rewrite and doesn't leave a window where both paths are trusted.

  1. Add the tenant claim to tokens and verify it. Until every token carries it, you can't derive scope, and the argument stays.
  2. Accept the argument but ignore it, and assert that it matches the token's tenant. Calls that disagree fail with a clear error. This turns the argument into a consistency check rather than a scope source, and it surfaces every client that was passing something else.
  3. Make the argument optional, defaulting to the token's tenant. Log every call that still supplies it.
  4. Reject the argument entirely once the logs are clean, with an unexpected_argument error that names the replacement behavior.
  5. Add row-level security as a backstop, so the handler-level scoping you already had is no longer the only defense.

Step 2 is the one that prevents the classic half-migration, where some handlers read from the token and others still read the argument, and nobody can tell which is which by reading a diff.

The checklist before a second customer onboards

  • Does any tool accept a tenant identifier as an argument? If yes, that's the whole review.
  • Is the tenant claim namespaced and mandatory? A missing claim should fail closed, not default to a workspace.
  • Is the audience checked? A token minted for another service shouldn't work here.
  • Are session variables scoped to the transaction? Pool inheritance is the classic intermittent leak.
  • Does cacheScope match the catalog's variability? Private for anything tenant-specific.
  • Are your own caches keyed by tenant? Including any in-process LRU.
  • Are quotas per tenant, not per process? A restart shouldn't reset a customer's usage.
  • Do background jobs carry tenant context explicitly? No ambient context exists.
  • Can you produce a per-tenant audit log without filtering another tenant's data out of a shared index? If not, plan for it now.
  • Does the error taxonomy distinguish tenant-level failures? tenant_quota_exceeded is actionable; rate_limited is not.

One habit pays for itself early: write the second-tenant test before the second tenant exists. It's the only artifact that fails when the architecture drifts, and it's much cheaper to write against a fixture than to reconstruct from a support ticket.

The most useful review question isn't "is this tenant-scoped?" — almost everything is, in the happy path. It's "what happens if the caller lies?" If the answer involves a database predicate that didn't run, the architecture is relying on the model's good behavior, and the model is not a security control.

Multi-tenancy in MCP isn't a feature you add; it's a property that either holds at every boundary or fails at the weakest one. The protocol's move to per-request credentials helps, because it removes the temptation to cache identity on a connection. It doesn't remove the temptation to pass it as an argument, and that temptation arrives with the first convenience tool you write.

Post a Comment

Previous Post Next Post