Skip to content

About

Companion code for MCP from Zero on karnstack: an MCP server built from raw JSON-RPC to a remote, authorized Worker, one tag per lesson.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mcp-from-zero

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 validation

Requires Node.js 24.14.1 or newer. Node runs the TypeScript files directly (type stripping), so there is no build step.

What each tag adds

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

This tag: lesson-10, when the data fights back

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() asks db.refundTotalCents(customerId, since) for the customer's refunds in the last 30 days. If this refund would take them above $100, it needs refunds:write (only Dana's account), and over HTTP the answer is 403 insufficient_scope with error_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_tickets returns exp_ 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.

OAuth for MCP (from lesson 9)

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.

The whole flow, recorded

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/flow

scripts/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).

Deploy your own copy

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 deploy

The 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 /mcp to 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: true and none in token_endpoint_auth_methods_supported, which ours does. Its redirect is https://claude.ai/api/mcp/auth_callback.

Clean up when you are done

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                        # optional

wrangler 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.

Checks

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.

Streamable HTTP on Workers (from lesson 8)

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

Run it locally

(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.

Checks (from lesson 8)

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.

Writes, confirmation and long calls (from lesson 7)

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:

  1. If a refund with the idempotency key refund-<ticket>-<charge> exists, it returns that same refund. A duplicate call pays nothing.
  2. 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.
  3. 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 a requestState. The client shows the form and calls again with a new id, the answer in inputResponses, and the requestState echoed back.
  4. On a "yes", it calls the billing provider and records the refund: re_5530 for ticket 2318. That id is kept for ticket 2318; every other refund counts up from re_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.

Resources and prompts (from lesson 6)

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/list lists 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_ticket with 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_customer results end with a resource_link to the refund policy. The host decides whether to read it.
  • Cursor pagination in search_tickets: 50 tickets per page and an opaque next_cursor to pass back as cursor. 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 server (from lesson 5)

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 scratch

Node 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.

Test it in the MCP Inspector

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.

Connect Claude Code and VS Code

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.

Checks (from lesson 5)

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.

What is in the repo

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.

Pinned versions

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.

License

MIT, see LICENSE. The files in wire/schema/ are copied from the MCP specification repository and keep that repository's license.

About

Companion code for MCP from Zero on karnstack: an MCP server built from raw JSON-RPC to a remote, authorized Worker, one tag per lesson.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages