Skip to main content
Glama
README.md
# 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**.

```sh
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: rotate `TOKEN_SIGNING_KEY`, `wrangler delete`, check the
Durable Objects in the dashboard, remove the connector.

### 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`:

```sh
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

```sh
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.

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**: Settings, Connectors, Add custom connector, the same URL,
  and "Use Claude's published identity". 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

```sh
claude mcp remove helpdesk                 # Claude Code
# claude.ai: Settings, 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
```

Then, in the Cloudflare dashboard (Workers and Pages, then Durable
Objects), check that no `HelpdeskStore` or `AuthStore` namespace is left,
and delete any that is. 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`.)

```sh
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.

```sh
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`:

```sh
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:

```sh
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](https://github.com/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.

TDQS

A3.9/5.0

Scored across 6 tools

Disambiguation5/5

Each tool targets a distinct resource and action: search vs. get for tickets, get for customers, export, note addition, and refund. Boundaries are clear, and the ticket lookup pair is well differentiated (bulk search vs. single-ticket detail).

Naming Consistency5/5

All names follow a consistent verb_noun snake_case pattern (search_tickets, get_ticket, get_customer, export_tickets, add_internal_note, issue_refund). No mixed conventions or vague verbs.

Tool Count5/5

Six tools is well-scoped for a helpdesk surface, with each tool clearly earning its place. No redundancy or thin coverage.

Completeness3/5

Read and write operations cover lookup, export, notes, and refunds, but there is no ticket creation, status update/close, or customer-facing reply, which are core helpdesk lifecycle actions. These gaps will limit end-to-end agent workflows.

Maintenance

ActivityMaintained
ResponsivenessNo issues