Building Multi-Tenant MCP Servers: Isolation and Scaling
AUTHOR: Pramod Dutta
A multi-tenant MCP server is a single deployment of a Model Context Protocol server that serves multiple distinct customers, teams, or workspaces from shared infrastructure while keeping each tenant's data, credentials, and rate limits fully isolated from the others. Getting this right matters because MCP servers frequently proxy access to real systems: databases, ticketing tools, internal APIs, file stores. A leak between tenants in an MCP context isn't a cosmetic bug, it's a data breach. This article walks through the isolation boundaries you need, how tenant identity flows through an MCP session, and the scaling patterns that hold up once you have more than a handful of customers.
Why single-tenant MCP servers don't scale as-is
Most MCP servers start life as single-tenant. You wire up a stdio or HTTP server, hardcode a database connection string or an API key in an environment variable, and it works great for one user or one internal team. The moment you want to sell that same server to multiple customers, or run it for multiple teams inside your own company, three assumptions break:
- Credentials are no longer static. A single
DATABASE_URLorAPI_KEYenv var can't represent ten different customers' backend systems. - Tool results can leak across sessions. If your server caches results, holds an in-memory connection pool, or reuses a global HTTP client without partitioning by tenant, one customer's request can accidentally return another customer's data.
- One tenant can starve the others. A tenant that fires off a large batch of tool calls (or a runaway agent loop) can exhaust connection pools, rate limits, or CPU that everyone else needs.
A multi-tenant MCP server has to solve identity, isolation, and fairness at the same time. Below is a working architecture for each.
Tenant identity: where it comes from and how it flows
Before you can isolate anything, every request into the MCP server needs a resolved tenant identity attached to it. There are two realistic entry points depending on your transport.
HTTP/SSE transport (the common case for hosted multi-tenant servers). Tenant identity is resolved during the initial handshake, typically from an OAuth access token or an API key passed in the Authorization header. Resolve it once per connection and attach it to a request-scoped context object, don't re-derive it on every tool call.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { IncomingMessage } from "node:http";
interface TenantContext {
tenantId: string;
plan: "free" | "pro" | "enterprise";
scopes: string[];
}
async function resolveTenant(req: IncomingMessage): Promise<TenantContext> {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
throw new Error("missing bearer token");
}
const token = authHeader.slice("Bearer ".length);
const claims = await verifyAccessToken(token); // JWT verification or introspection call
return {
tenantId: claims.tenant_id,
plan: claims.plan,
scopes: claims.scopes ?? [],
};
}stdio transport (local, single-process-per-tenant). If you're running one server process per customer, launched by their own agent client, tenant identity is implicit: it's whichever config or environment variables were injected when the process started. This is the simplest isolation model because the OS process boundary does the isolation for you, but it doesn't scale to hundreds of tenants on shared infrastructure without spinning up hundreds of processes.
Most production multi-tenant MCP deployments land on HTTP transport with a shared process pool, because per-tenant process spin-up is expensive and slow to cold-start under agent workloads that expect sub-second tool responses.
Isolation models, ranked by strength
There isn't one correct isolation model. Pick based on your threat model and how sensitive the underlying data is.
1. Process-per-tenant. Each tenant gets a dedicated OS process (or container). Strongest isolation: a bug in one tenant's request handling cannot read another tenant's memory. Downsides are cost and cold-start latency. This is the right call for enterprise tenants with strict data-residency or compliance requirements, or when tool execution involves running arbitrary code (a code-interpreter MCP server, for example) where process isolation is close to mandatory.
2. Shared process, request-scoped context, no shared mutable state. A single Node.js or Python process serves all tenants, but every tool handler receives an immutable tenant context object and never touches module-level or global mutable state. This is the sweet spot for most SaaS-style MCP servers: it's cheap to run and, if disciplined, just as safe as process isolation for anything that isn't executing untrusted code.
3. Shared process, shared connection pools keyed by tenant. Database and downstream API connections are pooled, but every pool lookup is keyed by tenantId. This avoids the overhead of opening a fresh database connection per request while still preventing cross-tenant data bleed, as long as the keying is enforced at every call site, not just at the top of the request.
4. Shared everything, row-level security. The application layer trusts a single database connection and relies on the database itself (Postgres row-level security policies, for example) to filter rows by tenant. This is efficient but pushes the isolation guarantee down into the database layer, which means a missing SET app.current_tenant_id before a query is a silent, catastrophic bug rather than a loud one. Use this only with automated tests that specifically assert cross-tenant queries return zero rows.
For most teams shipping a commercial MCP server, model 2 combined with model 3 for the data layer is the practical default. Reserve model 1 for tenants that pay for it or require it contractually.
Implementing request-scoped tenant context
The core discipline is: never let tenant identity live in a global variable, and never let a tool handler infer tenant identity from anything other than the context object it was explicitly given. Node's AsyncLocalStorage is the cleanest way to thread this through without passing a context parameter to every function by hand.
import { AsyncLocalStorage } from "node:async_hooks";
const tenantStorage = new AsyncLocalStorage<TenantContext>();
function runWithTenant<T>(ctx: TenantContext, fn: () => Promise<T>): Promise<T> {
return tenantStorage.run(ctx, fn);
}
function currentTenant(): TenantContext {
const ctx = tenantStorage.getStore();
if (!ctx) {
throw new Error("no tenant context: this code path is not request-scoped");
}
return ctx;
}Wrap every tool call in runWithTenant, resolved once per incoming request:
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const tenant = await resolveTenant(extra.req); // extra carries the raw HTTP request
return runWithTenant(tenant, async () => {
return dispatchTool(request.params.name, request.params.arguments);
});
});Now any downstream code, database client, cache lookup, logger, can call currentTenant() and get the right value without threading it through every function signature. This also gives you a single place to enforce scope checks: if a tool requires a scope the tenant's token doesn't have, reject it before dispatch.
async function dispatchTool(name: string, args: unknown) {
const tenant = currentTenant();
const tool = toolRegistry.get(name);
if (!tool) throw new Error(`unknown tool: ${name}`);
if (tool.requiredScope && !tenant.scopes.includes(tool.requiredScope)) {
throw new Error(`tenant ${tenant.tenantId} lacks scope ${tool.requiredScope}`);
}
return tool.handler(args, tenant);
}Keying connection pools and caches by tenant
The most common real-world leak in multi-tenant MCP servers isn't in the tool handler logic, it's in a database client or HTTP client that was instantiated once at server startup and reused across every tenant without a per-tenant credential or connection string.
If each tenant has its own database (a common pattern: one Postgres schema or one database per tenant), key your connection pool by tenant ID and lazily create pools on first use, with an eviction policy so idle tenants don't hold connections forever:
import { Pool } from "pg";
const pools = new Map<string, { pool: Pool; lastUsed: number }>();
const POOL_IDLE_TTL_MS = 10 * 60 * 1000;
function getPoolForTenant(tenantId: string): Pool {
const entry = pools.get(tenantId);
if (entry) {
entry.lastUsed = Date.now();
return entry.pool;
}
const connectionString = lookupTenantConnectionString(tenantId);
const pool = new Pool({ connectionString, max: 5 });
pools.set(tenantId, { pool, lastUsed: Date.now() });
return pool;
}
setInterval(() => {
const now = Date.now();
for (const [tenantId, entry] of pools) {
if (now - entry.lastUsed > POOL_IDLE_TTL_MS) {
entry.pool.end();
pools.delete(tenantId);
}
}
}, 60_000);If tenants share a single database with row-level isolation instead, set the tenant identifier as a session variable at the start of every transaction, and let a Postgres policy enforce it as a backstop, not the only line of defense:
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);async function withTenantTransaction<T>(tenantId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await sharedPool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [tenantId]);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}The set_config call with true as the third argument scopes the setting to the current transaction, so it can't leak into the next request that happens to grab the same pooled connection.
Caches need the same discipline. If you cache tool results (say, a schema introspection call that's expensive to recompute), namespace every cache key with the tenant ID, never a bare resource name.
function cacheKey(tenantId: string, resource: string): string {
return `${tenantId}:${resource}`;
}Rate limiting and fair-use quotas per tenant
Once isolation is solid, the next failure mode is one noisy tenant degrading service for everyone. Apply limits at two levels: per-tenant concurrency (how many tool calls a tenant can have in flight at once) and per-tenant rate (how many calls per minute).
import { RateLimiterMemory } from "rate-limiter-flexible";
const rateLimiters = new Map<string, RateLimiterMemory>();
function limiterForPlan(plan: TenantContext["plan"]): { points: number; duration: number } {
switch (plan) {
case "free": return { points: 30, duration: 60 };
case "pro": return { points: 300, duration: 60 };
case "enterprise": return { points: 2000, duration: 60 };
}
}
async function enforceRateLimit(tenant: TenantContext) {
let limiter = rateLimiters.get(tenant.tenantId);
if (!limiter) {
const { points, duration } = limiterForPlan(tenant.plan);
limiter = new RateLimiterMemory({ points, duration });
rateLimiters.set(tenant.tenantId, limiter);
}
try {
await limiter.consume(tenant.tenantId);
} catch {
throw new Error("rate limit exceeded for tenant, retry after backoff");
}
}For a single-process deployment, RateLimiterMemory is fine. Once you run more than one server instance behind a load balancer, move the limiter state to Redis (rate-limiter-flexible supports a Redis backend with the same API) so limits are enforced globally across instances rather than per-instance, which would otherwise let a tenant get N times their quota by hitting N different instances.
Also bound concurrency explicitly, independent of rate. A tenant firing 50 tool calls in the same second, each of which spawns a subprocess or opens a database connection, can exhaust shared resources even under a generous per-minute rate limit.
const inFlight = new Map<string, number>();
const MAX_CONCURRENT_PER_TENANT = 10;
async function withConcurrencyLimit<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
const current = inFlight.get(tenantId) ?? 0;
if (current >= MAX_CONCURRENT_PER_TENANT) {
throw new Error("too many concurrent tool calls for this tenant");
}
inFlight.set(tenantId, current + 1);
try {
return await fn();
} finally {
inFlight.set(tenantId, (inFlight.get(tenantId) ?? 1) - 1);
}
}Scaling horizontally
Once a single process is no longer enough tenants, scale MCP servers the same way you'd scale any stateless HTTP service, with a few MCP-specific wrinkles.
Keep server instances stateless. Anything that must persist across requests (rate limit counters, session state for long-lived SSE connections, cached tokens) belongs in Redis or your database, not in process memory. This is what lets you add or remove instances behind a load balancer without losing tenant state.
Watch out for SSE session affinity. If you're using the Streamable HTTP transport with server-sent events for long-lived sessions, a client's session is pinned to whichever server instance accepted the initial connection. Use sticky sessions at the load balancer (route by session ID, not just round-robin) or move to a fully stateless request/response model per tool call if your MCP client supports it, which sidesteps affinity entirely.
Separate the control plane from the data plane. Tenant provisioning, credential rotation, and quota configuration should live in their own service backed by your primary database, not inside the MCP server's hot path. The MCP server should read tenant config from a fast cache (Redis, or an in-memory cache with a short TTL) rather than hitting your control-plane database on every tool call.
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function getTenantConfig(tenantId: string): Promise<TenantContext> {
const cached = await redis.get(`tenant-config:${tenantId}`);
if (cached) return JSON.parse(cached);
const config = await loadTenantConfigFromDatabase(tenantId);
await redis.set(`tenant-config:${tenantId}`, JSON.stringify(config), { EX: 60 });
return config;
}Autoscale on tool-call latency and queue depth, not just CPU. MCP tool handlers often spend most of their time waiting on downstream APIs or databases, so CPU utilization is a poor scaling signal. Track p95 tool-call latency and in-flight request count per instance, and scale out when either crosses a threshold.
Isolate noisy or high-risk tenants onto dedicated capacity. For a tenant running heavy workloads, or one whose contract requires isolation, route them to a dedicated instance pool via a routing layer that inspects the resolved tenant ID before the request reaches the shared pool. This gives you process-level isolation for the tenants that need it without paying that cost for every tenant.
Auditing and observability per tenant
Multi-tenant systems need per-tenant observability, not just aggregate metrics. Two things pay for themselves quickly:
- Structured logs tagged with `tenantId` on every line, so a support engineer investigating one customer's issue can filter to exactly their traffic without wading through everyone else's.
- An audit log of every tool call, including tenant ID, tool name, arguments (redact anything sensitive), and result status. This is what you'll be asked for the first time a customer asks "did your system access X on our behalf, and when."
function logToolCall(tenant: TenantContext, toolName: string, status: "success" | "error", durationMs: number) {
console.log(JSON.stringify({
ts: new Date().toISOString(),
tenantId: tenant.tenantId,
tool: toolName,
status,
durationMs,
}));
}Ship these logs somewhere queryable by tenant ID (a log aggregator with indexed fields, or a dedicated audit table) rather than leaving them in stdout, since "show me everything tenant X did last Tuesday" is a request you should expect.
A minimal checklist before calling a multi-tenant MCP server production-ready
- Tenant identity is resolved once per connection from a verified token, never trusted from a request body field a client could forge.
- No tool handler reads tenant identity from a global variable; everything flows through request-scoped context.
- Every database query, cache key, and connection pool lookup is explicitly keyed by tenant ID, with a test that asserts cross-tenant queries return nothing.
- Rate limits and concurrency limits are enforced per tenant, and the limiter state is shared (Redis) once you run more than one instance.
- SSE or long-lived session state is either externalized or pinned with sticky routing.
- Every tool call is logged with tenant ID for audit purposes.
- High-risk or contractually isolated tenants have a path to dedicated process or container isolation, not just logical isolation.
FAQ
What's the difference between a multi-tenant MCP server and just running separate MCP servers per customer? Running separate server processes or deployments per customer is itself a valid multi-tenant strategy, it's process-per-tenant isolation, the strongest model described above. The tradeoff is operational cost: N tenants means N deployments to patch, monitor, and scale independently. A shared multi-tenant server trades some isolation strength for much lower operational overhead, which is why most SaaS MCP servers use a shared process with strict logical isolation rather than a fleet of per-customer deployments.
Can I use API keys instead of OAuth for tenant identification? Yes, API keys are simpler to implement and are fine for server-to-server or agent-to-server scenarios where you control both ends. Hash and store keys server-side (never store them in plaintext), and make sure key lookup resolves directly to a tenant record rather than embedding tenant ID as a client-supplied field alongside the key. OAuth becomes worth the added complexity once you need per-user consent, scoped permissions, or delegated access on behalf of a human user inside a tenant's organization.
Does row-level security in Postgres actually protect against a missing tenant filter in application code? It protects against forgetting a WHERE tenant_id = ... clause, which is the most common leak. It does not protect against forgetting to call set_config for the session's tenant ID in the first place, or against a superuser connection that bypasses row-level security policies by default. Treat RLS as a second layer, not a replacement for correct application-level scoping.
How do I handle a tenant that needs access to tools other tenants don't have? Model tool availability as part of the tenant's scope set, resolved at connection time, and filter the tools/list response and the dispatch table by those scopes. Don't register every tool for every tenant and rely on the handler to reject unauthorized calls after the fact, since that leaks the existence and schema of tools a tenant shouldn't even know about.
What happens to in-flight tool calls when I scale an instance down? For short-lived request/response tool calls, a graceful shutdown that stops accepting new connections and waits for in-flight requests to finish (with a timeout) is usually sufficient. For long-lived SSE sessions, you need the client to detect the disconnect and reconnect to a different instance, which is why session state should live outside the process, in Redis or your database, so reconnecting doesn't lose context.
Is it safe to let tenants run arbitrary code through a tool, like a code-interpreter MCP server, in a shared multi-tenant process? No. Any tool that executes code or shell commands on behalf of a tenant needs process or container-level isolation at minimum, and ideally a sandboxed runtime with restricted filesystem and network access. Logical isolation (context objects, keyed connection pools) is not a security boundary against arbitrary code execution, only a data-access boundary.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.