Lab 14 — Add bearer auth, per-tenant isolation, and rate limit to PKB-TS
Lesson 14 · MCP Servers · ~75 min, code changes committed under mcp-servers/sample-server/typescript/
L13 left PKB-TS reachable from the public internet behind Caddy. Anyone who
knows the URL can open an SSE session and call echo. That is
the right place to stop for a personal demo and the wrong place to
stop for anything else. This lab adds the three orthogonal guarantees that
make a remote MCP server safe to expose: identity (bearer
token → tenant id), isolation (per-tenant note store),
and quota (token-bucket rate limit per tenant). By the end,
two Inspector tabs with two different tokens will see two independent note
stores, and a tight call loop will trip the per-tenant rate limit.
Prereqs
- L13 lab complete.
docker compose upbrings up Caddy + pkb; Inspector can callechoover HTTPS atlocalhost(tls internalpath) or your real domain./healthzreturns{"ok":true,"service":"pkb"}. The four-file TS layout (server.ts,tools.ts,schemas.ts,logger.ts) is insrc/andnpm run buildexits clean. - Inspector with custom-header support.
@modelcontextprotocol/inspectorrecent enough to set theAuthorizationheader on a connection. If your version doesn't have a header field, the troubleshooting section at the bottom shows how to drive the smoke test withcurlinstead. - Working directory. All commands run from
mcp-servers/sample-server/typescript/unless noted.
Step 1 — Write src/auth.ts
Create src/auth.ts. The middleware extracts the bearer token
from the Authorization header, looks it up in a token →
tenant map seeded from PKB_TOKENS, and sets
req.tenant on success or returns 401 on failure:
import type { Request, Response, NextFunction } from "express";
import { toolLogger } from "./logger.js";
const log = toolLogger("auth");
// Token -> tenant id. For a real deployment swap for a DB / JWT / OAuth.
// Format: PKB_TOKENS="alice:tok_alice_xxx,bob:tok_bob_yyy"
function parseTokens(): Map<string, string> {
const raw = process.env.PKB_TOKENS ?? "";
const out = new Map<string, string>();
for (const pair of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
const [tenant, token] = pair.split(":");
if (tenant && token) out.set(token, tenant);
}
return out;
}
const TOKENS = parseTokens();
export interface AuthedRequest extends Request {
tenant?: string;
}
export function bearerAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
const hdr = req.header("authorization") ?? "";
const m = hdr.match(/^Bearer\s+(\S+)$/i);
if (!m) {
log.warn({ phase: "denied", reason: "no_bearer" });
res.status(401).json({ error: "missing bearer token" });
return;
}
const tenant = TOKENS.get(m[1]);
if (!tenant) {
log.warn({ phase: "denied", reason: "unknown_token" });
res.status(401).json({ error: "invalid token" });
return;
}
req.tenant = tenant;
next();
}
Three things worth pausing on:
parseTokens()runs once at module load. The env var is read into aMap<token, tenant>at startup; rotating tokens means restarting the process. That is the simplest model that works; production swaps the in-memory map for an OAuth introspection call or a JWT verifier with key caching.- Every denial is logged with a
reasonfield.no_bearervsunknown_tokenare two different attack shapes; counting them separately gives you an abuse signal in L15's audit pipeline. The token value itself is never logged — only the fact of failure. - The handler returns instead of falling through.
Express middleware must either call
next()or end the response. Forgetting thereturnafterres.status(401).json(...)means execution continues into the SSE handler withreq.tenant === undefined, and you leak an unauthenticated session.
Step 2 — Write src/store.ts
Create src/store.ts. Two levels of Map: tenant id
→ (note id → body):
// Per-tenant in-memory note store. Production swap: SQLite/Postgres keyed by tenant id.
type NoteId = string;
type Notes = Map<NoteId, string>;
const STORES = new Map<string, Notes>();
export function storeFor(tenant: string): Notes {
let s = STORES.get(tenant);
if (!s) {
s = new Map<NoteId, string>([
["hello", `Hello note for ${tenant}`],
]);
STORES.set(tenant, s);
}
return s;
}
export function tenantCount(): number {
return STORES.size;
}
storeFor(tenant) is the only path tool code uses to reach
notes. On first access for a new tenant, a placeholder hello
note is seeded so the search/fetch tools always have something to return
in the smoke test — otherwise an empty bucket is hard to tell apart
from a broken store. The tenantCount() helper is purely for
diagnostics; tools never read it.
Map is in-memory and
dies on restart. For a real deployment, replace storeFor with
a function that opens (or lazily creates) a SQLite file under
/data/<tenant>.db, or executes against a shared Postgres
with a WHERE tenant_id = $1 clause on every query. The
signature stays storeFor(tenant) → { search, fetch, write };
tool code never knows the difference.
Step 3 — Write src/rateLimit.ts
Create src/rateLimit.ts. A token bucket per tenant:
CAPACITY tokens of burst, REFILL_PER_MIN
sustained rate, refill computed lazily on each check():
// Token-bucket rate limit per tenant. Default: 60 req/min refill, 10-req burst.
interface Bucket { tokens: number; lastRefill: number; }
const BUCKETS = new Map<string, Bucket>();
const CAPACITY = Number(process.env.PKB_RL_CAPACITY ?? 10);
const REFILL_PER_MIN = Number(process.env.PKB_RL_REFILL_PER_MIN ?? 60);
export interface RateLimitResult {
allowed: boolean;
remaining: number;
retryAfterMs: number;
}
export function check(tenant: string): RateLimitResult {
const now = Date.now();
let b = BUCKETS.get(tenant);
if (!b) { b = { tokens: CAPACITY, lastRefill: now }; BUCKETS.set(tenant, b); }
// Refill since last check, capped at CAPACITY.
const elapsedMin = (now - b.lastRefill) / 60_000;
b.tokens = Math.min(CAPACITY, b.tokens + elapsedMin * REFILL_PER_MIN);
b.lastRefill = now;
if (b.tokens >= 1) {
b.tokens -= 1;
return { allowed: true, remaining: Math.floor(b.tokens), retryAfterMs: 0 };
}
const retryAfterMs = Math.ceil(((1 - b.tokens) / REFILL_PER_MIN) * 60_000);
return { allowed: false, remaining: 0, retryAfterMs };
}
How the math falls out:
- Defaults: 10-token burst, 60 / minute sustained.
A fresh tenant can make 10 calls right now without waiting. Sustained
above 1 / second and the bucket runs dry; each subsequent call returns
a non-zero
retryAfterMsuntil enough time has passed to refill at least one token. - Lazy refill, not a background timer. No
setInterval, no per-tenant ticker. Refill is computed on eachcheck()fromelapsed * rate. This scales to thousands of tenants without thousands of timers. - Configurable via env.
PKB_RL_CAPACITY/PKB_RL_REFILL_PER_MINlet you crank the burst and rate up for production or down for the lab where you want to see the limit trip in a few curls.
Step 4 — Add the PKB tools to src/tools.ts
Append three tenant-scoped tools to src/tools.ts. Each takes
(tenant: string, rawArgs: unknown) — the tenant is the
function's first argument, not a Zod-validated field on
rawArgs:
import { storeFor } from "./store.js";
import { z } from "zod";
const SearchInput = z.object({ query: z.string().min(1).max(1000), limit: z.number().int().min(1).max(50).default(10) });
const FetchInput = z.object({ id: z.string().min(1).max(200) });
const WriteInput = z.object({ id: z.string().min(1).max(200), body: z.string().min(1).max(50_000) });
export async function pkbSearch(tenant: string, rawArgs: unknown): Promise<string[]> {
const log = toolLogger("pkb.search").child({ tenant });
const t0 = performance.now();
log.info({ phase: "start" });
try {
const args = SearchInput.parse(rawArgs);
const store = storeFor(tenant);
const out: string[] = [];
for (const [id, body] of store.entries()) {
if (out.length >= args.limit) break;
if (id.includes(args.query) || body.includes(args.query)) out.push(id);
}
log.info({ phase: "ok", dur_ms: +(performance.now() - t0).toFixed(1), n: out.length });
return out;
} catch (err) {
log.warn({ phase: "err", dur_ms: +(performance.now() - t0).toFixed(1), type: (err as Error).name });
throw err;
}
}
export async function pkbFetch(tenant: string, rawArgs: unknown): Promise<string> {
const log = toolLogger("pkb.fetch").child({ tenant });
const t0 = performance.now();
log.info({ phase: "start" });
try {
const args = FetchInput.parse(rawArgs);
const store = storeFor(tenant);
const body = store.get(args.id);
if (body === undefined) {
log.warn({ phase: "err", dur_ms: +(performance.now() - t0).toFixed(1), type: "UnknownId" });
throw new Error(`unknown note id: ${args.id}`);
}
log.info({ phase: "ok", dur_ms: +(performance.now() - t0).toFixed(1), body_len: body.length });
return body;
} catch (err) {
if (!(err as Error).message?.startsWith("unknown note id")) {
log.warn({ phase: "err", dur_ms: +(performance.now() - t0).toFixed(1), type: (err as Error).name });
}
throw err;
}
}
export async function pkbWrite(tenant: string, rawArgs: unknown): Promise<string> {
const log = toolLogger("pkb.write").child({ tenant });
const t0 = performance.now();
log.info({ phase: "start" });
try {
const args = WriteInput.parse(rawArgs);
const store = storeFor(tenant);
store.set(args.id, args.body);
log.info({ phase: "ok", dur_ms: +(performance.now() - t0).toFixed(1), body_len: args.body.length });
return `saved: ${args.id}`;
} catch (err) {
log.warn({ phase: "err", dur_ms: +(performance.now() - t0).toFixed(1), type: (err as Error).name });
throw err;
}
}
tenant field. Tool args
carry note ids, query strings, and bodies — they do not carry
identity. The tenant is the function's first arg, sourced from the
authenticated session by the caller (the CallTool handler
in server.ts), and never from rawArgs. If you
ever feel tempted to add tenant to SearchInput
"because it's convenient", that is the failure mode. A caller would
pass {"query":"x","tenant":"victim"} and read someone
else's notes.
Step 5 — Wire it all in src/server.ts
Open src/server.ts. At the top, import the new modules and
the three new tool handlers:
import { bearerAuth, type AuthedRequest } from "./auth.js";
import { check as rlCheck } from "./rateLimit.js";
import { echoTool, pkbSearch, pkbFetch, pkbWrite } from "./tools.js";
Replace the CallTool handler with one that resolves the tenant
from the session, checks the rate limit, and dispatches to the right tool:
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
const tenant = (extra as any)?.tenant ?? "anonymous";
const rl = rlCheck(tenant);
if (!rl.allowed) {
return {
isError: true,
content: [{ type: "text", text: `rate limit exceeded; retry after ${rl.retryAfterMs} ms` }],
};
}
const { name, arguments: args } = req.params;
try {
if (name === "echo") return { content: [{ type: "text", text: await echoTool(args) }] };
if (name === "pkb.search") {
const ids = await pkbSearch(tenant, args);
return { content: [{ type: "text", text: JSON.stringify(ids) }] };
}
if (name === "pkb.fetch") return { content: [{ type: "text", text: await pkbFetch(tenant, args) }] };
if (name === "pkb.write") return { content: [{ type: "text", text: await pkbWrite(tenant, args) }] };
return { isError: true, content: [{ type: "text", text: `unknown tool: ${name}` }] };
} catch (err) {
return { isError: true, content: [{ type: "text", text: (err as Error).message }] };
}
});
Then replace the single-session SSE block in the http branch
of main() with the per-tenant session map plus middleware:
if (mode === "http") {
const app = express();
const port = Number(process.env.PORT ?? 8080);
const sessions = new Map<string, { transport: SSEServerTransport; tenant: string }>();
app.get("/healthz", (_req, res) => res.json({ ok: true, service: "pkb" }));
app.get("/sse", bearerAuth, async (req: AuthedRequest, res) => {
const tenant = req.tenant!;
const transport = new SSEServerTransport("/messages", res);
sessions.set(transport.sessionId, { transport, tenant });
(server as any).tenant = tenant; // simple plumbing for the handler closure
res.on("close", () => sessions.delete(transport.sessionId));
await server.connect(transport);
logger.info({ phase: "session_open", tenant, session_id: transport.sessionId });
});
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; }
// Inject the tenant into the request handler context.
(req as any).tenant = sess.tenant;
await sess.transport.handlePostMessage(req, res, req.body);
});
app.listen(port, () => logger.info({ phase: "ready", transport: "sse", port }));
return;
}
Finally, extend the ListTools handler so the new tools show
up in Inspector. Add three entries alongside echo:
pkb.search (input: query string +
limit int), pkb.fetch (input: id
string), and pkb.write (input: id string +
body string). The schemas mirror the Zod definitions from
Step 4 — no tenant field anywhere.
(server as any).tenant = tenant closure trick and the
(extra as any)?.tenant read in CallTool are a
deliberate simplification for the lab — they work because the SDK
serialises calls per session, so the field doesn't race between tenants
during a single request. For a production deployment with concurrent
tenants per process, factor a per-session Server instance
keyed by tenant id, or use the SDK's session-context API once that's
generally available. The lab's job is to make the three guarantees
explicit; the structural fix lives in L15's hardening pass.
Step 6 — Rebuild and start with two tenants
Compile and start the server with Alice and Bob in the token map:
npm run build
PKB_TRANSPORT=http \
PKB_TOKENS="alice:tok_alice,bob:tok_bob" \
node dist/server.js &
sleep 1
Confirm /healthz still answers — it is unauthenticated
on purpose so load balancers can probe without a token:
curl -s localhost:8080/healthz
# {"ok":true,"service":"pkb"}
Step 7 — Verify auth: anonymous is rejected
Hit /sse with no Authorization header:
curl -i localhost:8080/sse
Expect a clean HTTP/1.1 401 with body
{"error":"missing bearer token"}. In the server logs you
should see one line per attempt:
{"level":"warn","tool":"auth","phase":"denied","reason":"no_bearer"}
Now try a bogus token:
curl -i -H "Authorization: Bearer not-a-real-token" localhost:8080/sse
Still 401, but the log line now reads
"reason":"unknown_token". The two reasons being separable is
the abuse-signal hook L15's audit pipeline reads.
Step 8 — Verify isolation: Alice and Bob get separate notes
Open Inspector in two tabs (or two browser windows), each
pointed at http://localhost:8080/sse with a different
bearer header. In recent Inspector versions there is an
Authorization field next to the URL; older versions require
the curl-based workaround in the troubleshooting section.
- Tab 1 — header:
Authorization: Bearer tok_alice. - Tab 2 — header:
Authorization: Bearer tok_bob.
In Alice's tab, call pkb.write with arguments:
{ "id": "secret", "body": "alice-secret" }
Then call pkb.fetch with {"id":"secret"} —
you should see "alice-secret" come back. Switch to Bob's
tab and call pkb.fetch with the same args. You should get
an isError result with text
unknown note id: secret — Bob's store is empty for
that id. Now call pkb.write in Bob's tab with
{"id":"secret","body":"bob-secret"} and
pkb.fetch it — you get "bob-secret",
never Alice's value. Switch back to Alice and re-fetch — still
"alice-secret". Cross-tenant reads cannot happen because
the tenant id flows from the authenticated session, not from the tool
args.
In the server logs, every tool-call line is tagged with the right tenant:
{"level":"info","tool":"pkb.write","tenant":"alice","phase":"ok","dur_ms":0.3,"body_len":12}
{"level":"info","tool":"pkb.fetch","tenant":"bob","phase":"start"}
{"level":"warn","tool":"pkb.fetch","tenant":"bob","phase":"err","dur_ms":0.2,"type":"UnknownId"}
Step 9 — Verify rate limit
With Alice's session still open in Inspector, fire
pkb.search as fast as the UI lets you. After ~10 calls in a
second you should see an isError: true content with text
rate limit exceeded; retry after <N> ms. To make this
reliable from a shell, run a small concurrent burst against
/messages:
# Replace SID with the sessionId Inspector negotiated (see browser devtools or the URL)
SID="..."
seq 1 50 | xargs -P 20 -I{} curl -s -o /dev/null -w "%{http_code} " \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":{},"method":"tools/call","params":{"name":"pkb.search","arguments":{"query":"hello"}}}' \
"http://localhost:8080/messages?sessionId=$SID"
echo
The first ten or so return 200; the rest still return 200 at the HTTP
layer (the rate-limit denial is an MCP-level isError, not
an HTTP 429) but the response body now contains the rate-limit text. In
the logs you'll see successful calls flip to denials:
{"level":"info","tool":"pkb.search","tenant":"alice","phase":"ok","dur_ms":0.1,"n":1}
... (10 of these)
{"level":"info","tool":"pkb.search","tenant":"alice","phase":"start"}
# (no matching phase=ok because CallTool short-circuited to isError before pkbSearch ran)
Wait one second — the bucket refills at one token per second
— and a single follow-up call succeeds again. If you crank
PKB_RL_CAPACITY=2 and restart, the limit trips on the
third call instead of the eleventh; useful when you want to see the
behaviour in two curls.
Step 10 — Tear down
Stop the local server:
kill %1
# or: pkill -f "node dist/server.js"
If you want to test the full container path next, rebuild the image with the new code and start it with the same env vars:
docker build -t pkb-ts:l14 .
docker run --rm -p 8080:8080 \
-e PKB_TRANSPORT=http \
-e PKB_TOKENS="alice:tok_alice,bob:tok_bob" \
-d --name pkb-l14 pkb-ts:l14
sleep 2
curl -s localhost:8080/healthz
curl -i localhost:8080/sse # 401
curl -i -H "Authorization: Bearer tok_alice" localhost:8080/sse # 200 SSE stream
docker stop pkb-l14
Same three checks (healthz works, anonymous is 401, Alice's token flips it to 200) reproduced inside the L13 container.
Step 11 — Audit checklist
Before calling L14 done, confirm
- No tool schema contains a
tenantfield.grep -E "tenant" src/schemas.ts src/tools.ts— the only hits in tool code should be the function parametertenant: stringand thestoreFor(tenant)call, never inside a Zod object. - The tenant logged on every tool call comes from the
session. The
toolLogger(...).child({ tenant })pattern derivestenantfrom the function arg, which came from the session lookup in/messages, which came frombearerAuthon/sse. The chain has no read fromrawArgsanywhere. - 401 denials are logged with a
reasonfield; no token value is ever logged.grepthe log stream for the literal token stringtok_alice— zero hits. The fact of auth (success or denial) shows up; the secret material does not. - Rate-limit denials return
isError: true, not a thrown exception. A thrown error would bubble out to the SSE transport and could kill the session; the MCP-levelisErrorresult lets the client keep the connection open and retry after the hint. - The session map is cleared on connection close.
res.on("close", () => sessions.delete(transport.sessionId))runs whenever the SSE stream tears down, normally or otherwise. Open and close an Inspector tab a few times, then add a one-offlogger.info({ phase: "sweep", sessions: sessions.size })after the delete — the count must return to the pre-tab baseline. /healthzstill answers without a token. Load-balancer probes and the L13 healthcheck must not require auth; otherwise compose health probing breaks.
What you should leave with
- A PKB-TS server that requires a bearer token on
/sse, with denials logged by reason. Anonymous and bad tokens both return 401 with distinct log lines. - Per-tenant note isolation verified across two Inspector tabs with two tokens. Cross-tenant reads are impossible because the tenant is sourced from the authenticated session, not the tool args.
- A token-bucket rate limiter that returns
isErrorwith aretryAfterMshint when a single tenant bursts past 10 calls/second. - Three new tools advertised to clients
(
pkb.search,pkb.fetch,pkb.write) whose schemas carry note ids and bodies but never carry identity. - Four small, focused new files
(
src/auth.ts,src/store.ts,src/rateLimit.ts, and the bottom half ofsrc/tools.ts) that you can read end-to-end in under five minutes — the production version of each will be 3–10x larger, but the shape stays the same.
Troubleshooting
Common stumbles
- Inspector doesn't send the
Authorizationheader. Inspector versions differ; if yours lacks the header field, do the smoke test in two layers. First, prove the auth flow with curl:curl -i localhost:8080/sse(expect 401),curl -i -H "Authorization: Bearer tok_alice" localhost:8080/sse(expect a 200 SSE stream that hangs — that's the stream staying open;Ctrl-Cout). Then drive the tool calls from a 20-line Node client that uses the SDK'sClientwith a customSSEClientTransportconfigured with the header. The SDK's own examples carry this pattern. - Both Inspector tabs see the same notes.
storeForis being called with the same tenant in both paths — either your token map has the same value for both keys, or the tenant injection in/messagesisn't reachingCallTool. Addlog.info({ phase: "tenant_check", tenant })at the top ofCallTooland verify the values differ between tabs. If they're identical, the(req as any).tenant = sess.tenantline is wrong or the closure trick is racing — this is exactly the simplification flagged in the callout under Step 5. - Rate limit never triggers. Your loop is too
slow. A shell
forloop withcurlruns sequentially at ~5 req/s and the bucket refills faster than that. Usexargs -P 20(parallel) as in Step 9, or write a tiny Node script that opens one session and fires 50 concurrent tool calls. Alternatively, dropPKB_RL_CAPACITY=2so the limit trips on the third call. req.tenantisundefinedinsideCallTool. Theextraparameter insetRequestHandlerdoesn't automatically carry arbitrary fields from the HTTP layer — the lab uses a closure trick ((server as any).tenant = tenant) that works under the SDK's per-session serialisation but doesn't scale to concurrent tenants in one process. For production, factor a per-sessionServerinstance keyed by tenant id; L15 revisits this in the hardening pass.- "Module not found:
./auth.js". ESM import discipline strikes again. Local imports under ESM always end in.jseven when the source file isauth.ts;tscrewrites the extension at compile but expects it in the source. Runnpm run buildand re-check the import lines inserver.ts. - 401 on every call even with a correct token.
PKB_TOKENSwasn't passed to the process. Verify withps -ef | grep node— the env vars are visible on the line. The"alice:tok_alice,bob:tok_bob"format is strict: comma separator, single colon per pair, no whitespace inside a pair. A typo silently leaves the map empty and every token isunknown_token. - Server logs the literal
Authorizationheader. Stop. Something added a request-logging middleware (likelymorganorexpress.json()with verbose options). Remove it. The L12 log discipline (lengths and types, never values) carries over — only the synthetic{phase:"denied", reason:"..."}line should mention auth at all, and it never includes the token.