Lesson 14 Multi-Tenant Remote MCP Servers
Theme:

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:

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

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:

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.

  1. 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 of unknown_token is a credential-stuffing run; a spike of rate_limit from one tenant is a runaway client or a stolen token.
  2. 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.
  3. 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

  1. Putting tenant in tool-arg schemas — callers spoof identity at the wire. Always source from the session.
  2. Forgetting res.on("close", ...) cleanup — the sessions map leaks memory until the process restarts. Worse, the sessionIds linger as a stolen-token target.
  3. Trusting req.session.tenant without re-checking on every /messages POST — a stolen session id from an old SSE stream still works. The session-map lookup is the recheck.
  4. 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.
  5. 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