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 take tenantId from model-controlled arguments.
  • Return only authorized tools from tools/list, then authorize tools/call again. Listing is user experience; invocation is the security boundary.
  • Namespace tools, such as github.search_issues, so two servers cannot silently collide on search.
  • 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

  1. Expose one remote MCP endpoint to hosts such as IDEs, assistants, and internal agents.
  2. Aggregate tools from first-party and approved third-party MCP servers.
  3. Return a caller-specific catalog of tools, resources, and prompts.
  4. Route calls while preserving the tenant, user, agent, trace, and approval context.
  5. Support user-delegated and machine-to-machine access.
  6. 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.

Concern2025-era behaviorMCP 2026-07-28 behaviorGateway consequence
Negotiationinitialize then notifications/initializedOptional server/discover; version and capabilities in request _metaCache discovery per authorization context
SessionOptional Mcp-Session-IdNo protocol sessionRound-robin routing works by default
Server eventsGET/SSE channelsubscriptions/listen POST-response streamOpen a stream only for consumers that need changes
RecoverySSE event IDs and Last-Event-IDNo stream replayReissue safe requests with a new request ID
RoutingOften required body inspectionMcp-Method and Mcp-Name HTTP headersEdge can route and limit without parsing JSON
Catalog cachingAd hocttlMs and cacheScope on cacheable resultsHonor freshness and never share private results
Long workExperimental core TasksOptional Tasks extensionNegotiate explicitly; keep durable state outside a replica
Roots, Sampling, LoggingActiveDeprecatedDo 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

Secure multi-tenant MCP gateway architecture

MCP hosts call a gateway through an edge. The gateway authenticates, resolves tenant identity, evaluates policy, applies quotas, routes to isolated downstream MCP servers, exchanges credentials, and writes audit and telemetry events.

Client trust zoneMCP hostsIDE · agent · assistantHTTPSStateless MCP gatewayAuthN + tenantJWT · issuer · aud · tidPolicy enginedeny by defaultQuota + bulkheadtenant · tool · agentCatalog + routernamespace · schema · targetCredential brokertoken exchange · vaultResiliencetimeout · breaker · retryAudit + OpenTelemetrydecision ID · trace · latency · redacted result metadataServer trust zonesShared MCP serverrow-level tenant policyaudience: mcp://sharedDedicated tenant serverisolated network + datatenant-specific credentialThird-party MCPegress allowlist + SSRF guardper-tenant OAuth grantControl plane: server registry · policy bundles · tenant config · secret referencesVersioned, reviewed, replicated; never on the synchronous path as a single point of failure

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

ComponentOwnsMust not own
EdgeTLS, body limits, DDoS controls, coarse rate limitsTenant authorization
AuthenticatorSignature, issuer, audience, expiry, client and subject validationTrusting model arguments
Tenant resolverMembership and active tenant selectionFree-form tenant IDs
Policy enginePrincipal × tenant × tool × action × context decisionPrompt interpretation
CatalogStable public name, schema digest, server target, risk classRaw credentials
Credential brokerAudience-scoped short-lived downstream tokensReturning secrets to the model
RouterTarget selection, timeout budget, protocol-era adapterChanging authorization decisions
Audit pipelineTamper-evident decision and action recordsFull 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

MCP tool call authorization and routing lifecycle

A request moves through token verification, tenant resolution, policy, quota, route lookup, token exchange, downstream execution, output checks, and audit. Any failed gate returns a denial without calling the downstream server.

tools/call: complete mediation before side effects1Verify tokeniss · aud · exp · signature2Resolve tenantmembership · status3Authorizetool · action · context4Budgetquota · concurrency5Routeregistry · schema digest6Exchange credentialnew audience · least scope7Call downstreamtimeout · breaker · trace8Validate + auditsize · schema · redactiondeny + audit; no downstream call
  1. Validate the access token locally with a cached JWKS where possible. Check signature, iss, aud, exp, and client binding.
  2. Resolve the active tenant from a trusted claim plus server-side membership. Reject suspended tenants before catalog or cache access.
  3. Map the public tool name to a versioned catalog record and evaluate policy.
  4. Consume tenant, principal, and tool-class quotas atomically.
  5. Resolve an approved downstream URL from the registry. Never route to a URL supplied in tool arguments.
  6. Exchange the inbound token for a short-lived, downstream-audience token.
  7. Invoke the downstream MCP server with a deadline and propagated trace context.
  8. Validate response size and declared schema, redact logs, emit audit, and return.

Step-by-step Solution

Step 1: Choose a gateway shape

ShapeProsConsBest use
Transparent MCP proxyPreserves all downstream featuresHard to normalize policy; name collisions; larger attack surfaceOne trusted server behind one edge
Policy-enforcing facadeStable names, schemas, approval UX, and policyMore catalog and compatibility workRecommended multi-tenant design
One gateway per tenantStrong blast-radius isolationHigher cost and operational countRegulated or very large tenants
Generic invoke(server, tool, args) toolSmall visible catalogHides schemas and risk; weak client UXConstrained 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:

FieldExamplePurpose
publicNamegithub.search_issuesStable collision-free contract
serverIdgithub-prodApproved routing target
downstreamNamesearch_issuesActual MCP tool name
schemaDigestSHA-256 of canonical JSON SchemaDetect unreviewed changes
riskread, write, destructiveApproval and quota policy
requiredScopesissues:readLeast privilege
policyVersion184Cache 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:

  1. OAuth token exchange: exchange the gateway token for a token whose audience is the target MCP server and whose scope is the selected operation.
  2. Delegated tenant grant: retrieve a tenant user's encrypted refresh token and mint a short-lived provider token.
  3. 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.

MechanismUnsafe keySafe key or boundary
Tool catalog cacheserverIdtenantId:principalScope:serverId:policyVersion
Result cachetoolName:argsHashtenantId:subjectScope:toolName:argsHash:dataVersion
Discovery verdictserverUrlauthorization-context hash + server ID
Connection/client poolserverIdtenant + server + credential generation
Rate limitglobal countertenant + agent + tool risk class
Circuit breakerone global breakerserver and region, with tenant bulkheads
Audit streamshared unpartitioned topictenant 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:

  1. The MCP tool creates a proposed action with canonical arguments and expiry.
  2. A separate UI shows the exact effect, tenant, target, and diff.
  3. The user approves out of band.
  4. The approval service signs {tenant, subject, actionHash, expiry, nonce}.
  5. The gateway verifies the signed approval and consumes the nonce once.
  6. 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 3000

Important lines:

  • jwtVerify binds the token to the exact issuer and gateway audience.
  • tenantId is copied from a verified claim, never from tool arguments.
  • buildServer creates a caller-specific tool catalog for every request.
  • The handler repeats the scope check immediately before execution.
  • exchangeToken mints 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:

  1. Tenant context is treated as caller input. The gateway trusts tenantId in JSON, a query string, or an unsigned header.
  2. Authentication is mistaken for authorization. A valid token can call every discovered tool.
  3. Discovery is treated as enforcement. The gateway hides a tool in tools/list but accepts a forged tools/call.
  4. Inbound tokens are passed downstream. Audience boundaries disappear and the gateway becomes a confused deputy.
  5. Caches omit security context. Tenant A receives Tenant B's catalog or result.
  6. One global quota or pool allows noisy neighbors. A single tenant consumes all sockets, memory, or downstream concurrency.
  7. Tool schemas drift without review. A downstream update expands what an approved public tool can do.
  8. Write retries lack idempotency. A timeout produces duplicate emails, charges, or deletes.
  9. 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

SymptomLikely failureFirst check
User sees tools from another organizationShared catalog cacheInspect the full cache key and cacheScope
Correct user gets another tenant's dataUnscoped downstream query or tokenTrace tenant from JWT to database predicate
Direct JSON-RPC call succeeds for a hidden toolAuthorization only at list timeInspect tools/call policy logs
Downstream accepts a gateway bearer tokenToken passthroughCompare JWT aud with both resources
p99 latency jumps when one tenant runs exportsMissing bulkheadGraph concurrency by tenant and tool class
Duplicate side effects after timeoutsUnsafe retryCheck idempotency key and downstream dedupe record
Random tool executes after a server updateName collision or catalog driftCompare public name and schema digest
Memory grows with tenant countUnbounded client, session, or token cacheCount entries and eviction age per pool
Requests fail only behind a load balancerLegacy session affinity or stripped MCP headersCompare protocol era and forwarded headers
401 falls back to a legacy handshakeIncorrect version-probe error handlingEnsure auth failures never trigger fallback

Root Cause

The root cause is usually a broken trust boundary, not MCP message framing.

Root causeWhy it happensDurable correction
Identity and routing are combinedTeams optimize for convenient dispatchBuild immutable identity before routing
Policy is advisoryThe model or client is assumed cooperativeComplete mediation with deny-by-default checks
Shared state has incomplete keysFunctional cache hit rate is prioritizedInclude authorization context and policy version
Credential audiences are ignoredA token is viewed as a generic API keyValidate inbound aud; mint a new downstream token
Dynamic discovery is blindly trustedMCP interoperability is mistaken for trustApprove and pin server, name, schema, and risk metadata
Side effects lack a transaction identityJSON-RPC request IDs are mistaken for idempotency keysUse 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 tenantId in arguments has no effect or fails schema validation.
  • A valid token with the wrong aud, issuer, client, or expired exp is 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/discover reports the supported modern revision.
  • Mcp-Method and Mcp-Name disagreeing with the body are rejected.
  • Modern calls work across random replicas without Mcp-Session-Id.
  • tools/list is 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 signalMeaningDebug pathFix
401 invalid_tokenToken missing, invalid, or expiredCheck issuer, audience, expiry, JWKS cacheCorrect IdP metadata and token audience
403 insufficient_scopeIdentity valid, base scope absentCompare required and granted scopesStep up authorization or reduce requested access
ERA_NEGOTIATION_FAILEDNo compatible modern revisionInspect server/discover and cached verdict ageRe-probe or route to legacy adapter
Header mismatch JSON-RPC errorMCP headers disagree with bodyCapture edge and origin headersPreserve SDK-generated headers; reject tampering
Tool not foundStale catalog or wrong namespaceCompare catalog version and downstream listRefresh approved mapping; never fuzzy-match names
Circuit openDownstream exceeded failure thresholdInspect server-specific latency and errorsFail fast, recover with half-open probes
Deadline exceededQueue or downstream consumed budgetBreak down trace spansReduce queueing, set per-stage budgets, shed load
Duplicate mutationRetry was not idempotentSearch by operation keyPersist dedupe key before executing side effect
Cross-tenant cache hitCache key lacks auth contextLog key dimensions, not sensitive valuesPurge cache and deploy partitioned keys
Growing heapUnbounded sessions, clients, or subscriptionsHeap snapshot by constructorAdd 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

  1. Minutes 0–5: clarify actors, scale, tenancy, transport, and write risk.
  2. Minutes 5–10: state the security invariant and draw trust boundaries.
  3. Minutes 10–20: walk through authentication, catalog filtering, call-time policy, token exchange, and routing.
  4. Minutes 20–28: explain the 2026 stateless protocol and version adapter.
  5. Minutes 28–35: cover quotas, bulkheads, retries, idempotency, and capacity.
  6. Minutes 35–40: cover audit, OpenTelemetry, privacy, and deployment.
  7. 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-28 removes 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.

Official References

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.


Related Posts