Lesson 14 Multi-Tenant Remote MCP Servers
Theme:
Lesson 14 · Slide 1

Lesson 14 — Multi-Tenant Remote MCP Servers

Identify the caller, isolate their state, cap their request rate. Make PKB-TS safe to expose.

MCP Servers Module 5

Lesson 14 · Slide 2

The single-tenant problem

Where L13 left us

Any client that can reach /sse opens a session. One process, one notes store, no caller identity.

What L14 adds

Bearer auth, per-tenant note store, per-tenant rate limit, per-session transport map.

"Three independent guarantees: who, what data, how often."

Lesson 14 · Slide 3

The three orthogonal concerns

Each is independent. Getting one wrong does not save you on the others.

Lesson 14 · Slide 4

Bearer-token middleware

export function bearerAuth(req, res, next) {
  const m = (req.header("authorization") ?? "").match(/^Bearer\s+(\S+)$/i);
  if (!m) return res.status(401).json({ error: "missing bearer token" });
  const tenant = TOKENS.get(m[1]);
  if (!tenant) return res.status(401).json({ error: "invalid token" });
  req.tenant = tenant;
  next();
}

Token → tenant lookup at the edge. No tenant means 401 before the SDK ever sees the request.

Lesson 14 · Slide 5

Tokens for the lab; OAuth for production

Danger: Tokens are the simplest auth that works — not the strongest.

For the L14 lab, PKB_TOKENS="alice:tok_alice,bob:tok_bob" is enough. For real deployments you graduate to OAuth (L09 covered the flow) or a managed identity provider. The middleware shape — Authorization: Bearer X → tenant — does not change; only the lookup behind it does.

Lesson 14 · Slide 6

Per-session SSE map

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);
});

The SDK's SSEServerTransport exposes a sessionId you key the map on. The POST /messages route looks the session up by ?sessionId= query param.

Lesson 14 · Slide 7

Per-tenant note store

const STORES = new Map<string, Map<string, string>>();
export function storeFor(tenant: string) {
  let s = STORES.get(tenant);
  if (!s) { s = new Map(); STORES.set(tenant, s); }
  return s;
}

storeFor(tenant) is the only way tool code reaches notes. The tenant id comes from the authenticated session, never from request args.

Lesson 14 · Slide 8

Tenant flows via the session, not the args

Danger: Never let tool args carry the tenant id.

If a pkb.fetch arg looks like { "tenant": "...", "id": "..." }, a caller can pass any tenant id. Source the tenant from the authenticated session and override any arg field with the same name. Trust the session, not the wire.

Lesson 14 · Slide 9

Token-bucket rate limit

// Refill REFILL_PER_MIN tokens/min, cap CAPACITY.
function check(tenant) {
  const b = BUCKETS.get(tenant) ?? { tokens: CAPACITY, lastRefill: Date.now() };
  // ... refill, decrement, return { allowed, remaining, retryAfterMs } ...
}

Per-tenant bucket. CAPACITY tokens of burst; REFILL_PER_MIN sustained rate. Rejected calls return { isError: true, content: ... "retry after X ms" }.

Lesson 14 · Slide 10

Where the rate limit lives

Inside the CallTool handler, after tenant resolution. Per-IP rate limit at the edge (Caddy) is a separate, complementary layer — it catches anonymous floods before they hit the SDK. Per-tenant rate limit catches abusive authenticated clients.

Use both. Edge stops the unknown; in-process stops the known-but-misbehaving.

Lesson 14 · Slide 11

Quotas vs rate limits

Lesson 14 · Slide 12

Abuse handling

These three patterns cover ~90% of real abuse traffic.

Lesson 14 · Slide 13

What's in the lab

Lesson 14 · Slide 14

Recap & what's next

Coming up: Lesson 15 — Threat model, input fuzzing, prompt-injection-resistant tool results, audit logging, runbook.
1 / ?