Companion code for the karnstack course MCP from Zero. It builds one
Model Context Protocol server, helpdesk, for a small company that sells
booking software to gyms and yoga studios. Each lesson's end state is a git
tag (lesson-02 to lesson-10); check out the tag for the lesson you are on.
The code follows MCP revision 2026-07-28.
git checkout lesson-10
npm ci
npm run check # typecheck, tests, and wire validationRequires Node.js 24.14.1 or newer. Node runs the TypeScript files directly (type stripping), so there is no build step.
| Tag | Lesson | Adds |
|---|---|---|
lesson-02 |
JSON-RPC on the wire | hand-written stdio server: server/discover, errors, initialize for older clients |
lesson-03 |
Tools: list, call, and the model in between | tools/list and tools/call for get_ticket, tool errors vs protocol errors, wire/tap.ts |
lesson-04 |
Tools cost context | tools/tool-cost.ts, measured public tool lists, the six-tool design with an 800 token budget |
lesson-05 |
From hand-rolled to the SDK | the server rewritten on @modelcontextprotocol/server with the three read tools, SQLite data, stdio entry |
lesson-06 |
Resources and prompts | the refund policy and ticket resources, the triage_ticket prompt, cursor pagination in search_tickets |
lesson-07 |
Writes, confirmation and long calls | add_internal_note, issue_refund (confirmation, signed requestState, idempotency key), export_tickets (progress, cancellation, export handle) |
lesson-08 |
Going remote with Streamable HTTP | src/worker.ts on Cloudflare Workers, data in a SQLite Durable Object, read-only deploy, conformance suite |
lesson-09 |
Who is calling: OAuth for MCP | our own small authorization server in the Worker (CIMD, PKCE, audience-bound tokens, step-up to refunds:write), the bearer gate, write tools for signed-in staff |
lesson-10 |
When the data fights back | ticket 2331, the 30-day refund rule, random user-bound export handles, attacks/ demos |
Ticket 2331 is now in the helpdesk (attacks/ticket-2331.ts, loaded by
data/seed.ts): a bug report from customer cus_2977 whose text tells
any AI assistant to refund all twelve of the customer's $99 charges, and
any coding assistant to run a script. The server returns it as written;
that is correct. Two rules changed so the damage is bounded whatever the
model does:
- The refund limit counts the customer, not the call. In
src/tools/issue_refund.ts,checkRefundLimit()asksdb.refundTotalCents(customerId, since)for the customer's refunds in the last 30 days. If this refund would take them above $100, it needsrefunds:write(only Dana's account), and over HTTP the answer is403 insufficient_scopewitherror_description="Refunds above $100 per customer in 30 days need refunds:write". Twelve refunds of $99 now stop after the first. A retry of a refund that already happened is not counted again. - Export handles are random and bound to the user.
export_ticketsreturnsexp_plus 32 random hex digits, and stores the file as<user_id>:<handle>. The same handle read by anyone else is "Resource not found".
The attacks/ folder has runnable demos (see attacks/README.md): the
naive agent with approvals off, a poisoned and rug-pulling server, a
pinning defence, and the audience and Origin checks tried from outside.
npm run attack runs the naive agent.
Deploying and cleaning up work as in lesson 9 below. If you deployed
lesson 9, deploy again to get these rules, and when you are done, run the
same cleanup: remove the connector, rotate TOKEN_SIGNING_KEY, and
wrangler delete, which also deletes both Durable Object namespaces.
The Worker now holds two separate parties on separate paths:
- the MCP server, an OAuth resource server: it only checks tokens;
- a small teaching authorization server (
src/auth-server/): it signs people in and issues tokens.
| Path | Who answers | What it is |
|---|---|---|
POST /mcp |
MCP server | needs Authorization: Bearer <token>; without one, 401 with WWW-Authenticate: Bearer ... resource_metadata="<origin>/.well-known/oauth-protected-resource/mcp" |
GET /.well-known/oauth-protected-resource/mcp |
MCP server (the SDK's oauthMetadataResponse) |
protected resource metadata (RFC 9728): the resource and its authorization server |
GET /.well-known/oauth-authorization-server |
authorization server metadata (RFC 8414) | endpoints, PKCE S256, client_id_metadata_document_supported, token_endpoint_auth_methods_supported: ["none"], authorization_response_iss_parameter_supported |
GET, POST /authorize |
authorization server | fetches the client's Client ID Metadata Document, shows a sign-in and consent page, redirects back with a one-time code and iss |
POST /token |
authorization server | checks the code, client, redirect URI, resource and PKCE verifier, and returns a one-hour access token |
| File | What it does |
|---|---|
src/worker.ts |
routing, Host and Origin checks, the metadata documents, and the bearer gate (requireBearerAuth with expectedResource); verifyAccessToken() turns our JWT into the SDK's AuthInfo |
src/auth-server/metadata.ts |
authServerMetadata(origin) and SCOPES |
src/auth-server/cimd.ts |
fetchClientMetadata(clientId) (HTTPS only, no IP addresses, no redirects, 5 KB, 5 s) and redirectAllowed() (exact, or any port on loopback) |
src/auth-server/authorize.ts |
authorize(): the consent page and the code |
src/auth-server/token.ts |
token(): code for token |
src/auth-server/tokens.ts |
signAccessToken() and readAccessToken(): HS256 JWTs with iss, sub, aud, scope, client_id, exp |
src/auth-server/accounts.ts |
the two teaching accounts, Mei and Dana |
src/auth-server/store.ts |
AuthStore, a SQLite Durable Object; takeCode() makes a code single-use |
src/step-up.ts |
StepUpRequired and stepUpChallenge(): a tool's rule becomes a 403 insufficient_scope |
src/caller.ts |
callerOf(ctx): the user and scopes, from the token over HTTP |
Scopes: tickets:read, tickets:write (notes, and refunds up to $100),
refunds:write (refunds over $100). Mei may have the first two; Dana all
three. A client first asks for what the protected resource metadata lists,
tickets:read tickets:write. The tool list depends on the token: without
tickets:write, only the four read tools, so tools/list is cached
private: one list per token, never shared between callers. A refund
over $100 without refunds:write gets 403 with
scope="refunds:write" before the tool runs; the client signs in again
asking for it, and only Dana's sign-in grants it. The rule is in
src/tools/issue_refund.ts and holds over stdio too (as a tool error).
There are no refresh tokens: when the hour is up, the client signs in again. The billing provider is called with the helpdesk's own key, never with the user's token.
New seeded data for the step-up: ticket 2327 from Granite Peak Climbing
(cus_1310), an annual renewal of $1,188 (ch_8790) charged after they
cancelled.
scripts/oauth-flow.ts is a scripted client that does what Claude Code
does, using Claude Code's real Client ID Metadata Document
(https://claude.ai/oauth/claude-code-client-metadata) as its client_id:
node scripts/with-worker.ts node scripts/oauth-flow.ts /tmp/flowscripts/with-worker.ts starts wrangler dev with fresh secrets for that
run. To run the Worker yourself (npm run dev), copy .dev.vars.example
to .dev.vars and fill it in.
transcripts/lesson-09/ has the recordings: 01-mei-first-connection
(eight round trips from 401 to the tool list), 02-refund-step-up
(Mei refused, Dana steps up, confirms, refund re_5531) and
03-refused-tokens (wrong audience, expired, wrong key).
npx wrangler login
npx wrangler deploy # first deploy, to learn your hostname
# put "helpdesk.<your-subdomain>.workers.dev" in ALLOWED_HOSTS in wrangler.jsonc
npx wrangler secret put TOKEN_SIGNING_KEY # for example: openssl rand -base64 32
npx wrangler secret put REQUEST_STATE_KEY # at least 32 bytes
npx wrangler secret put MEI_PASSWORD
npx wrangler secret put DANA_PASSWORD
npx wrangler secret put BILLING_API_KEY # any value; the billing provider is a stand-in
npx wrangler deployThe global_fetch_strictly_public compatibility flag in wrangler.jsonc
keeps the Client ID Metadata Document fetch on the public internet.
On a deployed copy (2026-10-15), each MCP request used 8 to 81 ms of CPU, most of it building the SDK server for that request, and the first Durable Object call (the seed) 358 ms. The Workers Free plan allows 10 ms of CPU per request, with some flexibility for infrequent overruns, so on Free expect some requests to fail with Cloudflare Error 1102 ("Worker exceeded resource limits"). Workers Paid ($5 a month minimum) allows 30 seconds by default. See https://developers.cloudflare.com/workers/platform/limits/#cpu-time.
Connect:
- Claude Code:
claude mcp add --transport http helpdesk https://helpdesk.<your-subdomain>.workers.dev/mcp, then/mcpto sign in. Claude Code uses its Client ID Metadata Document and a loopback redirect. - claude.ai: Customize, Connectors, Add, Add custom connector, the
same URL, then Continue. On the second step claude.ai marks Sign in
now and "Use Claude's published identity" as Detected; keep both,
choose Add, then Connect. claude.ai uses Client ID Metadata
Documents only when the metadata advertises both
client_id_metadata_document_supported: trueandnoneintoken_endpoint_auth_methods_supported, which ours does. Its redirect ishttps://claude.ai/api/mcp/auth_callback.
claude mcp remove helpdesk # Claude Code
# claude.ai: Customize, Connectors, helpdesk, ⋮, Remove
npx wrangler secret put TOKEN_SIGNING_KEY # a new value: every token issued so far stops working
npx wrangler delete # deletes the Worker
npx wrangler logout # optionalwrangler delete also deletes the HelpdeskStore and AuthStore
Durable Object namespaces and their data; the dashboard's Durable Objects
page should list neither. This tag uses no KV namespaces. Secrets belong to
the Worker and go with it.
npm run test:worker signs Mei and Dana in through the real flow and
checks the 401, the metadata, the token audience (a token for another
service is refused), the step-up, and the authorization server's refusals.
npm run conformance runs the official suite through a small proxy that
adds a test token (scripts/conformance.ts), since the suite cannot sign
in.
The same server now also runs on Cloudflare Workers, over Streamable HTTP:
one endpoint, POST /mcp, one HTTP request per message. Nothing about the
tools changed. Because it has no authorization yet, the deployed server is
read-only: it registers only search_tickets, get_ticket,
get_customer and export_tickets, and serves only the seeded, invented
data.
| File | What it does |
|---|---|
src/worker.ts |
the Worker: route /mcp only, check Host and Origin (DNS rebinding), then the SDK's createMcpHandler, which builds a fresh server per request |
src/db-durable.ts |
HelpdeskStore, a SQLite-backed Durable Object holding the helpdesk database (seeded on first use), and durableSql(), the same Sql interface over RPC |
wrangler.jsonc |
Worker name helpdesk, the Durable Object binding HELPDESK, and the vars ALLOWED_HOSTS, ALLOWED_ORIGINS, HELPDESK_MODE |
src/env.d.ts, worker-configuration.d.ts |
types for the Worker's bindings (npm run types regenerates the second) |
conformance/baseline.yml |
the official conformance scenarios this server is expected to fail, and why |
(From lesson 9 the endpoint needs a token, so this request now gets 401.)
npm run dev # wrangler dev: http://localhost:8787/mcp
curl -s http://localhost:8787/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"},"io.modelcontextprotocol/clientCapabilities":{}}}}'transcripts/lesson-08/*.http.md has every request and response from such
a session: server/discover, a tool call, a progress stream (SSE), 202
for a notification, the -32020, -32022 and -32602 errors, 405 for
GET, 403 for a foreign Origin or Host, and an older client's
initialize with no session.
npm run check now also starts the Worker locally (wrangler dev, fresh
state) for npm run test:worker (the official client against the Worker,
both protocol eras, the Host, Origin, 405, -32020, -32602 and 202
cases) and for npm run conformance, the official suite
(@modelcontextprotocol/conformance 0.2.0-alpha.12, --requirements 2026-07-28). Most of its scenarios call test fixtures an SDK registers,
which the helpdesk does not have; conformance/baseline.yml lists them.
Every message the server sent passed the suite's wire schema check.
The server now has all six tools from the lesson 4 design.
add_internal_note (ticket_id, body) adds a note only staff see.
Annotations: not read-only, not destructive, not idempotent (each call adds
a note).
issue_refund (ticket_id, charge_id, amount_cents) refunds a
charge through the billing provider (src/billing.ts, a stand-in called
with the helpdesk's own API key). In order:
- If a refund with the idempotency key
refund-<ticket>-<charge>exists, it returns that same refund. A duplicate call pays nothing. - It checks the request against the database: the charge must belong to the ticket's customer, and the amount must fit what is left of the charge.
- It asks the user. The first call returns
resultType: "input_required"with a form ("Refund $49.00 to Blue Heron Yoga for ticket 2318?") and arequestState. The client shows the form and calls again with a new id, the answer ininputResponses, and therequestStateechoed back. - On a "yes", it calls the billing provider and records the refund:
re_5530for ticket 2318. That id is kept for ticket 2318; every other refund counts up fromre_5531.
The requestState is signed with HMAC (the SDK's
createRequestStateCodec), expires after 10 minutes, and is bound to the
method and the user. A forged one is rejected with -32602. A client that
cannot show a form gets -32021 (missing client capability) and no
refund. Older clients get the same question as a real elicitation/create
request; the SDK does that for us.
export_tickets (status) writes matching tickets to a CSV file,
500 per batch, slowly on purpose (EXPORT_DELAY_MS, 250 ms a batch). If
the request carries a progressToken it sends notifications/progress
after each batch. If the client cancels, it stops. It returns an export id
(exp_1), an explicit handle; read the file at helpdesk://exports/exp_1.
Environment variables for src/stdio.ts: BILLING_API_KEY (default: a
test key), HELPDESK_STATE_KEY (at least 32 bytes; default: random per
process), EXPORT_DELAY_MS, HELPDESK_USER, HELPDESK_DB.
The live tool list now measures 717 tokens, plus 49 for the instructions: 766 of the 800 budget.
Added in lesson 6:
- Resources (
src/resources.ts,registerResources(server, db)):helpdesk://policies/refunds, the refund policy as Markdown;- the template
helpdesk://tickets/{ticket_id}, one ticket as JSON.resources/listlists the 180 open tickets; any ticket can be read. An unknown ticket is error-32602("Resource not found").
- A prompt (
src/prompts.ts,registerPrompts(server, db)):triage_ticketwith one argument,ticket_id. It embeds the ticket and the policy and asks for a triage. In Claude Code:/mcp__helpdesk__triage_ticket 2318. - A resource link:
get_customerresults end with aresource_linkto the refund policy. The host decides whether to read it. - Cursor pagination in
search_tickets: 50 tickets per page and an opaquenext_cursorto pass back ascursor. All 12,400 tickets take 248 pages. A cursor only works for the search that made it. - Cache hints on the list results and on each resource (
ttlMs,cacheScope).
The SDK answers subscriptions/listen by itself; see
transcripts/lesson-06/subscriptions-listen.md.
Try it in Claude Code with @helpdesk:helpdesk://tickets/2318 (attach a
ticket) or /mcp__helpdesk__triage_ticket 2318 (run the prompt).
The helpdesk server runs on the official TypeScript SDK v2,
@modelcontextprotocol/server 2.3.1. The data is a deterministic SQLite
database (Node's built-in node:sqlite): 3,100 customers and 12,400
tickets, 180 of them open, created on first run.
npm start # node src/stdio.ts: the server on stdio
npm run seed # recreate data/helpdesk.db from scratchNode prints an ExperimentalWarning for node:sqlite on stderr. It is
harmless; node --disable-warning=ExperimentalWarning src/stdio.ts hides it.
| File | What it does |
|---|---|
src/server.ts |
createServer(db): a factory that builds an McpServer with the instructions, cache hints and tools |
src/tools/<tool_name>.ts |
one file per tool, each exporting register<ToolName>(server, db) |
src/result.ts |
toolResult(data) (text + structuredContent) and toolError(text) |
src/db.ts |
the Helpdesk queries over a small Sql interface, and the schema |
src/db-node.ts |
openNodeDb(): the Sql interface on node:sqlite, seeding on first use |
src/stdio.ts |
serveStdio(() => createServer(db)) |
src/resources.ts |
the refund policy resource and the ticket template (lesson 6) |
src/prompts.ts |
the triage_ticket prompt (lesson 6) |
src/billing.ts |
a stand-in for the billing provider's refund API (lesson 7) |
src/caller.ts |
callerOf(ctx): who is calling (lesson 7: the local user) |
data/seed.ts |
the deterministic seed: ticket 2318 from Blue Heron Yoga (cus_1042, charges ch_8811 and ch_8812), ticket 2331 from cus_2977 |
The SDK server is dual-era: it answers server/discover from current
clients and initialize from older ones, with no code of ours.
The Inspector speaks the older protocol unless you ask, so pass
--protocol-era auto:
npx @modelcontextprotocol/inspector@2.11.0 --protocol-era auto node src/stdio.ts # web UI
npx @modelcontextprotocol/inspector@2.11.0 --cli --protocol-era auto node src/stdio.ts --method tools/list
npx @modelcontextprotocol/inspector@2.11.0 --cli --protocol-era auto node src/stdio.ts \
--method tools/call --tool-name get_ticket --tool-arg 'ticket_id="2318"'Quote string arguments as JSON ('ticket_id="2318"'). The CLI parses
ticket_id=2318 as the number 2318, and the schema rejects it.
This folder's .mcp.json registers the server at project scope: open the
folder in Claude Code and approve it. Or add it yourself:
claude mcp add helpdesk -- node "$PWD/src/stdio.ts"Claude Code names the tools mcp__helpdesk__get_ticket and so on. For VS
Code, use the .vscode/mcp.json shown for lesson 3 with
src/stdio.ts in place of handwritten/server.ts.
npm run check adds the SDK tests (test/tools.test.ts runs the official
client against the server in-process) and measures the live server's tool
list against the 800 token budget (npm run budget). The SDK adds a
$schema key to every input schema, about 17 tokens per tool, so the live
list costs more than the lesson 4 design.
The hand-written server and the lesson 2 to 4 files stay as reference;
src/ does not import them.
| Path | What it is |
|---|---|
handwritten/server.ts |
the hand-written stdio server |
wire/*.jsonl |
the messages typed by hand in the lessons, one per line |
wire/validate.ts |
checks every message in wire/ and transcripts/ against the official schema |
wire/schema/ |
the spec's JSON Schema for 2026-07-28 and 2025-11-25, copied unchanged |
scripts/capture.ts |
replays message files into a stdio server and records the real session |
scripts/render.ts |
turns a recorded .jsonl session into a readable .md |
wire/tap.ts |
a logging pipe between a real client and a stdio server |
tools/tool-cost.ts |
counts the tokens a server's tool list costs |
tools/measured/ |
saved tool lists of public servers |
tools/helpdesk-design.json |
the six-tool design for our server |
transcripts/lesson-NN/ |
recorded sessions, as .jsonl (raw lines) and .md (readable) |
src/ |
the SDK server (lesson 5 on) |
data/seed.ts |
the deterministic helpdesk data |
attacks/ |
lesson 10's attack and defence demos |
test/ |
tests, run with node --test |
Files named *.invalid.jsonl hold deliberate mistakes and must fail
validation. Files named *.broken.jsonl record a broken session on purpose.
| Package | Version |
|---|---|
| typescript | 6.0.3 |
| @types/node | 24.19.1 |
| ajv | 8.20.0 |
| ajv-formats | 3.0.1 |
| js-tiktoken | 1.0.21 |
| @modelcontextprotocol/server | 2.3.1 |
| @modelcontextprotocol/client | 2.3.1 (tests) |
| zod | 4.6.5 |
| wrangler | 4.146.0 |
| @modelcontextprotocol/conformance | 0.2.0-alpha.12 |
The schema files come from
modelcontextprotocol/modelcontextprotocol
at commit 86f6e0c675afea9a8f233e09b6faf566b126086c (schema/2026-07-28/schema.json
and schema/2025-11-25/schema.json), under that project's license.
MIT, see LICENSE. The files in wire/schema/ are copied from the MCP
specification repository and keep that repository's license.