MCP System Design: Secure Multi-Tenant Gateway
Quick answer
A secure multi-tenant MCP gateway authenticates every request, derives the tenant from a verified identity, filters tool discovery, reauthorizes every invocation, exchanges tokens for audience-scoped downstream credentials, and partitions caches, quotas, connections, and audit data by tenant. With MCP 2026-07-28, keep the data plane stateless and scale it horizontally behind Streamable HTTP.
The interview prompt sounds simple: “Design a gateway between AI clients and many MCP servers.” The hard part is not forwarding JSON-RPC. It is preserving identity, authorization, and failure boundaries while an untrusted model dynamically discovers and invokes tools for thousands of organizations.
This answer uses the current MCP 2026-07-28 specification and the stable
TypeScript SDK v2. That matters. Much of the material indexed by search engines
still assumes a 2025-era initialize handshake, Mcp-Session-Id, sticky sessions,
and a long-lived GET/SSE channel. Those are legacy behaviors, not the architecture
you should lead with in a new system.
Quick Answer
A secure multi-tenant MCP gateway authenticates every request, derives the tenant
from a verified identity, filters tool discovery, reauthorizes every invocation,
exchanges tokens for audience-scoped downstream credentials, and partitions
caches, quotas, connections, and audit data by tenant. With MCP 2026-07-28, keep
the data plane stateless and scale it horizontally behind Streamable HTTP.
TL;DR
- Use one hardened Streamable HTTP endpoint for clients and private connections to downstream MCP servers.
- Derive
tenantId, user, client, and scopes from a verified token. Never taketenantIdfrom model-controlled arguments. - Return only authorized tools from
tools/list, then authorizetools/callagain. Listing is user experience; invocation is the security boundary. - Namespace tools, such as
github.search_issues, so two servers cannot silently collide onsearch. - Exchange the inbound gateway token for a short-lived token with the downstream server as its audience. Token passthrough is forbidden.
- Partition every cache and control by
{tenant, principal, server, policyVersion}. - Put destructive actions behind deterministic policy and out-of-band human approval. Tool annotations are untrusted hints, not enforcement.
- Use timeouts, bulkheads, circuit breakers, bounded retries, and idempotency keys.
- Emit OpenTelemetry traces and immutable audit events without logging tokens or raw sensitive results.
- Prefer the stateless 2026 protocol. Isolate legacy 2025 servers behind adapters.
What Existing MCP Gateway Guides Usually Miss
The highest-ranking material covers useful pieces, but rarely the complete interview problem. Protocol documentation explains the wire contract. Product documentation explains one implementation. Security posts list threats. Cloud reference architectures show network boundaries. An interviewer expects you to connect those pieces and defend the tradeoffs.
The missing details are usually:
- where tenant identity originates and how it propagates;
- why catalog filtering does not replace call-time authorization;
- how tool-name collisions and tool-definition changes are handled;
- why token passthrough creates a confused deputy;
- which state belongs in MCP, which belongs in the application, and which is removed in the 2026 protocol;
- how cache keys, rate limits, connection pools, and circuit breakers can leak or starve across tenants;
- what to retry, what not to retry, and how writes remain idempotent;
- capacity estimates, failure modes, deployment notes, and verification tests.
That is the gap this design closes.
Start the Interview by Fixing the Scope
State assumptions before drawing boxes. It shows that you know system design is constraint-driven.
Functional requirements
- Expose one remote MCP endpoint to hosts such as IDEs, assistants, and internal agents.
- Aggregate tools from first-party and approved third-party MCP servers.
- Return a caller-specific catalog of tools, resources, and prompts.
- Route calls while preserving the tenant, user, agent, trace, and approval context.
- Support user-delegated and machine-to-machine access.
- Record an auditable allow or deny decision for sensitive operations.
Non-functional requirements
- 10,000 tenants, 2,000 peak tool calls per second, and a 2x burst target.
- 99.95% monthly gateway availability.
- Less than 25 ms p95 gateway processing overhead, excluding the downstream tool.
- No cross-tenant data or credential leakage.
- Regional data residency and revocation within minutes.
- Horizontal scaling without sticky sessions for current-protocol traffic.
These are interview assumptions, not universal targets. Say that you would replace them with measured traffic, payload, and downstream-latency distributions.
Know the MCP Version Boundary
The MCP 2026-07-28 changelog
introduces the largest gateway-relevant change: the core protocol is stateless.
The stable TypeScript SDK v2 supports
the new protocol and the legacy era.
| Concern | 2025-era behavior | MCP 2026-07-28 behavior | Gateway consequence |
|---|---|---|---|
| Negotiation | initialize then notifications/initialized | Optional server/discover; version and capabilities in request _meta | Cache discovery per authorization context |
| Session | Optional Mcp-Session-Id | No protocol session | Round-robin routing works by default |
| Server events | GET/SSE channel | subscriptions/listen POST-response stream | Open a stream only for consumers that need changes |
| Recovery | SSE event IDs and Last-Event-ID | No stream replay | Reissue safe requests with a new request ID |
| Routing | Often required body inspection | Mcp-Method and Mcp-Name HTTP headers | Edge can route and limit without parsing JSON |
| Catalog caching | Ad hoc | ttlMs and cacheScope on cacheable results | Honor freshness and never share private results |
| Long work | Experimental core Tasks | Optional Tasks extension | Negotiate explicitly; keep durable state outside a replica |
| Roots, Sampling, Logging | Active | Deprecated | Do not add them to a new design |
Version trap: the v2 client defaults can still speak the legacy era unless
version negotiation is enabled. For mixed fleets use mode: "auto"; for a new
2026-only estate, pin 2026-07-28 and fail clearly instead of silently falling
back.
Proposed Architecture
The gateway is both an MCP server to upstream clients and an MCP client to downstream servers. Keep its data plane small and deterministic. Put onboarding, policy authoring, credential setup, and catalog approval in a separate control plane.
Component responsibilities
| Component | Owns | Must not own |
|---|---|---|
| Edge | TLS, body limits, DDoS controls, coarse rate limits | Tenant authorization |
| Authenticator | Signature, issuer, audience, expiry, client and subject validation | Trusting model arguments |
| Tenant resolver | Membership and active tenant selection | Free-form tenant IDs |
| Policy engine | Principal × tenant × tool × action × context decision | Prompt interpretation |
| Catalog | Stable public name, schema digest, server target, risk class | Raw credentials |
| Credential broker | Audience-scoped short-lived downstream tokens | Returning secrets to the model |
| Router | Target selection, timeout budget, protocol-era adapter | Changing authorization decisions |
| Audit pipeline | Tamper-evident decision and action records | Full tokens or unredacted sensitive content |
The Core Security Invariant
Write this on the whiteboard:
For every operation, the authenticated principal may act only within the resolved tenant, through an explicitly allowed capability, using a credential scoped to the selected downstream resource.
The tenant is part of the security principal, not routing metadata. A useful identity object is:
export type RequestIdentity = Readonly<{
tenantId: string;
subject: string;
clientId: string;
scopes: readonly string[];
tokenId?: string;
}>;This complete type is deliberately missing a setter. Build it once after token validation and pass it through typed request context. Tool arguments never modify it.
Request Lifecycle
- Validate the access token locally with a cached JWKS where possible. Check
signature,
iss,aud,exp, and client binding. - Resolve the active tenant from a trusted claim plus server-side membership. Reject suspended tenants before catalog or cache access.
- Map the public tool name to a versioned catalog record and evaluate policy.
- Consume tenant, principal, and tool-class quotas atomically.
- Resolve an approved downstream URL from the registry. Never route to a URL supplied in tool arguments.
- Exchange the inbound token for a short-lived, downstream-audience token.
- Invoke the downstream MCP server with a deadline and propagated trace context.
- Validate response size and declared schema, redact logs, emit audit, and return.
Step-by-step Solution
Step 1: Choose a gateway shape
| Shape | Pros | Cons | Best use |
|---|---|---|---|
| Transparent MCP proxy | Preserves all downstream features | Hard to normalize policy; name collisions; larger attack surface | One trusted server behind one edge |
| Policy-enforcing facade | Stable names, schemas, approval UX, and policy | More catalog and compatibility work | Recommended multi-tenant design |
| One gateway per tenant | Strong blast-radius isolation | Higher cost and operational count | Regulated or very large tenants |
Generic invoke(server, tool, args) tool | Small visible catalog | Hides schemas and risk; weak client UX | Constrained internal clients only |
Use the facade by default. Give each exposed capability a stable, namespaced name,
an owner, a schema digest, a risk class, and a downstream mapping. For example,
github.search_issues and jira.search_issues remain distinct even if both
downstream servers call their tool search.
Step 2: Authenticate the transport, not the tool arguments
The gateway is an OAuth resource server. It validates tokens issued for the gateway. A good JWT validation path checks:
- signature against trusted keys;
- exact issuer and expected audience;
- expiry and not-before time with small clock skew;
- authorized client ID and token type;
- subject, tenant claim, and server-side tenant membership;
- required base scope such as
mcp.
Bad:
type UnsafeArguments = {
tenantId: string;
projectId: string;
};
export function routeUnsafely(args: UnsafeArguments): string {
return `https://${args.tenantId}.internal.example/projects/${args.projectId}`;
}The model controls both values. It can change tenantId to another organization,
and string interpolation also creates a routing injection surface.
Correct:
export type Identity = Readonly<{ tenantId: string; subject: string }>;
export function projectPath(identity: Identity, projectId: string): string {
const safeProjectId = encodeURIComponent(projectId);
return `/tenants/${identity.tenantId}/projects/${safeProjectId}`;
}identity was created after authentication. The caller supplies only the resource
identifier. The downstream data layer must still include a tenant predicate; URL
construction alone is not isolation.
Step 3: Filter discovery, then reauthorize execution
Return only capabilities the caller may see. This reduces prompt size and prevents
tool names and descriptions from leaking restricted features. Then repeat policy
evaluation on every tools/call, resources/read, prompts/get, and
subscriptions/listen operation.
Why twice?
- Permissions can change after a catalog response is cached.
- A client can call a tool without listing it first.
- A malicious client can forge JSON-RPC directly.
- A tool's risk can depend on arguments, environment, device, or approval state.
Use a deny-by-default decision such as:
allow = member(tenant, subject)
&& enabled(tenant, tool)
&& role_allows(subject, tool.action)
&& scope_allows(token, tool.scope)
&& environment_allows(client, tool.environment)
&& approval_valid(tool, arguments, approval)Keep policy inputs factual. “The model believes this is safe” is not a valid authorization signal.
Step 4: Separate the catalog from live downstream discovery
Do not merge arbitrary tools/list responses into production immediately. A
compromised server could replace a harmless tool description or schema after
approval, sometimes called tool poisoning.
Store this control-plane record:
| Field | Example | Purpose |
|---|---|---|
publicName | github.search_issues | Stable collision-free contract |
serverId | github-prod | Approved routing target |
downstreamName | search_issues | Actual MCP tool name |
schemaDigest | SHA-256 of canonical JSON Schema | Detect unreviewed changes |
risk | read, write, destructive | Approval and quota policy |
requiredScopes | issues:read | Least privilege |
policyVersion | 184 | Cache invalidation and audit replay |
Run discovery in an isolated control-plane worker. Diff changes, require review for new or risk-increasing capabilities, and publish an immutable catalog version. The data plane reads a local or distributed cache of approved records.
Step 5: Never pass tokens through
The official MCP security guidance forbids accepting a token not issued for the MCP server and passing it downstream. That breaks audience boundaries and creates a confused deputy.
Use one of these patterns:
- OAuth token exchange: exchange the gateway token for a token whose audience is the target MCP server and whose scope is the selected operation.
- Delegated tenant grant: retrieve a tenant user's encrypted refresh token and mint a short-lived provider token.
- Workload identity: use the gateway's service identity plus signed tenant and subject context that the downstream service verifies.
Key credential-cache entries by tenant, subject, downstream audience, scope set, and credential generation. Encrypt refresh tokens with a KMS-backed envelope key. Do not put access tokens in prompts, tool arguments, traces, or audit bodies.
Step 6: Partition every shared mechanism
Cross-tenant leaks often come from infrastructure that appears unrelated to auth.
| Mechanism | Unsafe key | Safe key or boundary |
|---|---|---|
| Tool catalog cache | serverId | tenantId:principalScope:serverId:policyVersion |
| Result cache | toolName:argsHash | tenantId:subjectScope:toolName:argsHash:dataVersion |
| Discovery verdict | serverUrl | authorization-context hash + server ID |
| Connection/client pool | serverId | tenant + server + credential generation |
| Rate limit | global counter | tenant + agent + tool risk class |
| Circuit breaker | one global breaker | server and region, with tenant bulkheads |
| Audit stream | shared unpartitioned topic | tenant partition plus immutable global index |
The current SDK explicitly warns not to share cached server/discover results
across principals. Even if the server reauthorizes the later call, the cached
advertisement can mislead client-side capability gating.
Step 7: Design writes as workflows
Prompt injection is an input-integrity problem, not an authentication failure. A poisoned email can convince a correctly authenticated agent to call a destructive tool. Deterministic controls must still hold when the model is wrong.
For high-impact writes:
- The MCP tool creates a proposed action with canonical arguments and expiry.
- A separate UI shows the exact effect, tenant, target, and diff.
- The user approves out of band.
- The approval service signs
{tenant, subject, actionHash, expiry, nonce}. - The gateway verifies the signed approval and consumes the nonce once.
- The downstream call uses an idempotency key.
Do not treat confirmed: true from tool arguments as human approval. The model can
set it.
Step 8: Make failure behavior explicit
- Set one end-to-end deadline and allocate it across auth, policy, token exchange, queueing, and downstream execution.
- Retry reads only on transient transport failures, with exponential backoff and jitter.
- Retry writes only when the tool is idempotent and carries a stable idempotency key.
- Bound response bytes, list pagination, concurrent calls, and subscription count.
- Use per-server circuit breakers and per-tenant concurrency bulkheads.
- Return sanitized MCP errors. Put correlation details in logs, not model-visible content.
Minimal Reproducible TypeScript Gateway
This example shows the security seams with the stable v2 packages: local JWT verification, per-request server construction, tenant-derived policy, Streamable HTTP, and a downstream MCP client using token exchange. It exposes one read tool to keep the example reviewable; production catalogs generate equivalent explicit registrations from approved schema records.
Install the runtime dependencies:
{
"name": "secure-mcp-gateway",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/gateway.ts"
},
"dependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"express": "^5.2.1",
"jose": "^6.0.0",
"tsx": "^4.20.0",
"zod": "^4.0.0"
}
}The important dependency choice is the split v2 packages. Older examples use the
monolithic @modelcontextprotocol/sdk v1 package.
Create src/gateway.ts:
import { createHash, randomUUID } from "node:crypto";
import type { Server } from "node:http";
import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import type { OAuthTokenVerifier } from "@modelcontextprotocol/express";
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth,
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import type { AuthInfo, OAuthMetadata } from "@modelcontextprotocol/server";
import {
createMcpHandler,
McpServer,
OAuthError,
OAuthErrorCode,
} from "@modelcontextprotocol/server";
import { createRemoteJWKSet, jwtVerify } from "jose";
import * as z from "zod/v4";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
}
const config = {
port: Number(process.env.PORT ?? "3000"),
publicUrl: new URL(required("MCP_PUBLIC_URL")),
issuer: required("OIDC_ISSUER").replace(/\/$/, ""),
audience: required("MCP_AUDIENCE"),
downstreamUrl: new URL(required("DOWNSTREAM_MCP_URL")),
downstreamAudience: required("DOWNSTREAM_AUDIENCE"),
tokenExchangeUrl: new URL(required("TOKEN_EXCHANGE_URL")),
exchangeClientId: required("EXCHANGE_CLIENT_ID"),
exchangeClientSecret: required("EXCHANGE_CLIENT_SECRET"),
};
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
throw new Error("PORT must be an integer from 1 to 65535");
}
type TenantAuthInfo = AuthInfo & {
tenantId: string;
subject: string;
};
const jwks = createRemoteJWKSet(
new URL("/.well-known/jwks.json", `${config.issuer}/`),
);
async function verifyAccessToken(token: string): Promise<TenantAuthInfo> {
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: config.issuer,
audience: config.audience,
algorithms: ["RS256", "ES256"],
});
const tenantId = payload.tid;
const subject = payload.sub;
const clientId = payload.azp ?? payload.client_id;
const scopes = typeof payload.scope === "string"
? payload.scope.split(" ").filter(Boolean)
: [];
if (
typeof tenantId !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(tenantId) ||
typeof subject !== "string" ||
typeof clientId !== "string" ||
typeof payload.exp !== "number"
) {
throw new Error("Required identity claims are missing");
}
return {
token,
clientId,
scopes,
expiresAt: payload.exp,
tenantId,
subject,
};
} catch {
throw new OAuthError(
OAuthErrorCode.InvalidToken,
"Access token is invalid or expired",
);
}
}
const verifier: OAuthTokenVerifier = { verifyAccessToken };
const oauthMetadata: OAuthMetadata = {
issuer: config.issuer,
authorization_endpoint: `${config.issuer}/authorize`,
token_endpoint: `${config.issuer}/oauth/token`,
jwks_uri: `${config.issuer}/.well-known/jwks.json`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
scopes_supported: ["mcp", "projects:read"],
};
async function exchangeToken(
inboundToken: string,
tenantId: string,
): Promise<string> {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: inboundToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
audience: config.downstreamAudience,
scope: `projects:read tenant:${tenantId}`,
});
const basic = Buffer.from(
`${config.exchangeClientId}:${config.exchangeClientSecret}`,
).toString("base64");
const response = await fetch(config.tokenExchangeUrl, {
method: "POST",
headers: {
authorization: `Basic ${basic}`,
"content-type": "application/x-www-form-urlencoded",
},
body,
signal: AbortSignal.timeout(2_000),
});
if (!response.ok) {
throw new Error(`Token exchange failed with HTTP ${response.status}`);
}
const value: unknown = await response.json();
const parsed = z.object({ access_token: z.string().min(20) }).parse(value);
return parsed.access_token;
}
async function callDownstream(
identity: TenantAuthInfo,
projectId: string,
) {
const downstreamToken = await exchangeToken(identity.token, identity.tenantId);
const authenticatedFetch: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${downstreamToken}`);
return fetch(input, { ...init, headers });
};
const client = new Client(
{ name: "tenant-gateway", version: "1.0.0" },
{ versionNegotiation: { mode: "auto" } },
);
const transport = new StreamableHTTPClientTransport(config.downstreamUrl, {
fetch: authenticatedFetch,
});
try {
await client.connect(transport);
return await client.callTool(
{
name: "projects.get",
arguments: { projectId },
},
{ timeout: 5_000 },
);
} finally {
await client.close();
}
}
function buildServer({ authInfo }: { authInfo?: AuthInfo }) {
const identity = authInfo as TenantAuthInfo | undefined;
if (!identity) throw new Error("Authenticated identity is required");
const server = new McpServer({
name: "secure-multi-tenant-gateway",
version: "1.0.0",
});
if (identity.scopes.includes("projects:read")) {
server.registerTool(
"projects_get",
{
description: "Read one project in the authenticated tenant",
inputSchema: z.object({ projectId: z.string().uuid() }),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ projectId }) => {
const startedAt = performance.now();
const decisionId = randomUUID();
// Call-time authorization repeats the catalog filter.
if (!identity.scopes.includes("projects:read")) {
return {
isError: true,
content: [{ type: "text", text: "Permission denied" }],
};
}
try {
const result = await callDownstream(identity, projectId);
console.log(JSON.stringify({
event: "mcp.tool.completed",
decisionId,
tenantId: identity.tenantId,
subjectHash: createHash("sha256")
.update(identity.subject)
.digest("hex"),
tool: "projects_get",
durationMs: Math.round(performance.now() - startedAt),
isError: result.isError ?? false,
}));
return {
content: result.content,
structuredContent: result.structuredContent,
isError: result.isError,
};
} catch (error) {
console.error(JSON.stringify({
event: "mcp.tool.failed",
decisionId,
tenantId: identity.tenantId,
tool: "projects_get",
errorType: error instanceof Error ? error.name : "UnknownError",
}));
return {
isError: true,
content: [{
type: "text",
text: `Tool call failed; reference ${decisionId}`,
}],
};
}
},
);
}
return server;
}
const handler = createMcpHandler(buildServer);
const nodeHandler = toNodeHandler(handler);
const app = createMcpExpressApp({
host: "0.0.0.0",
allowedHosts: [config.publicUrl.host],
});
app.use(mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: config.publicUrl,
}));
const auth = requireBearerAuth({
verifier,
requiredScopes: ["mcp"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(config.publicUrl),
});
app.all("/mcp", auth, (request, response) => {
void nodeHandler(request, response, request.body);
});
const httpServer: Server = app.listen(config.port, "0.0.0.0", () => {
console.log(`MCP gateway listening on ${config.port}`);
});
async function shutdown(): Promise<void> {
await handler.close();
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => error ? reject(error) : resolve());
});
}
process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());Expected startup output:
MCP gateway listening on 3000Important lines:
jwtVerifybinds the token to the exact issuer and gateway audience.tenantIdis copied from a verified claim, never from tool arguments.buildServercreates a caller-specific tool catalog for every request.- The handler repeats the scope check immediately before execution.
exchangeTokenmints a separate downstream-audience credential.mode: "auto"handles modern and legacy downstream servers during migration.- The audit record hashes the subject and never logs access tokens or result data.
The example assumes the issuer places tid in the token only after checking active
membership. If users can select among tenants after sign-in, resolve that selection
against a server-side membership store before returning TenantAuthInfo.
Common mistakes in otherwise similar code are accepting any signing algorithm,
omitting aud, using one downstream token for all tenants, caching the catalog by
server URL alone, and logging the raw exception object when it contains headers.
In a higher-throughput implementation, cache server/discover results and token
exchange responses until shortly before expiry. Key both by authorization context.
Use a connection manager with bounded entries, not an unbounded map. The HTTP
agent can reuse sockets even when each MCP request remains logically stateless.
Common Causes
These causes explain most insecure or unreliable multi-tenant MCP gateways:
- Tenant context is treated as caller input. The gateway trusts
tenantIdin JSON, a query string, or an unsigned header. - Authentication is mistaken for authorization. A valid token can call every discovered tool.
- Discovery is treated as enforcement. The gateway hides a tool in
tools/listbut accepts a forgedtools/call. - Inbound tokens are passed downstream. Audience boundaries disappear and the gateway becomes a confused deputy.
- Caches omit security context. Tenant A receives Tenant B's catalog or result.
- One global quota or pool allows noisy neighbors. A single tenant consumes all sockets, memory, or downstream concurrency.
- Tool schemas drift without review. A downstream update expands what an approved public tool can do.
- Write retries lack idempotency. A timeout produces duplicate emails, charges, or deletes.
- Legacy session assumptions leak into modern design. Sticky sessions and in-memory state limit scaling even though the current protocol does not need them.
Reproduce a cross-tenant failure
Cache tools/list by downstream server ID only. Request it first with a Tenant A
token whose catalog includes an admin tool, then request it with a Tenant B token.
If Tenant B receives the cached admin catalog, the isolation bug is proven. Repeat
with a result cache keyed only by tool name and arguments. These tests belong in CI;
never use real tenant data to demonstrate the failure.
Symptoms
| Symptom | Likely failure | First check |
|---|---|---|
| User sees tools from another organization | Shared catalog cache | Inspect the full cache key and cacheScope |
| Correct user gets another tenant's data | Unscoped downstream query or token | Trace tenant from JWT to database predicate |
| Direct JSON-RPC call succeeds for a hidden tool | Authorization only at list time | Inspect tools/call policy logs |
| Downstream accepts a gateway bearer token | Token passthrough | Compare JWT aud with both resources |
| p99 latency jumps when one tenant runs exports | Missing bulkhead | Graph concurrency by tenant and tool class |
| Duplicate side effects after timeouts | Unsafe retry | Check idempotency key and downstream dedupe record |
| Random tool executes after a server update | Name collision or catalog drift | Compare public name and schema digest |
| Memory grows with tenant count | Unbounded client, session, or token cache | Count entries and eviction age per pool |
| Requests fail only behind a load balancer | Legacy session affinity or stripped MCP headers | Compare protocol era and forwarded headers |
| 401 falls back to a legacy handshake | Incorrect version-probe error handling | Ensure auth failures never trigger fallback |
Root Cause
The root cause is usually a broken trust boundary, not MCP message framing.
| Root cause | Why it happens | Durable correction |
|---|---|---|
| Identity and routing are combined | Teams optimize for convenient dispatch | Build immutable identity before routing |
| Policy is advisory | The model or client is assumed cooperative | Complete mediation with deny-by-default checks |
| Shared state has incomplete keys | Functional cache hit rate is prioritized | Include authorization context and policy version |
| Credential audiences are ignored | A token is viewed as a generic API key | Validate inbound aud; mint a new downstream token |
| Dynamic discovery is blindly trusted | MCP interoperability is mistaken for trust | Approve and pin server, name, schema, and risk metadata |
| Side effects lack a transaction identity | JSON-RPC request IDs are mistaken for idempotency keys | Use durable operation-specific idempotency keys |
Capacity and Performance
At 2,000 calls per second and 600 ms average downstream latency, Little's Law gives roughly 1,200 concurrent in-flight calls. Design for the 2x burst target, then add headroom for subscriptions and slow downstreams. Six replicas supporting 800 bounded calls each provide 4,800 slots; verify the number with load tests and heap profiles rather than presenting it as a universal Node.js limit.
The gateway is I/O-bound. CPU cost comes from JWT verification, JSON parsing, schema validation, policy evaluation, encryption, and telemetry. Useful tactics:
- cache JWKS and compiled policy bundles;
- keep database, Redis, HTTP, and TLS pools bounded;
- stream large results or return resource links instead of buffering them;
- enforce request and response byte limits before parsing or logging;
- keep catalog ordering deterministic to improve client and LLM prompt-cache hits;
- sample high-volume success traces but retain every security decision audit;
- shed load before memory pressure turns a slow dependency into a gateway outage.
A GPU provides no benefit for the gateway data plane unless you deliberately run a local ML security model there. Keep that inference service separate so GPU queueing cannot block deterministic authorization. Ordinary CPU instances are easier to scale and isolate.
Verification Steps
Security tests
- A Tenant A token cannot list, call, read, subscribe to, or fetch cached data for Tenant B.
- Changing
tenantIdin arguments has no effect or fails schema validation. - A valid token with the wrong
aud, issuer, client, or expiredexpis 401. - A valid base token without a tool scope is denied at call time.
- The downstream token has the downstream audience, tenant scope, short expiry, and is different from the inbound token.
- A changed downstream schema is quarantined until approved.
- Private IPs, link-local metadata endpoints, redirects, and DNS rebinding are blocked for tenant-registered servers.
- Logs, traces, errors, and metrics contain no bearer or refresh token.
Protocol tests
-
server/discoverreports the supported modern revision. -
Mcp-MethodandMcp-Namedisagreeing with the body are rejected. - Modern calls work across random replicas without
Mcp-Session-Id. -
tools/listis deterministic and private cache entries are not shared. - A 2025-only downstream uses the isolated legacy adapter path.
- Cancellation closes the in-flight request and releases concurrency permits.
- Pagination limits stop a malicious server from returning endless cursors.
Reliability tests
- Load reaches 2x forecast without unbounded queue, heap, or pool growth.
- One tenant exhausting its quota does not raise another tenant's latency.
- Token exchange, policy store, registry, and one downstream server can each fail independently without a total outage.
- Read retries stay within the deadline; write retries deduplicate.
- A rolling deployment drains calls and subscriptions cleanly.
- Audit events reconcile one-to-one with permitted sensitive actions.
The TypeScript SDK testing guide
supports in-process modern-protocol tests by driving a handler's fetch function,
which is faster and more deterministic than opening a real port in CI.
Troubleshooting Matrix
| Error or signal | Meaning | Debug path | Fix |
|---|---|---|---|
401 invalid_token | Token missing, invalid, or expired | Check issuer, audience, expiry, JWKS cache | Correct IdP metadata and token audience |
403 insufficient_scope | Identity valid, base scope absent | Compare required and granted scopes | Step up authorization or reduce requested access |
ERA_NEGOTIATION_FAILED | No compatible modern revision | Inspect server/discover and cached verdict age | Re-probe or route to legacy adapter |
| Header mismatch JSON-RPC error | MCP headers disagree with body | Capture edge and origin headers | Preserve SDK-generated headers; reject tampering |
| Tool not found | Stale catalog or wrong namespace | Compare catalog version and downstream list | Refresh approved mapping; never fuzzy-match names |
| Circuit open | Downstream exceeded failure threshold | Inspect server-specific latency and errors | Fail fast, recover with half-open probes |
| Deadline exceeded | Queue or downstream consumed budget | Break down trace spans | Reduce queueing, set per-stage budgets, shed load |
| Duplicate mutation | Retry was not idempotent | Search by operation key | Persist dedupe key before executing side effect |
| Cross-tenant cache hit | Cache key lacks auth context | Log key dimensions, not sensitive values | Purge cache and deploy partitioned keys |
| Growing heap | Unbounded sessions, clients, or subscriptions | Heap snapshot by constructor | Add max size, TTL, eviction, and shutdown cleanup |
Platform and Deployment Notes
Linux and Docker
Run as a non-root user on a read-only filesystem. Drop Linux capabilities, apply a seccomp profile, cap memory and PIDs, and mount no Docker socket. Give the gateway egress only to approved identity, policy, telemetry, and downstream endpoints. For local stdio servers, use separate sandboxed processes or containers with explicit filesystem and network grants.
macOS and Windows
For local development, bind to 127.0.0.1, validate Host and Origin, and use
stdio when the host launches the server directly. Do not open OAuth URLs through a
shell command. Use platform URL APIs so a malicious authorization URL cannot become
shell injection. Windows service accounts and macOS sandbox profiles should have
only the files and keychain entries required by the gateway.
WSL
Treat WSL and Windows as separate network and filesystem trust zones. A service bound to all WSL interfaces may be reachable from the Windows host or local network. Bind loopback, validate hosts, and avoid forwarding Windows credentials or home directories into Linux containers by default.
CI/CD
Use short-lived workload identity instead of stored cloud keys. Generate an SBOM, scan dependencies and images, sign the artifact, and deploy by immutable digest. Run protocol conformance, cross-tenant property tests, policy tests, SSRF tests, and secret-scanning gates. Roll out catalog and policy versions independently with fast rollback.
Cloud and production
Terminate public TLS at a managed edge, then use authenticated private transport to the gateway and downstream servers. Use regional control-plane replicas, but keep a last-known-good policy and registry cache in each data-plane region. Decide whether large regulated tenants need a dedicated gateway, account, project, VPC, or key. Do not claim logical tenant columns provide the same blast radius as physical isolation.
Development
Use synthetic tenants and fake secrets. Keep development audiences, issuers, catalogs, and downstream servers separate from production. A local bypass flag is acceptable only if the process refuses to start with that flag outside a loopback environment.
Prevention
- Make tenant identity immutable and transport-derived.
- Require authorization on every MCP method, not only
tools/call. - Pin public names, schema digests, server identities, and risk classes.
- Exchange tokens and validate every audience.
- Partition caches, pools, breakers, quotas, and telemetry by security context.
- Use explicit handles for application state; do not recreate protocol sessions.
- Require signed, single-use approvals for destructive actions.
- Enforce byte, page, time, concurrency, and subscription limits.
- Keep legacy servers in a separate adapter pool with expiry dates.
- Test isolation invariants continuously, not only before launch.
- Review audit coverage during incident exercises.
How to Present This in 45 Minutes
- Minutes 0–5: clarify actors, scale, tenancy, transport, and write risk.
- Minutes 5–10: state the security invariant and draw trust boundaries.
- Minutes 10–20: walk through authentication, catalog filtering, call-time policy, token exchange, and routing.
- Minutes 20–28: explain the 2026 stateless protocol and version adapter.
- Minutes 28–35: cover quotas, bulkheads, retries, idempotency, and capacity.
- Minutes 35–40: cover audit, OpenTelemetry, privacy, and deployment.
- Minutes 40–45: invite follow-ups on shared versus dedicated isolation, approval workflows, or regional failover.
If time is short, protect the invariants first. A simpler gateway with correct tenant and credential boundaries is stronger than an elaborate architecture that trusts model-controlled routing.
Key Takeaways
- MCP standardizes communication; it does not supply your tenant isolation model.
- The authenticated identity selects the tenant. The model never does.
- Catalog filtering improves safety and context size, but execution-time policy is mandatory.
- Token exchange preserves OAuth audience boundaries and downstream least privilege.
- Stateless MCP
2026-07-28removes the need for sticky data-plane sessions. - Shared infrastructure is safe only when its keys and limits include the full authorization context.
- Tool descriptions and annotations are hints. Network, identity, policy, schema, and approval controls are enforcement.
- The best interview answer includes failure behavior and verification, not only a diagram of the happy path.
Suggested Internal Links
- system design interview framework
- OAuth 2.1 authorization code and PKCE guide
- multi-tenant SaaS data isolation patterns
- API gateway rate limiting and quotas
- idempotency keys for distributed systems
- OpenTelemetry tracing in Node.js
- preventing SSRF in TypeScript services
- AI agent prompt injection defenses
Official References
- Model Context Protocol specification
2026-07-28 - MCP
2026-07-28key changes - MCP architecture overview
- MCP security best practices
- MCP TypeScript SDK v2
- TypeScript SDK gateway and worker fleet guidance
- OAuth 2.0 Security Best Current Practice, RFC 9700
- OAuth 2.0 Protected Resource Metadata, RFC 9728
- W3C Trace Context
FAQs
What is a multi-tenant MCP gateway?
A multi-tenant MCP gateway is a policy-enforcing MCP server that presents one endpoint to many clients and routes approved operations to downstream MCP servers. It centralizes authentication, tenant isolation, authorization, credential exchange, quotas, observability, and audit without merging tenant contexts.
Where should tenant identity come from?
Derive tenant identity from a verified token claim and confirm membership in
server-side data. Do not trust tenantId in tool arguments, query parameters, or
an unsigned X-Tenant-ID header. If a trusted proxy supplies the tenant, bind the
signed assertion to the authenticated principal and intended gateway audience.
Should the gateway pass client tokens downstream?
No. Validate that the inbound token targets the gateway. Then mint or exchange a different short-lived token for the selected downstream audience and scopes. Raw token passthrough breaks resource boundaries and enables confused-deputy attacks.
How does MCP 2026-07-28 change gateway design?
It removes protocol sessions, initialize, notifications/initialized,
Mcp-Session-Id, and SSE replay. Requests are self-contained, so ordinary
round-robin replicas can serve them. Cache server/discover by authorization
context and use subscriptions/listen only when a client needs change events.
How do you prevent cross-tenant MCP leaks?
Use tenant-derived routing, tenant predicates in every storage call, audience- scoped credentials, and cache keys containing tenant and principal scope. Then continuously test the invariant that Tenant A cannot list, call, read, subscribe to, or retrieve cached content belonging to Tenant B.
Is hiding unauthorized tools in tools/list enough?
No. A client can invoke a known tool name without listing it first, and permissions can change after discovery. Filter discovery for least disclosure, then perform a fresh deny-by-default authorization check immediately before every operation.
Should the gateway expose a generic tool dispatcher?
Usually not. A generic invoke tool hides input schemas, risk annotations, and
approval semantics from the host. Prefer stable namespaced tools with explicit
schemas. Use a generic dispatcher only when a controlled client understands the
catalog and the gateway still validates the selected tool deterministically.
How should a gateway handle destructive tools?
Turn the action into a proposal, show the exact effect in a separate trusted UI,
and require a signed, expiring, single-use approval bound to the tenant, user, and
canonical argument hash. Execute with an idempotency key. A model-supplied
confirmed: true flag is not human approval.
Does an MCP gateway need a GPU?
No. Gateway work is mainly network I/O, JWT verification, policy evaluation, JSON validation, and telemetry. CPU instances scale this workload well. If you add an ML-based content detector, operate it as a bounded dependency so GPU saturation cannot block deterministic authorization or tenant isolation.
Key takeaways
- •Never accept tenant identity from tool arguments or an unverified header; derive it from the validated access token.
- •Filter tools during discovery and repeat authorization at execution because discovery is not a security boundary.
- •Do not pass the gateway bearer token to downstream MCP servers; exchange it for a short-lived, audience-scoped credential.
- •MCP 2026-07-28 removes protocol sessions and the initialize handshake, making a stateless gateway the preferred design.
- •Partition discovery caches, result caches, quotas, circuit breakers, credentials, and audit streams by authorization context.
- •Treat the model and downstream tool descriptions as untrusted; deterministic policy and out-of-band approval protect writes.
Frequently asked questions
What is a multi-tenant MCP gateway?
A multi-tenant MCP gateway is a policy-enforcing MCP server that presents one endpoint to many clients and routes approved tool, resource, and prompt operations to downstream MCP servers. It centralizes authentication, tenant isolation, authorization, credential exchange, rate limits, observability, and audit logging without merging one tenant's security context with another's.
Where should tenant identity come from in an MCP gateway?
Tenant identity must come from a verified authentication credential, such as a validated JWT tid claim or a server-side membership lookup keyed by the token subject. Do not trust tenantId in tool arguments, query parameters, or X-Tenant-ID unless a trusted proxy signs and binds that value to the authenticated principal.
Should an MCP gateway pass client tokens to downstream servers?
No. MCP security guidance forbids token passthrough. The gateway must validate that the inbound token targets the gateway, then use OAuth token exchange, client credentials, or a secret broker to obtain a separate short-lived token whose audience and scopes target the selected downstream server.
How does MCP 2026-07-28 change gateway design?
MCP 2026-07-28 removes protocol-level sessions, initialize, notifications/initialized, Mcp-Session-Id, and SSE resumability. Each request carries version and capability metadata. Gateways can route stateless requests across replicas, cache server/discover results per authorization context, and use subscriptions/listen only when change notifications are required.
How do you prevent cross-tenant data leaks in MCP?
Use tenant-derived routing, deny-by-default authorization, tenant predicates in every storage query, audience-scoped downstream credentials, and cache keys that begin with tenant and principal scope. Test the isolation invariant directly: a token for tenant A must never list, invoke, read, subscribe to, or retrieve cached content owned by tenant B.
Should an MCP gateway expose one generic invoke tool?
Usually no. A generic invoke tool hides schemas from clients, weakens approvals, and moves validation into an opaque string interface. Prefer stable namespaced tools such as github.search_issues, filter the advertised catalog per caller, and recheck policy when each tool executes. A generic dispatch tool is acceptable only for constrained internal clients.