Multi-Tenant Remote MCP Servers
Lesson 14 · MCP Servers · ~12 min read
From “anyone on the network” to “this specific caller”
Lesson 13 made PKB-TS reachable over HTTPS. That is the start of the danger, not the end. A reachable, unauthenticated server that holds shared state is one wrong DNS record away from being an open relay for whatever its tools do — note storage in our case; for other servers it might be API calls billed to your account, file writes to your disk, or queries against your private data. The point of Lesson 14 is to install three independent guarantees that together make a remote MCP server safe to expose:
- Identity. Who is calling? Resolve every request to a tenant id before the SDK ever sees it.
- Isolation. Their state is not your state. Two callers must never be able to read each other’s data, even by accident.
- Quota. No single caller can melt the process. Each tenant gets a bounded share of CPU, memory, and outbound calls.
These three are orthogonal. Getting one right does not save you on the others. The L14 lab wires all three into PKB-TS in code that totals under 200 lines.
Bearer-token middleware
The simplest auth that works is a bearer token mapped to a tenant id at the HTTP edge.
Authorization: Bearer X → req.tenant = "alice".
Implement as Express middleware so it sits in front of the SDK and fails closed on
missing or unknown credentials:
export function bearerAuth(req, res, next) {
const m = (req.header("authorization") ?? "").match(/^Bearer\s+(\S+)$/i);
if (!m) {
log.warn({ phase: "denied", reason: "no_bearer" });
return res.status(401).json({ error: "missing bearer token" });
}
const tenant = TOKENS.get(m[1]);
if (!tenant) {
log.warn({ phase: "denied", reason: "unknown_token" });
return res.status(401).json({ error: "invalid token" });
}
req.tenant = tenant;
next();
}
Apply bearerAuth to /sse — the session-opening route.
/messages does not need it directly because every POST carries a
?sessionId= query param that you look up in the in-memory session map; an
unknown id returns 404, never 200. The lookup itself is the second gate: a stolen
session id from a now-closed stream simply will not resolve.
The PKB_TOKENS="alice:tok_alice,bob:tok_bob" env-var format is fine for the
lab. For production you swap the in-memory map for an OAuth introspection call (Lesson 09
introduced the flow), a JWT verifier with a public-key cache, or a managed identity
provider like Auth0 / Clerk / Okta. The middleware shape —
Authorization: Bearer X → tenant — does not change; only the
lookup behind it does.
Per-session SSE: the transport map
With one tenant the SSE wiring looks like Lesson 13: one process holds one transport.
let transport: SSEServerTransport | null = null;
With many tenants you need a map keyed by session id. The SDK’s
SSEServerTransport constructor returns an object whose
sessionId property you can read immediately after construction; use it as
the map key, and clean the entry up when the underlying response closes:
const sessions = new Map<string, { transport: SSEServerTransport; tenant: string }>();
app.get("/sse", bearerAuth, async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
sessions.set(transport.sessionId, { transport, tenant: req.tenant });
res.on("close", () => sessions.delete(transport.sessionId));
await server.connect(transport);
});
app.post("/messages", express.json(), async (req, res) => {
const sid = String(req.query.sessionId ?? "");
const sess = sessions.get(sid);
if (!sess) { res.status(404).send("unknown session"); return; }
(req as any).tenant = sess.tenant;
await sess.transport.handlePostMessage(req, res, req.body);
});
The res.on("close", ...) cleanup is not optional: without it the
sessions map grows for every dropped connection until the process restarts.
For long-lived deployments add a periodic sweep that prunes sessions whose underlying
socket has been idle for more than N hours — the homework covers a configurable
TTL variant.
Per-tenant in-memory store
The simplest isolation is two levels of Map: a top-level keyed by tenant,
each value being that tenant’s note store. The only path tool code uses
to reach notes is storeFor(tenant):
const STORES = new Map<string, Map<string, string>>(); // tenant -> (note_id -> body)
export function storeFor(tenant: string): Map<string, string> {
let s = STORES.get(tenant);
if (!s) { s = new Map(); STORES.set(tenant, s); }
return s;
}
The tenant argument comes from the session, not from the tool args. Get this wrong and
your isolation collapses: a caller passes {"tenant": "victim", "id": "..."}
and reads someone else’s data. See the next section for why this is the single
most common multi-tenant bug.
For production: replace Map with SQLite-per-tenant (one file per tenant in
/data/<tenant>.db) or a shared Postgres with a
WHERE tenant_id = $1 clause appended to every query. The interface stays
storeFor(tenant) → { search, fetch, write }; only the storage engine
changes.
Tenant must not flow through tool args
Trust the session, not the wire
- Tool input schemas (
SearchInput,FetchInput,WriteInput) do not include atenantfield. There is no legitimate reason for a caller to specify whose notes they are reading; the bearer token already answered that. - Tool handlers receive
(tenant: string, rawArgs: unknown)—tenantis the function’s first argument, sourced fromextra.tenantinsideCallTool, sourced from the session lookup in/messages, sourced frombearerAuthmiddleware on/sse. Identity flows one direction: HTTP → session → handler. - Even if a caller sends
{"text": "...", "tenant": "victim"}, Zod’s.strict()rejects the unknown key — or, worse, your schema accepts it and you simply ignore it because you only ever readtenantfrom the function arg. The two layers compound: schema rejects AND handler never reads. - Logs always include the session-derived tenant id, not anything from args. That is how the audit trail in Lesson 15 ties every action to a real caller.
If you find yourself adding a tenant arg to a tool schema “because
it’s convenient”, that is the failure mode. Stop and source it from the
session instead.
Token-bucket rate limit
The simplest “fair” rate limit is a token bucket per tenant:
- Each tenant has a bucket of
CAPACITYtokens (default 10) — the burst allowance. - Tokens refill at
REFILL_PER_MIN(default 60 per minute = 1 per second) — the sustained rate. - Every call decrements the bucket. If empty, deny with a
retryAfterMsderived from the refill rate.
function check(tenant: string): { allowed: boolean; retryAfterMs: number } {
const b = BUCKETS.get(tenant) ?? { tokens: CAPACITY, lastRefill: Date.now() };
const elapsedMin = (Date.now() - b.lastRefill) / 60_000;
b.tokens = Math.min(CAPACITY, b.tokens + elapsedMin * REFILL_PER_MIN);
b.lastRefill = Date.now();
if (b.tokens >= 1) { b.tokens -= 1; return { allowed: true, retryAfterMs: 0 }; }
return { allowed: false, retryAfterMs: Math.ceil(((1 - b.tokens) / REFILL_PER_MIN) * 60_000) };
}
Call check(tenant) at the top of CallTool, after
tenant resolution. On allowed: false return an isError: true
content with the retry hint — never throw; the client should see your
message, not a generic protocol error. The math handles burst (10 calls right now is
fine) and sustained load (you cannot sustain above 60/min without refusal).
This is one layer. Pair it with a per-IP rate limit at Caddy (handles anonymous floods before they even reach Node) and per-tenant daily quotas (handles commercial caps that are too coarse for a per-second bucket). PKB-TS implements per-tenant rate limit only; the others are homework / production additions.
Abuse handling
Three patterns cover roughly ninety percent of real abuse traffic. None of them live inside the SDK; they are operational concerns that Lesson 15 wires into a runbook.
- Log every denial with a structured reason:
{phase: "denied", reason: "no_bearer" | "unknown_token" | "expired_token" | "rate_limit"}. Counts per reason per minute is your abuse signal — a sudden spike ofunknown_tokenis a credential-stuffing run; a spike ofrate_limitfrom one tenant is a runaway client or a stolen token. - Token quarantine. After N (e.g., 50) consecutive denials from a token, block that token for M minutes. Catches credential-stuffing cheaply without touching legitimate tenants.
- Tenant-level escalation. If a tenant repeatedly hits the rate limit and the token-quarantine rule, escalate to a per-IP block at Caddy via a small admin script or a static deny-list. Per-IP blocks are blunt; reserve them for repeat offenders.
Common pitfalls
Five mistakes that look harmless and break isolation
- Putting
tenantin tool-arg schemas — callers spoof identity at the wire. Always source from the session. - Forgetting
res.on("close", ...)cleanup — thesessionsmap leaks memory until the process restarts. Worse, the sessionIds linger as a stolen-token target. - Trusting
req.session.tenantwithout re-checking on every/messagesPOST — a stolen session id from an old SSE stream still works. The session-map lookup is the recheck. - Implementing the rate limit before auth — unauthenticated floods evict legitimate per-tenant buckets out of the map. Always: auth first, then rate limit on the resolved tenant.
- Treating “401” and “403” as the same — use 401 for missing or invalid credentials, 403 for valid credentials but wrong tenant, 429 for rate-limit. Downstream tooling (clients, dashboards, alerting) depends on the codes.
References
- MCP SDK
SSE transport source — the authoritative reference for
SSEServerTransport,sessionId, andhandlePostMessage. - Express middleware
documentation — the request-lifecycle model that
bearerAuthplugs into. - Token-bucket algorithm — background on burst vs sustained rate; the same math powers Stripe’s public-API rate limiter.
- RFC 7662 — OAuth 2.0 Token Introspection — the production swap-in for the lab’s in-memory token map.
sample-server/typescript/src/auth.ts,store.ts,rateLimit.ts— the working code you build in the L14 lab.