Lesson 14 Multi-Tenant Remote MCP Servers
Theme:

Homework — Lesson 14

Three tasks · daily quota, session timeout, per-IP rate limit at Caddy · ~75–100 min

Lesson 14 turned PKB-TS from a single-tenant playground into a multi-tenant server with three orthogonal guarantees: identity (bearer middleware), isolation (per-tenant note store keyed off the authenticated session), and quota (token-bucket rate limit at CallTool). The homework extends each of those three axes by exactly one production-grade increment: (1) a per-tenant daily quota on top of the per-minute rate limit; (2) a session TTL so abandoned SSE connections do not pile up forever; (3) a per-IP rate limit at Caddy so anonymous floods never reach the SDK in the first place. None of these requires rewriting what L14 shipped — each is an additive layer.

Before you start

Lesson 14's lab must be done end-to-end: auth.ts, store.ts, rateLimit.ts exist; server.ts mounts bearerAuth on /sse; two Inspector tabs with tok_alice / tok_bob see isolated notes; pkb.search from a single tenant trips the 10-burst / 60-per-minute rate limit. You also need the L13 Caddy stack running on your hostname — Task 3 edits the Caddyfile. No new npm dependencies are required (daily-quota and session-TTL use the standard library; Caddy's rate_limit directive needs Caddy 2.7+ — pin caddy:2.7 or newer in docker-compose.yml if you have not already).

Task 1 — Add a per-tenant daily quota

The L14 token-bucket caps throughput (60 calls/min sustained, 10-burst). It does not cap volume. A misbehaving client that paces itself exactly at 60/min still burns 86 400 calls/day. Layer a second check on top: a daily counter per tenant that resets at UTC midnight.

Requirements:

✓ Self-check: set PKB_DAILY_QUOTA=5 and restart the server; call pkb.search 5 times as Alice — all succeed; the 6th returns isError: true with daily quota exceeded; resets at 2026-05-27T00:00:00.000Z; a parallel call as Bob still succeeds (counters are per-tenant); restart the process — the counter resets in-memory (production would persist this to SQLite or Redis); log a denial line with phase=denied reason=daily_quota tenant=alice.

Task 2 — Add a session timeout

The sessions Map in server.ts is cleared on res.on("close", ...), which handles the happy case. It does not handle: clients that drop their TCP stream without closing cleanly, NAT timeouts that abandon a stream halfway, or clients that just walk away. Over hours those leak into RAM. Cap the session lifetime explicitly.

Requirements:

✓ Self-check: set PKB_SESSION_TTL_MS=10000 and restart; open Inspector with Bearer tok_alice, confirm a pkb.search succeeds; wait 11 s; attempt another pkb.search in the same Inspector tab — the POST /messages returns 404 unknown session; logs contain a phase=session_expired line; reconnecting (Inspector re-opens /sse) gets a fresh session id and resumes working.

Task 3 — Per-IP rate limit at Caddy

The L14 rate limit is per tenant. It cannot help against anonymous traffic — a flood with no Authorization header still costs us the 401 round-trip and the per-request log line. Push that defence one layer out to Caddy, where it costs near-zero per request and never reaches Node.

Requirements:

✓ Self-check: for i in {1..50}; do curl -sk -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer tok_alice" https://<your-hostname>/sse; done produces a mix of 200 and 429 after the first ~30 requests within one minute; healthcheck on /healthz stays 200 throughout (proves the limit is path-scoped); Caddy's access log shows the 429 responses with the same remote_ip; tenant=alice never enters the SDK for those rejected requests (the per-tenant counter in rateLimit.ts is untouched).

View solutions →