Guide · Architecture
MCP server multi-tenant
A multi-tenant MCP server handles sessions from multiple distinct customers on a single process. Each tenant has its own credentials, data, configuration, and potentially a different set of enabled tools. The MCP protocol's stateful session model makes multi-tenancy both easier and harder than in stateless REST APIs: easier because tenant identity is resolved once at session creation and available for the session lifetime; harder because module-scope state (in-memory caches, connection pools, global variables) is shared across all tenants by default, creating data leakage and noisy-neighbour risks that a new Request object each time would avoid.
TL;DR
Extract tenant identity from the API key in the Authorization header at the initialize request. Store the resolved TenantContext (tenant ID, config, database credentials) in a Map<sessionId, TenantContext> keyed by mcp-session-id. Clean up the map when the session closes via res.on('close'). Never put tenant-specific data in module scope — use the session map instead. Use PostgreSQL row-level security or separate database schemas to enforce data isolation at the storage layer, not just in application code. If you expose a per-tenant subdomain (e.g. acme.yourmcp.com), configure one AliveMCP probe per subdomain so downtime for one tenant does not hide behind another tenant's healthy probe.
Extracting tenant context at session creation
Tenant identity comes from the Authorization header on the initialize POST request. Resolve the API key to a tenant record once, store the full context, and reuse it for every subsequent request on that session:
// tenant.ts — tenant resolution
export interface TenantContext {
tenantId: string;
tenantName: string;
dbSchema: string; // PostgreSQL schema for row isolation
features: Set<string>; // enabled feature flags
rateLimitRpm: number; // tenant-specific rate limit
}
// tenant-store.ts — per-session tenant map
const sessionTenants = new Map<string, TenantContext>();
export function setTenantForSession(sessionId: string, ctx: TenantContext) {
sessionTenants.set(sessionId, ctx);
}
export function getTenantForSession(sessionId: string): TenantContext | undefined {
return sessionTenants.get(sessionId);
}
export function clearTenantForSession(sessionId: string) {
sessionTenants.delete(sessionId);
}
// mcp-handler.ts — tenant resolution at initialize time
app.post('/mcp', authMiddleware, async (req, res) => {
const apiKey = extractBearerToken(req);
const tenant = await resolveTenant(apiKey); // DB lookup or cache lookup
const sessionId = req.headers['mcp-session-id'] as string ?? randomUUID();
setTenantForSession(sessionId, tenant);
res.on('close', () => clearTenantForSession(sessionId));
const server = createServerForTenant(tenant); // register tools with tenant context
const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });
await server.connect(transport);
await transport.handleRequest(req, res);
});
The res.on('close') cleanup is important. Without it, the session map grows unboundedly as sessions open and never close their entries. Long-lived sessions (hours or days) in a busy multi-tenant server will exhaust memory over time. Always pair a setTenantForSession call with its corresponding clearTenantForSession on close.
Per-tenant tool access control
Different tenants often have access to different tools — enterprise tenants get advanced tools, free-tier tenants get a subset. Rather than registering all tools on every server instance and checking features inside each handler, register only the tools the tenant is authorized to use:
// server-factory.ts — register only authorized tools
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { TenantContext } from './tenant.js';
export function createServerForTenant(tenant: TenantContext): McpServer {
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
// All tenants get core tools
registerCoreTools(server, tenant);
// Enterprise-only tools
if (tenant.features.has('advanced_analytics')) {
registerAnalyticsTools(server, tenant);
}
if (tenant.features.has('bulk_export')) {
registerExportTools(server, tenant);
}
return server;
}
This approach has a clean property: a free-tier tenant calling an enterprise tool gets a -32601 method not found response (the tool literally does not exist in their session) rather than an application-level authorization error. The tool surface is the authorization layer. The trade-off is that two tenants on the same server process see different tool lists, which you must account for in your schema snapshot CI test — either test each feature combination or test the superset and document that free-tier sessions are a subset.
Data isolation patterns
Tool handlers must never access another tenant's data. Application-level checks (WHERE tenant_id = $1) work but are easy to forget. Enforce isolation at the database layer so a missing WHERE clause fails rather than silently returning cross-tenant data:
| Pattern | Mechanism | Overhead | Recommended for |
|---|---|---|---|
| Row-level security (RLS) | PostgreSQL SET app.tenant_id + policy | Low — one SET LOCAL per query batch | Single shared schema, medium tenants |
| Separate schemas | PostgreSQL schema per tenant, search_path per session | Low — one SET search_path per connection | Strict isolation, <500 tenants |
| Separate databases | One PG database per tenant, separate connection pool | High — N pools, N connection counts | Compliance requirements, large enterprise |
| Column-based filtering | Application-level WHERE tenant_id = $1 | None | Low-risk prototypes only |
For RLS, set the tenant ID as a session-local variable before executing queries in a tool handler:
// tool handler with RLS
server.tool('list_documents', '...', { workspace_id: z.string() }, async (args, extra) => {
const sessionId = extra.meta?.sessionId ?? '';
const tenant = getTenantForSession(sessionId);
if (!tenant) return { isError: true, content: [{ type: 'text', text: 'Session not found' }] };
await db.query(`SET LOCAL app.tenant_id = '${tenant.tenantId}'`);
const rows = await db.query(
'SELECT id, title FROM documents WHERE workspace_id = $1',
[args.workspace_id]
);
// RLS policy filters to current tenant automatically
return { content: [{ type: 'text', text: JSON.stringify(rows.rows) }] };
});
Module-scope pitfalls in multi-tenant servers
In a stateless REST API, module-scope variables reset per request lifecycle. In an MCP server, module-scope state persists for the process lifetime and is shared across all tenant sessions. Common mistakes:
// WRONG — module-scope state shared across tenants
let currentTenantId: string; // leaks between sessions
const cache = new Map<string, unknown>(); // tenant A's data visible to tenant B
// if cache key is not tenant-scoped
const config = loadConfig(); // fine ONLY if config is immutable
// and has no tenant-specific values
// CORRECT — use the session map for any per-tenant state
// Read from getTenantForSession(sessionId) inside every tool handler
// Store tenant-scoped cache entries with tenant ID in the key:
const tenantScopedCache = new Map<string, unknown>();
// key: `${tenantId}:${cacheKey}` — never just cacheKey alone
The rule is simple: if a value differs between tenants, it must never live in module scope. Only immutable, tenant-agnostic values (static config, compiled Zod schemas, the McpServer instance for the server metadata) belong in module scope. Everything else belongs in the per-session tenant map.
Related questions
Should each tenant get its own McpServer instance or share one?
Create a new McpServer instance per session (per HTTP request to /mcp). This is the pattern in the SDK examples and ensures that tool registrations (which happen on the server instance, not globally) are scoped to the session. The server instance is lightweight — it is not a connection or a thread. The database pool, config, and logger are shared across all instances via PluginDeps or direct module imports, which is where you want shared state to live.
How do I monitor a multi-tenant MCP server with AliveMCP?
Configure one probe per probe credential. If all tenants share the same endpoint URL and you have a single dedicated probe API key, one probe is enough to verify the server is up. If tenants use per-tenant subdomains (acme.yourmcp.com, beta.yourmcp.com), create one AliveMCP probe per subdomain — a DNS or TLS failure on one subdomain will not be caught by a probe pointed at another. Use AliveMCP's label feature to group probes by tenant tier (free, pro, enterprise) for at-a-glance tier health.
How do I handle noisy-neighbour rate limiting in multi-tenant sessions?
Apply tenant-specific rate limits sourced from the TenantContext. Store per-tenant request counters in Redis with a key like ratelimit:{tenantId}:{window}. A free-tier tenant hitting their rate limit gets HTTP 429 before transport.handleRequest; their rate limit exhaustion does not affect other tenants' quotas. See the rate limiting guide for the Redis sliding window implementation.
Can I use the same MCP session ID across tenants for debugging?
No. The mcp-session-id header is client-supplied and is not guaranteed to be unique across tenants — two tenants could theoretically supply the same session ID. Use an internal session ID that you generate server-side (a UUID assigned at initialize time) as your correlation key, and treat the client-supplied session ID as an opaque routing token rather than a globally unique identifier.
Further reading
- MCP server authentication — resolving tenant identity from Bearer tokens and JWT claims
- MCP server rate limiting — per-tenant quota enforcement with Redis
- MCP server plugins — per-tenant plugin activation patterns
- MCP server connection pooling — shared pools across tenant sessions
- MCP server logging — including tenant_id in every log line
- AliveMCP — per-tenant probe configuration and uptime monitoring for multi-tenant MCP servers