From 0c99d20d6fe13e093d027ffa229672ac5be7039f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 05:54:48 +0000 Subject: [PATCH 01/27] docs: add docs-v2 conventions, tree index, and page scaffolds Lay out the 45-page docs-v2 tree: authoring conventions in _meta/CONVENTIONS.md, a one-line-per-page index in _TREE.md, H2-outline scaffolds for 39 pages, and byte-identical copies of the three migration guides. --- docs-v2/_TREE.md | 95 ++ docs-v2/_meta/CONVENTIONS.md | 246 ++++ docs-v2/advanced/custom-methods.md | 60 + docs-v2/advanced/custom-transports.md | 79 ++ docs-v2/advanced/gateway.md | 65 + docs-v2/advanced/low-level-server.md | 66 + docs-v2/advanced/schema-libraries.md | 59 + docs-v2/advanced/wire-schemas.md | 55 + docs-v2/clients/caching.md | 63 + docs-v2/clients/calling.md | 69 + docs-v2/clients/connect.md | 63 + docs-v2/clients/machine-auth.md | 66 + docs-v2/clients/middleware.md | 62 + docs-v2/clients/oauth.md | 66 + docs-v2/clients/roots.md | 59 + docs-v2/clients/server-requests.md | 62 + docs-v2/clients/subscriptions.md | 62 + docs-v2/get-started/first-client.md | 93 ++ docs-v2/get-started/packages.md | 79 ++ docs-v2/get-started/real-host.md | 85 ++ docs-v2/migration/index.md | 54 + docs-v2/migration/support-2026-07-28.md | 633 +++++++++ docs-v2/migration/upgrade-to-v2.md | 1237 +++++++++++++++++ docs-v2/protocol-versions.md | 70 + docs-v2/servers/completion.md | 65 + docs-v2/servers/elicitation.md | 73 + docs-v2/servers/errors.md | 72 + docs-v2/servers/input-required.md | 75 + .../servers/logging-progress-cancellation.md | 72 + docs-v2/servers/notifications.md | 51 + docs-v2/servers/prompts.md | 64 + docs-v2/servers/resources.md | 63 + docs-v2/servers/sampling.md | 67 + docs-v2/serving/authorization.md | 59 + docs-v2/serving/express.md | 63 + docs-v2/serving/fastify.md | 60 + docs-v2/serving/hono.md | 63 + docs-v2/serving/http.md | 68 + docs-v2/serving/legacy-clients.md | 46 + docs-v2/serving/sessions-state-scaling.md | 46 + docs-v2/serving/stdio.md | 52 + docs-v2/serving/web-standard.md | 54 + docs-v2/testing.md | 57 + docs-v2/troubleshooting.md | 68 + 44 files changed, 4786 insertions(+) create mode 100644 docs-v2/_TREE.md create mode 100644 docs-v2/_meta/CONVENTIONS.md create mode 100644 docs-v2/advanced/custom-methods.md create mode 100644 docs-v2/advanced/custom-transports.md create mode 100644 docs-v2/advanced/gateway.md create mode 100644 docs-v2/advanced/low-level-server.md create mode 100644 docs-v2/advanced/schema-libraries.md create mode 100644 docs-v2/advanced/wire-schemas.md create mode 100644 docs-v2/clients/caching.md create mode 100644 docs-v2/clients/calling.md create mode 100644 docs-v2/clients/connect.md create mode 100644 docs-v2/clients/machine-auth.md create mode 100644 docs-v2/clients/middleware.md create mode 100644 docs-v2/clients/oauth.md create mode 100644 docs-v2/clients/roots.md create mode 100644 docs-v2/clients/server-requests.md create mode 100644 docs-v2/clients/subscriptions.md create mode 100644 docs-v2/get-started/first-client.md create mode 100644 docs-v2/get-started/packages.md create mode 100644 docs-v2/get-started/real-host.md create mode 100644 docs-v2/migration/index.md create mode 100644 docs-v2/migration/support-2026-07-28.md create mode 100644 docs-v2/migration/upgrade-to-v2.md create mode 100644 docs-v2/protocol-versions.md create mode 100644 docs-v2/servers/completion.md create mode 100644 docs-v2/servers/elicitation.md create mode 100644 docs-v2/servers/errors.md create mode 100644 docs-v2/servers/input-required.md create mode 100644 docs-v2/servers/logging-progress-cancellation.md create mode 100644 docs-v2/servers/notifications.md create mode 100644 docs-v2/servers/prompts.md create mode 100644 docs-v2/servers/resources.md create mode 100644 docs-v2/servers/sampling.md create mode 100644 docs-v2/serving/authorization.md create mode 100644 docs-v2/serving/express.md create mode 100644 docs-v2/serving/fastify.md create mode 100644 docs-v2/serving/hono.md create mode 100644 docs-v2/serving/http.md create mode 100644 docs-v2/serving/legacy-clients.md create mode 100644 docs-v2/serving/sessions-state-scaling.md create mode 100644 docs-v2/serving/stdio.md create mode 100644 docs-v2/serving/web-standard.md create mode 100644 docs-v2/testing.md create mode 100644 docs-v2/troubleshooting.md diff --git a/docs-v2/_TREE.md b/docs-v2/_TREE.md new file mode 100644 index 0000000000..4f84919241 --- /dev/null +++ b/docs-v2/_TREE.md @@ -0,0 +1,95 @@ +# docs-v2 draft — full tree (45 pages) + +This is the Phase 2 docs draft: 3 fully written CALIBRATION pages, 39 SCAFFOLD pages (H2 outlines with one verified lead code block each), and 3 VERBATIM-COPY migration files, on the approved 45-page structure. +React to two things: (1) voice — read the three CALIBRATION pages (`index.md`, `get-started/first-server.md`, `servers/tools.md`) against `_meta/CONVENTIONS.md`; (2) structure — this file plus each scaffold's H2 outline. Scaffold prose comes in a later tranche; do not review scaffold wording. +Format: `path | shape | status | scope`. Sections appear in nav order. + +## Top level + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `index.md` | landing | CALIBRATION | What MCP is (3 sentences) · one server snippet · four doors | + +## get-started/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `get-started/first-server.md` | tutorial | CALIBRATION | Setup once → one tool → run → see it answer | +| `get-started/real-host.md` | tutorial | SCAFFOLD | Plug your server into Claude Code / VS Code / Cursor | +| `get-started/first-client.md` | tutorial | SCAFFOLD | Connect, list, call, read, close — neutral, no vendor SDK | +| `get-started/packages.md` | explanation | SCAFFOLD | Which of the 10 packages, why subpaths exist | + +## servers/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `servers/tools.md` | how-to | CALIBRATION | Register, the schema payoff, structured output | +| `servers/resources.md` | how-to | SCAFFOLD | Static + templated resources, list callbacks | +| `servers/prompts.md` | how-to | SCAFFOLD | Register prompts, message construction | +| `servers/completion.md` | how-to | SCAFFOLD | Autocomplete a schema field | +| `servers/logging-progress-cancellation.md` | how-to | SCAFFOLD | The ctx every handler receives: logging, progress, cancellation | +| `servers/elicitation.md` | how-to | SCAFFOLD | Ask the user (form mode, URL mode) | +| `servers/sampling.md` | how-to | SCAFFOLD | Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first | +| `servers/input-required.md` | how-to | SCAFFOLD | Handle input_required (multi-round-trip requests) | +| `servers/notifications.md` | how-to | SCAFFOLD | Notify clients of changes | +| `servers/errors.md` | how-to | SCAFFOLD | isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out) | + +## serving/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `serving/stdio.md` | how-to | SCAFFOLD | serveStdio and the console.error gotcha | +| `serving/http.md` | how-to | SCAFFOLD | createMcpHandler; the per-request factory model lives HERE (recipes link back) | +| `serving/express.md` | how-to | SCAFFOLD | Express recipe — self-contained, install one-liner at top, one back-link to http.md | +| `serving/hono.md` | how-to | SCAFFOLD | Hono recipe — same shape as express.md | +| `serving/fastify.md` | how-to | SCAFFOLD | Fastify recipe — same shape as express.md | +| `serving/web-standard.md` | how-to | SCAFFOLD | Web-standard runtimes (Workers etc.) recipe — same shape as express.md | +| `serving/sessions-state-scaling.md` | how-to | SCAFFOLD | Sessions, Resumability, Multi-node — stateless ruling first, two sentences | +| `serving/authorization.md` | how-to | SCAFFOLD | Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router | +| `serving/legacy-clients.md` | how-to | SCAFFOLD | The legacy: option; where SSE went | + +## clients/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `clients/connect.md` | how-to | SCAFFOLD | Client + transports, what you can ask after connect | +| `clients/calling.md` | how-to | SCAFFOLD | The verbs; auto-aggregating pagination | +| `clients/server-requests.md` | how-to | SCAFFOLD | Sampling/elicitation handlers; era unification told once via one cross-link | +| `clients/roots.md` | how-to | SCAFFOLD | Provide roots — SUNSET-FRAMED (SEP-2577), banner at top | +| `clients/subscriptions.md` | how-to | SCAFFOLD | listen filters vs legacy subscribe | +| `clients/oauth.md` | how-to | SCAFFOLD | User-facing authorization-code flow. Opens with the one-line auth router | +| `clients/machine-auth.md` | how-to | SCAFFOLD | Client credentials, private-key JWT, cross-app access | +| `clients/middleware.md` | how-to | SCAFFOLD | Compose request/response middleware | +| `clients/caching.md` | how-to | SCAFFOLD | Client store + server cache hints, presented as one feature | + +## Top level + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `protocol-versions.md` | explanation | SCAFFOLD | Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide | + +## advanced/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `advanced/low-level-server.md` | explanation | SCAFFOLD | Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria | +| `advanced/custom-methods.md` | how-to | SCAFFOLD | Vendor-prefixed methods, extension capabilities | +| `advanced/schema-libraries.md` | how-to | SCAFFOLD | Valibot/ArkType, JSON-Schema-in, pluggable validators | +| `advanced/custom-transports.md` | how-to | SCAFFOLD | Implement the Transport interface | +| `advanced/wire-schemas.md` | how-to | SCAFFOLD | @modelcontextprotocol/core for gateways/proxies (raw wire schemas) | +| `advanced/gateway.md` | how-to | SCAFFOLD | Zero-round-trip reconnect with a prior discover result | + +## Top level + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `testing.md` | how-to | SCAFFOLD | In-memory linked pair + handler.fetch — no sockets | +| `troubleshooting.md` | reference | SCAFFOLD | Verbatim error message as each heading; seeded from faq.md; pruning rule stated | + +## migration/ + +| path | shape | status | scope | +| --- | --- | --- | --- | +| `migration/index.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/index.md` — untouched per the approved tree | +| `migration/upgrade-to-v2.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/upgrade-to-v2.md` — untouched per the approved tree | +| `migration/support-2026-07-28.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/support-2026-07-28.md` — untouched per the approved tree | diff --git a/docs-v2/_meta/CONVENTIONS.md b/docs-v2/_meta/CONVENTIONS.md new file mode 100644 index 0000000000..7636193336 --- /dev/null +++ b/docs-v2/_meta/CONVENTIONS.md @@ -0,0 +1,246 @@ +# docs-v2 CONVENTIONS + +Single source of truth for form. Three blocks: REGISTER (voice), SCAFFOLD FORMAT +(page shape), WIRING (snippet mechanics, verified in this worktree). Every writer +agent reads this file before touching a page. + +--- + +## REGISTER + +THE REGISTER — "restrained tiangolo". Felix chose this against live samples. Every fully +written page must pass EVERY rule; the voice reviewer fails pages per-rule. +R1. First screenful: a code block, or one lead-in sentence then a code block. Zero preamble. +R2. No main-flow paragraph exceeds 3 sentences; most are 1-2. +R3. Second person, imperative, present tense. Never "we", never "the user", never "one can". +R4. Every code block has a one-line lead-in naming the ONE thing it adds or changes. + Never two code blocks adjacent. +R5. After a capability lands, state the observable result; when there is output, quote it + verbatim in its own fenced block — and it must be REAL (produced by the companion example). +R6. The schema-payoff sentence appears at most ONCE per page, as a plain sentence + ("From that one schema the SDK derives the JSON Schema the model sees, validates arguments + before your handler runs, and infers the handler's argument types."). Never a refrain, + never a bullet ceremony. +R7. Asides leave the main column: tips, version notes, "coming from X" -> ::: tip / ::: info / + ::: warning containers (VitePress syntax). The page read WITHOUT its asides is still a + complete working path. +R8. Era caveats are ONE line linking /protocol-versions — never inline era prose. Deprecation + (SEP-2577: sampling, roots) is the opposite: a loud ::: warning banner at the TOP of that + page, migration target named first. +R9. Headings on doing-pages are imperative micro-steps ("Add a tool", "Validate the input"), + not noun labels. +R10. Bold a key term once on first introduction. Backtick every identifier. Never {@linkcode}. + Define terms inline; link for depth, never instead of defining. +R11. End with "## Recap": 3-6 flat bullets, each one claim already made on the page, zero code, + zero new information. No "Next up" handoff line after it. +R12. BANNED: theatrical stingers ("Look at what you passed in", "You didn't write any of them", + "That's it.", "That's the whole API."); exclamation marks; rhetorical questions; "Let's"; + "we will"; "In this section"; "simply"; "basically"; "It is important to note"; emoji; + passive voice for SDK behavior (write "the SDK generates", not "is generated by"). +R13. No option/parameter tables on narrative pages. Two carve-outs only: the error-code table + at the bottom of servers/errors.md, and the one-line install command at the top of every + serving/ recipe page. +R14. No hedging ("you might want to", "consider"). State what to do. +R15. Never write DEFENSIVELY. No prose that justifies a choice to an imagined reviewer: + no "this page is structured this way because", no "we use X rather than Y since", + no pre-empting objections, no apologizing for an API. Teach what the reader does; + rationale belongs in PR descriptions and ADRs, never on the page. If a sentence + answers "why did the AUTHOR do it this way" instead of "what does the READER do + next", delete it. (Source: Felix's #2390 review pattern, 2026-06-29 — both of his + flags that round were defensive prose; the fix each time was contract + the one + sharp edge + minimal usage, justification moved out.) +The target feel — confident, plain, code-first, not performed. Calibration sample (Felix-approved): + + ## Add a tool + + registerTool takes a name, a config, and a handler. + inputSchema is a Zod schema — the only schema you write. + + server.registerTool('search', { + description: 'Search the product catalog', + inputSchema: z.object({ + query: z.string(), + limit: z.number().int().max(50).optional(), + }), + }, async ({ query, limit }) => { ... }) + + From that one schema the SDK derives the JSON Schema the + model sees, validates arguments before your handler runs, + and infers the handler's argument types. + + ::: tip + Call the tool with limit: 999 and the SDK rejects it + before your function runs. + ::: + + ## Recap + + * registerTool(name, config, handler) registers a tool. + * One Zod schema: wire schema, validation, handler types. + +--- + +## SCAFFOLD FORMAT + +SCAFFOLD FORMAT — every non-calibration page is a SKELETON, not prose. Exact shape: + --- + status: scaffold + shape: + --- + # + + <!-- SCAFFOLD - structure only; prose comes in a later tranche. + scope: <the one-line scope from the approved tree, verbatim> + teaches: <symbolA>, <symbolB>, ... + source: mined from docs/<file>.md "<heading>" | net-new + --> + + ## <Imperative micro-step heading> + <!-- teaches: X | salvage: docs/server.md "<heading>" --> + <fenced ts block: the page's ONE defining lead code block, REAL verified API, with a + first-line comment: // draft - API verified against packages/<pkg>/src/<file>.ts> + <!-- result: one line, what the reader observes --> + + ## <Imperative micro-step heading> + <!-- teaches: Y --> + <!-- code: one line describing the block that goes here --> + + ... (4-9 H2 sections total; each one job) + + ## Recap + <!-- the 3-6 claims this page will prove --> + +Only the FIRST code block is real (and source-verified). Later blocks are comment placeholders. +H2 count and ordering ARE the deliverable — they get structure-reviewed. +Special cases: sampling.md and roots.md open with a ::: warning sunset banner placeholder +(SEP-2577; name the migration target). Each serving/ recipe page's first body line is the +install one-liner. real-host.md: VS Code leads the main flow; Claude Code and Cursor are +labeled H3 subsections after it (Felix ruling). + +--- + +## WIRING + +How a code fence in a docs-v2 page is wired to a typechecked example file. All of this +was verified live in this worktree on 2026-06-29 (see steps below); the mechanics are +implemented by `scripts/sync-snippets.ts`. + +### 1. Region syntax (in `examples/guides/**/*.examples.ts`) + +A region is a named block delimited by line comments. The name MUST be identical on the +open and close marker: + +``` +//#region instructions_basic +const server = new McpServer( + { name: 'db-server', version: '1.0.0' }, + { instructions: '...' } +); +//#endregion instructions_basic +``` + +Regions normally live inside a wrapper function whose name equals the region name +(see `examples/guides/serverGuide.examples.ts`). The sync script dedents the region +body to the indentation of the `//#region` line, so the indentation inside the wrapper +function is stripped in the rendered fence. Region extraction only works for `.ts` +files. Region names follow `exportedName_variant` (e.g. `registerTool_basic`). + +### 2. Fence attribute syntax (in the `.md` page) + +The opening fence carries a `source="<relative-path>#<region>"` attribute. The body +between the fences is OWNED by the sync script — it is overwritten on every mutating +run, and `--check` reports drift if it does not already match the region byte-for-byte. + +Real, working example (this exact fence is live in this file and is verified by +`pnpm sync:snippets --check`; this file lives in `_meta/`, one level deeper than a +top-level page, hence the extra `../` — a page at `docs-v2/<page>.md` uses one `../`): + +````md +```ts source="../../examples/guides/serverGuide.examples.ts#instructions_basic" +const server = new McpServer( + { name: 'db-server', version: '1.0.0' }, + { + instructions: + 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' + } +); +``` +```` + +Notes on the exact form (regex `MARKDOWN_LABELED_FENCE_PATTERN` in sync-snippets.ts): +- The opening fence must start at column 0: `` ```ts source="<path>#<region>" ``. +- An optional display filename may appear BEFORE `source=`: + `` ```ts my-app.ts source="./x.examples.ts#foo" ``. +- Omit `#<region>` to inline an entire file (any file type, e.g. `json`, `sh`). +- The closing fence must be a bare ```` ``` ```` on its own line. + +### 3. Path resolution rule + +The path in `source="..."` is resolved RELATIVE TO THE MARKDOWN FILE's own directory +(`resolve(dirname(mdFile), examplePath)` in the script). It is NOT relative to the repo +root and NOT relative to `docs-v2/`. + +From the standard locations, the prefixes are: + +| page location | companion example location | `source=` prefix | +|--------------------------------|------------------------------------------|------------------| +| `docs-v2/<page>.md` | `examples/guides/<file>.examples.ts` | `../examples/guides/` | +| `docs-v2/<section>/<page>.md` | `examples/guides/<section>/<file>.examples.ts` | `../../examples/guides/<section>/` | + +So a page at `docs-v2/get-started/first-server.md` whose companion is +`examples/guides/get-started/firstServer.examples.ts` uses: + +``` +source="../../examples/guides/get-started/firstServer.examples.ts#<region>" +``` + +The sync script scans `docs/**/*.md` AND `docs-v2/**/*.md` (the docs-v2 glob was added +during prep, 2026-06-29). + +### 4. How example files are typechecked + +`examples/guides/` has NO package.json or tsconfig of its own. It is part of the +`@modelcontextprotocol/examples` workspace package (`examples/package.json`), whose +`examples/tsconfig.json` includes `"./"` recursively — so new files under +`examples/guides/<section>/*.examples.ts` are picked up with ZERO config changes. + +The typecheck command (verified passing in this worktree): + +```sh +pnpm --filter @modelcontextprotocol/examples typecheck +``` + +(it runs `tsgo -p tsconfig.json --noEmit` inside `examples/`.) + +Import rules for example files (enforced by that tsconfig's `paths` mapping): +- `import { McpServer } from '@modelcontextprotocol/server'` +- `import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'` +- `import * as z from 'zod/v4'` ← the ONLY zod import form used in this repo. +- Never import from `@modelcontextprotocol/core-internal` in a guide example. + +### 5. The sync command (HARD RULE) + +Writers run ONLY the read-only check, never the mutating form: + +```sh +pnpm sync:snippets --check +``` + +NEVER run bare `pnpm sync:snippets` — it rewrites every fenced block in `docs/` and +`docs-v2/` in place, and concurrent mutating runs from parallel agents race and clobber +each other. Hand-write the fence body to match the region exactly; `--check` confirms. + +Verified outputs from prep (2026-06-29): +- With a stale `source=` fence present in `docs-v2/`, `--check` exits 1 with + `1 file(s) out of sync: .../docs-v2/_THROWAWAY-proof.md (1 snippet(s))`. +- With no drift, `--check` exits 0 with `All snippets are up to date`. + +### 6. Other verified ground truth + +- `registerTool` lives on `McpServer` (`packages/server/src/server/mcp.ts`). The + raw-shape `inputSchema` overload is deprecated — ALWAYS pass `z.object({...})`. +- Blessed entry points: `serveStdio` (`@modelcontextprotocol/server/stdio`) and + `createMcpHandler` (`@modelcontextprotocol/server`). +- Verify every API you reference against `packages/*/src` at this commit before writing + it into a lead code block; annotate the block's first line with + `// draft - API verified against packages/<pkg>/src/<file>.ts`. diff --git a/docs-v2/advanced/custom-methods.md b/docs-v2/advanced/custom-methods.md new file mode 100644 index 0000000000..5de11e0bf4 --- /dev/null +++ b/docs-v2/advanced/custom-methods.md @@ -0,0 +1,60 @@ +--- +status: scaffold +shape: how-to +--- +# Custom methods + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Vendor-prefixed methods, extension capabilities. +teaches: setRequestHandler (3-arg schema overload), RequestHandlerSchemas, Client.request, ctx.mcpReq.notify, setNotificationHandler, registerCapabilities({ extensions }), getServerCapabilities().extensions +source: mined from docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings"; docs/server.md "Extension capabilities"; docs/client.md "Extension capabilities"; examples/custom-methods/ +--> + +## Handle a vendor-prefixed method on the server +<!-- teaches: setRequestHandler('vendor/x', { params, result }, handler) | salvage: docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings"; examples/custom-methods/server.ts --> +A non-spec method needs schemas: pass `{ params, result }` as the second argument and the SDK validates both directions. + +```ts +// draft - API verified against packages/core-internal/src/shared/protocol.ts (setRequestHandler 3-arg Standard Schema overload) and packages/server/src/server/mcp.ts (McpServer.server) +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' }); + +mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => { + return { items: Array.from({ length: limit }, (_, i) => `${query}-${i}`) }; +}); +``` +<!-- result: the handler receives validated, typed params; a malformed acme/search is rejected before it runs --> + +## Call it from the client +<!-- teaches: Client.request({ method, params }, ResultSchema) | salvage: examples/custom-methods/client.ts --> +<!-- code: await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult) --> + +## Send a custom notification from the handler +<!-- teaches: ctx.mcpReq.notify({ method: 'acme/...', params }) for vendor-prefixed notifications --> +<!-- code: await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }) --> + +## Receive it on the client +<!-- teaches: setNotificationHandler('acme/...', { params }, handler) — same schema rule as requests --> +<!-- code: client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => ...) --> + +## Declare an extension capability +<!-- teaches: registerCapabilities({ extensions: { 'com.example/x': {...} } }) before connecting; prefix-qualified identifiers | salvage: docs/server.md "Extension capabilities"; examples/extension-capabilities/server.ts --> +<!-- code: mcp.server.registerCapabilities({ extensions: { 'com.example/feature-flags': { flags: ['dark-mode'] } } }) --> + +## Read the negotiated extensions on the client +<!-- teaches: getServerCapabilities()?.extensions — advertised by initialize on legacy connections and server/discover on 2026-07-28 ones (one-line era cross-link) | salvage: docs/client.md "Extension capabilities" --> +<!-- code: const extensions = client.getServerCapabilities()?.extensions ?? {} --> + +## Recap +<!-- the claims this page will prove: +* Non-spec methods take a { params, result } schema bundle; spec methods never do. +* client.request(request, ResultSchema) is the calling side; both directions are validated. +* Custom notifications mirror custom requests: notify on one side, setNotificationHandler with { params } on the other. +* capabilities.extensions advertises a vendor feature; the client reads the negotiated map after connect. +* Method names and extension identifiers are prefix-qualified — never bare words. +--> diff --git a/docs-v2/advanced/custom-transports.md b/docs-v2/advanced/custom-transports.md new file mode 100644 index 0000000000..49494b7ba3 --- /dev/null +++ b/docs-v2/advanced/custom-transports.md @@ -0,0 +1,79 @@ +--- +status: scaffold +shape: how-to +--- +# Custom transports + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Implement the Transport interface. +teaches: Transport, TransportSendOptions, JSONRPCMessage, start/send/close contract, onmessage/onerror/onclose, sessionId, setProtocolVersion, hasPerRequestStream, ReadBuffer, serializeMessage, deserializeMessage, InMemoryTransport.createLinkedPair +source: mined from docs/server.md "Transports"; docs/client.md "Connecting to a server"; net-new for the interface walkthrough +--> + +## Implement the `Transport` interface +<!-- teaches: Transport — three methods (start, send, close) and three callbacks (onmessage, onerror, onclose) | salvage: net-new (interface in packages/core-internal/src/shared/transport.ts) --> +A **transport** moves `JSONRPCMessage` values in both directions. Implement three methods and expose three callbacks; the `Client` and `Server` classes drive everything else. + +```ts +// draft - API verified against packages/core-internal/src/shared/transport.ts (Transport interface; re-exported by @modelcontextprotocol/server and /client via core-internal/src/exports/public/index.ts) +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server'; + +export class WebSocketServerTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(private readonly socket: WebSocket) {} + + async start(): Promise<void> { + this.socket.onmessage = event => { + this.onmessage?.(JSON.parse(String(event.data)) as JSONRPCMessage); + }; + this.socket.onerror = () => this.onerror?.(new Error('websocket error')); + this.socket.onclose = () => this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise<void> { + this.socket.send(JSON.stringify(message)); + } + + async close(): Promise<void> { + this.socket.close(); + this.onclose?.(); + } +} +``` +<!-- result: server.connect(new WebSocketServerTransport(socket)) speaks MCP over your channel --> + +## Honor the callback contract +<!-- teaches: callbacks are installed BEFORE start(); never call start() yourself when handing the transport to Client/Server — connect() does; close() must fire onclose --> +<!-- code: none — three-rule contract list, mirrored from the interface JSDoc --> + +## Connect it like a built-in transport +<!-- teaches: Client.connect(transport) / Server.connect(transport) take any Transport | salvage: docs/client.md "Connecting to a server" --> +<!-- code: await client.connect(new WebSocketClientTransport(socket)) --> + +## Frame messages over a byte stream +<!-- teaches: ReadBuffer, serializeMessage, deserializeMessage — the newline-delimited framing the stdio transports use, exported for reuse --> +<!-- code: readBuffer.append(chunk); for (let msg; (msg = readBuffer.readMessage()); ) this.onmessage?.(msg) --> + +## Report a session ID and the negotiated version +<!-- teaches: optional members the protocol layer calls back into — sessionId, setProtocolVersion(version), setSupportedProtocolVersions(versions) --> +<!-- code: sessionId getter + setProtocolVersion stub on the class --> + +## Opt into per-request cancellation +<!-- teaches: hasPerRequestStream + TransportSendOptions.requestSignal — only for transports that open one underlying request per outbound JSON-RPC request; single-channel transports leave it undefined --> +<!-- code: readonly hasPerRequestStream = true; send(message, { requestSignal }) honors the abort --> + +## Test it against the in-memory pair +<!-- teaches: InMemoryTransport.createLinkedPair as the reference Transport implementation and the harness to drive yours --> +<!-- code: const [clientSide, serverSide] = InMemoryTransport.createLinkedPair() --> + +## Recap +<!-- the claims this page will prove: +* A transport is start/send/close plus onmessage/onerror/onclose — nothing else is required. +* connect() installs the callbacks and calls start() for you; never call start() first. +* ReadBuffer, serializeMessage and deserializeMessage give you stdio-style framing for free. +* sessionId, setProtocolVersion and hasPerRequestStream are optional hooks the protocol layer uses when present. +* InMemoryTransport is both the smallest reference implementation and the test harness for yours. +--> diff --git a/docs-v2/advanced/gateway.md b/docs-v2/advanced/gateway.md new file mode 100644 index 0000000000..fe695a4738 --- /dev/null +++ b/docs-v2/advanced/gateway.md @@ -0,0 +1,65 @@ +--- +status: scaffold +shape: how-to +--- +# Gateways and worker fleets + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Zero-round-trip reconnect with a prior discover result. +teaches: ConnectOptions.prior, DiscoverResult, Client.getDiscoverResult, Client.discover, versionNegotiation mode 'auto', SdkErrorCode.EraNegotiationFailed, Client.listen +source: mined from docs/client.md "Skipping the probe: connect({ prior })" and "Protocol version negotiation (2026-07-28 revision)"; examples/gateway/ +--> + +## Connect with a prior discover result +<!-- teaches: connect(transport, { prior }) adopts a persisted DiscoverResult with zero round trips | salvage: docs/client.md "Skipping the probe: connect({ prior })" --> +A fleet that already knows the server's advertisement never has to probe again: pass it as `prior` and `connect()` sends nothing on the wire. + +```ts +// draft - API verified against packages/client/src/client/client.ts (ConnectOptions.prior, getDiscoverResult) and packages/client/src/index.ts (Client, StreamableHTTPClientTransport, DiscoverResult) +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const url = new URL('https://api.example.com/mcp'); + +// Probe once (here via the 'auto'-mode connect), persist the result … +const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(url)); +const persisted = JSON.stringify(bootstrap.getDiscoverResult()); + +// … then every worker connects with zero round trips. +const worker = new Client({ name: 'worker', version: '1.0.0' }); +await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); +``` +<!-- result: worker.callTool works immediately; the server sees no extra discover or initialize --> + +## Probe once at bootstrap +<!-- teaches: where a DiscoverResult comes from — an 'auto'/pinned connect or an explicit client.discover(); getDiscoverResult() reads it back | salvage: docs/client.md "Protocol version negotiation (2026-07-28 revision)" --> +<!-- code: bootstrap.getDiscoverResult() after the auto-mode connect --> + +## Persist the advertisement +<!-- teaches: DiscoverResult round-trips through JSON.stringify/JSON.parse by design — Redis, a config map, a process-local cache | salvage: examples/gateway/client.ts steps 2-3 --> +<!-- code: redis.set(key, JSON.stringify(discovered)); later JSON.parse(await redis.get(key)) as DiscoverResult --> + +## Fan out to workers +<!-- teaches: every worker connects { prior } from the same blob; capabilities, serverInfo, instructions and the negotiated version are adopted directly | salvage: examples/gateway/client.ts step 3 --> +<!-- code: workers.map(name => new Client({ name, version }).connect(transport, { prior })) --> + +## Reuse only within one authorization context +<!-- teaches: the advertisement is what the server returned for the bootstrap credential — never share a DiscoverResult across principals | salvage: examples/gateway/client.ts security note --> +<!-- code: none — ::: warning aside; the rule is the content --> + +## Open a listen stream when a worker needs notifications +<!-- teaches: connect({ prior }) never auto-opens subscriptions/listen; prior-connected workers are request-only until you call client.listen(filter) | salvage: docs/client.md "Skipping the probe: connect({ prior })" final paragraph --> +<!-- code: await worker.listen({ tools: {} }) on the one worker that watches for changes --> + +## Handle a stale or incompatible advertisement +<!-- teaches: connect({ prior }) is 2026-07-28+ only and rejects with SdkError(EraNegotiationFailed) when no modern version is shared; re-probe and re-persist on that path | salvage: docs/client.md "Skipping the probe: connect({ prior })" --> +<!-- code: catch SdkError, check error.code === SdkErrorCode.EraNegotiationFailed, fall back to a fresh probe --> + +## Recap +<!-- the claims this page will prove: +* connect(transport, { prior }) adopts a persisted DiscoverResult with zero round trips. +* The advertisement comes from one bootstrap probe ('auto'/pinned connect or client.discover()) and JSON-round-trips by design. +* Workers on the prior path are request-only; call listen() yourself if one needs notifications. +* Never reuse a DiscoverResult across authorization contexts. +* An incompatible prior rejects with EraNegotiationFailed — fall back to a fresh probe. +--> diff --git a/docs-v2/advanced/low-level-server.md b/docs-v2/advanced/low-level-server.md new file mode 100644 index 0000000000..dacb940892 --- /dev/null +++ b/docs-v2/advanced/low-level-server.md @@ -0,0 +1,66 @@ +--- +status: scaffold +shape: explanation +--- +# Low-level Server + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria. +teaches: Server, ServerOptions.capabilities, setRequestHandler (spec-method overload), RequestTypeMap, McpServer.server, McpServerFactory (accepts Server) +source: mined from docs/migration/upgrade-to-v2.md "Low-level protocol & handler context (ctx)" and "setRequestHandler / setNotificationHandler use method strings"; docs/server.md "Tools" +--> + +## Build the server and list your tools by hand +<!-- teaches: Server, ServerOptions.capabilities, setRequestHandler('tools/list') | salvage: docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings" --> +`Server` gives you the protocol with no registration layer on top: declare the capability, then answer `tools/list` yourself. + +```ts +// draft - API verified against packages/server/src/server/server.ts (Server, ServerOptions) and packages/core-internal/src/shared/protocol.ts (setRequestHandler spec-method overload) +import { Server } from '@modelcontextprotocol/server'; + +const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } }); + +server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'search', + description: 'Search the product catalog', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + }, + ], +})); +``` +<!-- result: a client's tools/list returns exactly the array you wrote — the SDK derived none of it --> + +## Handle `tools/call` yourself +<!-- teaches: setRequestHandler('tools/call'), RequestTypeMap['tools/call'], CallToolResult | salvage: docs/migration/upgrade-to-v2.md "Low-level protocol & handler context (ctx)" --> +<!-- code: setRequestHandler('tools/call', async (request, ctx) => ...) — dispatch on request.params.name, read request.params.arguments, return { content } --> + +## Validate arguments yourself +<!-- teaches: what registerTool was doing for you (JSON Schema derivation + pre-handler validation); fromJsonSchema as the halfway point --> +<!-- code: parse request.params.arguments by hand (or fromJsonSchema(inputSchema)['~standard'].validate) before touching it --> + +## Serve it with the same entry points +<!-- teaches: McpServerFactory accepts McpServer | Server — serveStdio and createMcpHandler take this Server unchanged | salvage: docs/server.md "Transports" --> +<!-- code: serveStdio(() => server) — identical to the high-level path --> + +## Reach the low level from `McpServer` +<!-- teaches: McpServer.server escape hatch; mixing registerTool with hand-registered handlers | salvage: docs/server.md "Extension capabilities" (the server.server idiom) --> +<!-- code: mcp.server.setRequestHandler(...) on an existing McpServer --> + +## Decide which layer to build on +<!-- teaches: the criteria — McpServer for tools/resources/prompts (schema payoff, list-changed bookkeeping, completions); Server when you own dispatch (gateways, dynamic tool sets, non-standard registries, custom methods) --> +<!-- code: none — decision prose; ends with the default ruling: start on McpServer, drop down per handler via mcp.server --> + +## Recap +<!-- the claims this page will prove: +* Server is the protocol layer: setRequestHandler(method, handler) and nothing else. +* On Server you write the JSON Schema and the validation that registerTool derives from one Zod schema. +* serveStdio and createMcpHandler accept a Server factory unchanged. +* McpServer.server is the escape hatch — you never have to choose for the whole program. +* Default to McpServer; drop to Server only when you own dispatch. +--> diff --git a/docs-v2/advanced/schema-libraries.md b/docs-v2/advanced/schema-libraries.md new file mode 100644 index 0000000000..3a19dc39f7 --- /dev/null +++ b/docs-v2/advanced/schema-libraries.md @@ -0,0 +1,59 @@ +--- +status: scaffold +shape: how-to +--- +# Schema libraries + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Valibot/ArkType, JSON-Schema-in, pluggable validators. +teaches: registerTool (Standard Schema overload), StandardSchemaWithJSON, @valibot/to-json-schema, fromJsonSchema, jsonSchemaValidator option, AjvJsonSchemaValidator, CfWorkerJsonSchemaValidator +source: mined from docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)" and "Automatic JSON Schema validator selection by runtime"; examples/schema-validators/ +--> + +## Register a tool with an ArkType schema +<!-- teaches: registerTool accepts any Standard-Schema-with-JSON value, not only Zod | salvage: docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)"; examples/schema-validators/server.ts --> +`inputSchema` takes any **Standard Schema** that can produce JSON Schema — ArkType works as-is. + +```ts +// draft - API verified against packages/server/src/server/mcp.ts (registerTool StandardSchemaWithJSON overload) +import { McpServer } from '@modelcontextprotocol/server'; +import { type } from 'arktype'; + +const server = new McpServer({ name: 'greeter', version: '1.0.0' }); + +server.registerTool( + 'greet', + { description: 'Greet someone', inputSchema: type({ name: 'string' }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) +); +``` +<!-- result: same payoff as Zod — derived JSON Schema, pre-handler validation, inferred handler argument types --> + +## Register a tool with a Valibot schema +<!-- teaches: Valibot needs the @valibot/to-json-schema wrapper to expose JSON Schema conversion | salvage: examples/schema-validators/server.ts --> +<!-- code: inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) --> + +## Start from JSON Schema you already have +<!-- teaches: fromJsonSchema(schema) wraps a plain JSON Schema document into a Standard Schema you can pass to inputSchema/outputSchema | salvage: docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)" --> +<!-- code: inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }) --> + +## Validate structured output with any library +<!-- teaches: outputSchema works with the same Standard Schema rule; structuredContent is validated against it | salvage: examples/schema-validators/server.ts "get-weather" --> +<!-- code: outputSchema with the chosen library; handler returns { content, structuredContent } --> + +## Swap the JSON Schema validator +<!-- teaches: jsonSchemaValidator option on ServerOptions; @modelcontextprotocol/server/validators/ajv subpath (Ajv, addFormats, AjvJsonSchemaValidator) | salvage: docs/migration/upgrade-to-v2.md "Automatic JSON Schema validator selection by runtime" --> +<!-- code: new McpServer(info, { jsonSchemaValidator: new AjvJsonSchemaValidator(customAjv) }) --> + +## Pick the validator for your runtime +<!-- teaches: the default is runtime-selected (AJV on Node.js, @cfworker/json-schema on browser/workerd); /validators/cf-worker subpath to force it --> +<!-- code: import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker' --> + +## Recap +<!-- the claims this page will prove: +* inputSchema and outputSchema accept any Standard Schema that exposes JSON Schema — Zod, ArkType, Valibot (via @valibot/to-json-schema). +* The raw-shape ZodRawShape overload is deprecated; pass a schema object. +* fromJsonSchema turns an existing JSON Schema document into something you can register. +* The JSON Schema validator is pluggable: pass jsonSchemaValidator, or import a provider from a validators/ subpath. +* The default validator is chosen by runtime; you only override it to pin or configure one. +--> diff --git a/docs-v2/advanced/wire-schemas.md b/docs-v2/advanced/wire-schemas.md new file mode 100644 index 0000000000..70c383b878 --- /dev/null +++ b/docs-v2/advanced/wire-schemas.md @@ -0,0 +1,55 @@ +--- +status: scaffold +shape: how-to +--- +# Wire schemas + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: @modelcontextprotocol/core for gateways/proxies (raw wire schemas). +teaches: @modelcontextprotocol/core, CallToolResultSchema (and the ~160 spec *Schema constants), JSONRPCMessageSchema, the OAuth/OpenID schema group, where the TypeScript types live instead +source: mined from docs/migration/upgrade-to-v2.md "Zod *Schema constants moved to @modelcontextprotocol/core"; packages/core/src/index.ts header +--> + +## Validate a wire payload +<!-- teaches: @modelcontextprotocol/core exports the exact Zod schemas the SDK validates with; *.safeParse on untrusted JSON | salvage: docs/migration/upgrade-to-v2.md "Zod *Schema constants moved to @modelcontextprotocol/core" --> +A gateway holds raw JSON, not SDK objects. `@modelcontextprotocol/core` ships the spec's Zod schemas so you can validate it directly. + +```ts +// draft - API verified against packages/core/src/index.ts (CallToolResultSchema re-export) +import { CallToolResultSchema } from '@modelcontextprotocol/core'; + +const parsed = CallToolResultSchema.safeParse(payload); +if (!parsed.success) { + throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`); +} +``` +<!-- result: parsed.data is the typed result; a malformed upstream response is rejected at the boundary --> + +## Decide whether you need this package at all +<!-- teaches: the audience split — Client/Server users never import core; gateways, proxies and test harnesses that touch raw JSON-RPC do --> +<!-- code: none — two-sentence router; links back to servers/ and clients/ for the SDK-object path --> + +## Pick the schema for the message you hold +<!-- teaches: naming convention <SpecType>Schema; the request/result/notification/params families; JSONRPCMessageSchema for the undecoded envelope --> +<!-- code: JSONRPCMessageSchema.parse(line) on an incoming frame --> + +## Route raw JSON-RPC in a proxy +<!-- teaches: parse the envelope once, branch on method, validate params with the per-method schema — no Client or Server in the path --> +<!-- code: switch on message.method, then CallToolRequestSchema.safeParse(message) before forwarding --> + +## Validate OAuth and discovery metadata +<!-- teaches: the second export group — OAuth/OpenID *Schema constants for token responses, protected-resource metadata, authorization-server metadata --> +<!-- code: OAuthMetadataSchema.safeParse(await response.json()) --> + +## Get the TypeScript types, guards and errors from the SDK packages +<!-- teaches: core is Zod values ONLY; the spec types, isJSONRPCRequest-style guards and error classes ship from @modelcontextprotocol/server and /client (and z.infer works on any core schema) --> +<!-- code: import type { CallToolResult } from '@modelcontextprotocol/client' next to the core schema import --> + +## Recap +<!-- the claims this page will prove: +* @modelcontextprotocol/core re-exports the SDK's own spec + OAuth Zod schemas and nothing else. +* Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not normal Client/Server users. +* Every spec type has a <Name>Schema constant; JSONRPCMessageSchema validates the undecoded envelope. +* Types, guards and error classes are not in core — import them from @modelcontextprotocol/server or /client. +* The package is runtime-neutral; zod is its only dependency. +--> diff --git a/docs-v2/clients/caching.md b/docs-v2/clients/caching.md new file mode 100644 index 0000000000..bd83727c6a --- /dev/null +++ b/docs-v2/clients/caching.md @@ -0,0 +1,63 @@ +--- +status: scaffold +shape: how-to +--- +# Cache responses + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Client store + server cache hints, presented as one feature. +teaches: CacheableRequestOptions.cacheMode, ClientOptions.responseCacheStore, ClientOptions.cachePartition, ClientOptions.defaultCacheTtlMs, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS, server-side ttlMs/cacheScope hints (SEP-2549) +source: mined from docs/client.md "Response caching (2026-07-28 draft)"; server hint side mined from docs/server.md / packages/server/src — ONE feature, both halves on this page +--> + +## Let the cache work + +<!-- teaches: the zero-config path — cacheable verbs honour the server's ttlMs automatically; cacheMode overrides per call | salvage: docs/client.md "Response caching (2026-07-28 draft)" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts (listTools(params?, options?: CacheableRequestOptions), readResource) and packages/client/src/client/responseCache.ts (InMemoryResponseCacheStore, MAX_CACHE_TTL_MS) +const tools = await client.listTools(); // network, then cached for the server's ttlMs +const again = await client.listTools(); // served from cache while still fresh + +await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store +await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write +``` + +<!-- result: the second listTools() makes no network round trip; quote the companion example's timing/log output. --> + +## Have the server send the hint + +<!-- teaches: the other half of the feature — the server attaches ttlMs / cacheScope to cacheable results (SEP-2549); without a hint nothing is served from cache | salvage: net-new (server cache-hint config in packages/server/src); cross-reference, not duplicated prose --> +<!-- code: the server-side registration option that sets ttlMs / cacheScope on a list result --> + +## Choose a cache mode per call + +<!-- teaches: cacheMode 'refresh' vs 'bypass' vs default; which verbs are cacheable (tools/list, prompts/list, resources/list, resources/templates/list, resources/read, server/discover) | salvage: docs/client.md "Response caching" --> +<!-- code: none — placeholder comment naming the three modes; 'bypass' leaves the cache byte-untouched --> + +## Bring your own store + +<!-- teaches: ClientOptions.responseCacheStore, the ResponseCacheStore interface, InMemoryResponseCacheStore default | salvage: docs/client.md "Response caching" (ClientOptions bullets) --> +<!-- code: new Client(info, { responseCacheStore: myStore }) --> + +## Partition the store per user + +<!-- teaches: ClientOptions.cachePartition isolating 'private'-scoped entries when one store serves several principals | salvage: docs/client.md "Response caching" (IMPORTANT callout) --> +<!-- code: new Client(info, { responseCacheStore: shared, cachePartition: userId }) --> +<!-- aside: ::: warning — a shared store without cachePartition can serve one user's private resource bodies to another --> + +## Cache against servers that send no hints + +<!-- teaches: ClientOptions.defaultCacheTtlMs; eviction on list_changed / resources/updated notifications | salvage: docs/client.md "Response caching" (defaultCacheTtlMs bullet + eviction paragraph) --> +<!-- code: new Client(info, { defaultCacheTtlMs: 60_000 }) --> +<!-- aside: ::: info — one-line era cross-link to /protocol-versions: cache hints are a 2026-07-28 surface; against 2025-era servers defaultCacheTtlMs is the only lever --> + +## Recap + +<!-- the claims this page will prove: +- Caching is one feature with two halves: the server sends ttlMs/cacheScope, the client honours it — neither half does anything alone (by default). +- The cacheable verbs serve a still-fresh result without a round trip; cacheMode overrides per call. +- responseCacheStore swaps the backing store; cachePartition is mandatory when that store is shared across principals. +- defaultCacheTtlMs is the opt-in for servers that send no hints. +- list_changed and resources/updated notifications evict automatically. +--> diff --git a/docs-v2/clients/calling.md b/docs-v2/clients/calling.md new file mode 100644 index 0000000000..41602cbe7b --- /dev/null +++ b/docs-v2/clients/calling.md @@ -0,0 +1,69 @@ +--- +status: scaffold +shape: how-to +--- +# Call tools, read resources, get prompts + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: The verbs; auto-aggregating pagination. +teaches: Client.listTools, Client.callTool, Client.listResources, Client.readResource, Client.listResourceTemplates, Client.listPrompts, Client.getPrompt, Client.complete, ClientOptions.listMaxPages, CallToolRequestOptions.onprogress +source: mined from docs/client.md "Tools", "Resources", "Prompts", "Completions", "Tracking progress" +--> + +## List the tools and call one + +<!-- teaches: Client.listTools, Client.callTool | salvage: docs/client.md "Tools" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts (listTools: ListToolsResult, callTool: CallToolResult) +const { tools } = await client.listTools(); + +const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 }, +}); +console.log(result.content); +``` + +<!-- result: result.content is the model-facing content array; quote the real printed output from the companion example. --> + +## Let the SDK walk the pages + +<!-- teaches: auto-aggregating pagination, ClientOptions.listMaxPages, LIST_PAGINATION_EXCEEDED | salvage: docs/client.md "Tools" (aggregate-walk paragraph) --> +<!-- code: listTools() with no cursor returns the COMPLETE list; { cursor } opts into per-page control; listMaxPages caps the walk --> +<!-- aside: ::: warning — a server whose pagination never terminates rejects with SdkError LIST_PAGINATION_EXCEEDED --> + +## Read structured output + +<!-- teaches: CallToolResult.structuredContent | salvage: docs/client.md "Tools" (structuredContent block) --> +<!-- code: check result.structuredContent !== undefined and narrow the unknown before use --> + +## Read a resource + +<!-- teaches: Client.listResources, Client.readResource, Client.listResourceTemplates | salvage: docs/client.md "Resources" --> +<!-- code: listResources() then readResource({ uri }) iterating contents --> + +## Get a prompt + +<!-- teaches: Client.listPrompts, Client.getPrompt | salvage: docs/client.md "Prompts" --> +<!-- code: listPrompts() then getPrompt({ name, arguments }) returning messages --> + +## Autocomplete an argument + +<!-- teaches: Client.complete | salvage: docs/client.md "Completions" --> +<!-- code: client.complete({ ref, argument }) returning completion.values --> + +## Track progress on a long call + +<!-- teaches: CallToolRequestOptions.onprogress, resetTimeoutOnProgress, maxTotalTimeout | salvage: docs/client.md "Tracking progress" --> +<!-- code: callTool(params, { onprogress, resetTimeoutOnProgress: true, maxTotalTimeout }) --> + +## Recap + +<!-- the claims this page will prove: +- listTools/listResources/listResourceTemplates/listPrompts auto-aggregate every page; pass { cursor } only when you want per-page control. +- callTool returns content for the model and optionally structuredContent for your application. +- readResource and getPrompt mirror the same list-then-fetch shape. +- complete() autocompletes a prompt or resource-template argument. +- onprogress on the call options streams progress without changing the return type. +--> diff --git a/docs-v2/clients/connect.md b/docs-v2/clients/connect.md new file mode 100644 index 0000000000..4bf0e82ba2 --- /dev/null +++ b/docs-v2/clients/connect.md @@ -0,0 +1,63 @@ +--- +status: scaffold +shape: how-to +--- +# Connect to a server + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Client + transports, what you can ask after connect. +teaches: Client, Client.connect, StreamableHTTPClientTransport, StdioClientTransport, SSEClientTransport, Client.close, Client.getInstructions, ConnectOptions +source: mined from docs/client.md "Connecting to a server", "Disconnecting", "Server instructions", "Protocol version negotiation" +--> + +## Create a client and connect over HTTP + +<!-- teaches: Client, StreamableHTTPClientTransport, Client.connect | salvage: docs/client.md "Streamable HTTP" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts and packages/client/src/client/streamableHttp.ts +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + +await client.connect(transport); +``` + +<!-- result: connect() resolves once the initialize handshake completes; the client now holds the negotiated protocol version and the server's capabilities. --> +<!-- aside (::: info Coming from v1?): Client and the transport classes keep their names; the import + paths moved to @modelcontextprotocol/client (and its /stdio subpath) — run the codemod, then see + /migration/upgrade-to-v2. (proposal §3 path 3: the standard aside, mandatory on this page) --> + +## Connect to a local process over stdio + +<!-- teaches: StdioClientTransport (@modelcontextprotocol/client/stdio) | salvage: docs/client.md "stdio" --> +<!-- code: same Client, StdioClientTransport({ command, args }) spawning the server process; note the /stdio subpath import --> + +## Fall back to SSE for legacy servers + +<!-- teaches: SSEClientTransport | salvage: docs/client.md "SSE fallback for legacy servers" --> +<!-- code: try StreamableHTTPClientTransport, catch, retry with SSEClientTransport on a fresh Client --> +<!-- aside: ::: info — one-line era cross-link to /protocol-versions; version negotiation (ConnectOptions / setVersionNegotiation) is a labeled aside, not main flow --> + +## Read what the server told you at connect time + +<!-- teaches: Client.getServerVersion, Client.getServerCapabilities, Client.getInstructions | salvage: docs/client.md "Server instructions", "Extension capabilities" --> +<!-- code: log getServerVersion(), getServerCapabilities(), getInstructions() after connect --> +<!-- result: the capability object is what gates every verb on the next page --> + +## Disconnect cleanly + +<!-- teaches: Client.close | salvage: docs/client.md "Disconnecting" --> +<!-- code: await client.close() --> + +## Recap + +<!-- the claims this page will prove: +- new Client({ name, version }) plus a transport plus connect() is the whole setup. +- StreamableHTTPClientTransport is the default for remote servers; StdioClientTransport (from /stdio) for local processes; SSEClientTransport only as a legacy fallback. +- connect() performs initialization; afterwards getServerCapabilities()/getInstructions() are populated. +- close() tears down the transport. +- Era differences live on /protocol-versions, not here. +--> diff --git a/docs-v2/clients/machine-auth.md b/docs-v2/clients/machine-auth.md new file mode 100644 index 0000000000..957aa41d15 --- /dev/null +++ b/docs-v2/clients/machine-auth.md @@ -0,0 +1,66 @@ +--- +status: scaffold +shape: how-to +--- +# Authenticate without a user + +<!-- ROUTER (one line, first body line of the page — proposal §3 path 4): +"Protecting a server you run → serving/authorization. Authenticating a user → clients/oauth. +No user (service-to-service) → this page." --> + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Client credentials, private-key JWT, cross-app access. +teaches: AuthProvider, ClientCredentialsProvider, PrivateKeyJwtProvider, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant +source: mined from docs/client.md "Bearer tokens", "Client credentials", "Private key JWT", "Cross-App Access (Enterprise Managed Authorization)" +--> + +## Authenticate with client credentials + +<!-- teaches: ClientCredentialsProvider; the authProvider option is the same one OAuth uses | salvage: docs/client.md "Client credentials" --> + +```ts +// draft - API verified against packages/client/src/client/authExtensions.ts (ClientCredentialsProvider implements OAuthClientProvider) and packages/client/src/client/streamableHttp.ts (authProvider) +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const authProvider = new ClientCredentialsProvider({ + clientId: 'my-service', + clientSecret: 'my-secret', +}); + +const client = new Client({ name: 'my-service', version: '1.0.0' }); +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + +await client.connect(transport); +``` + +<!-- result: the provider discovers the authorization server, runs the client_credentials grant, and refreshes the token on 401 — no browser, no user. --> + +## Bring your own bearer token + +<!-- teaches: the minimal AuthProvider interface (token(), optional onUnauthorized()) for tokens managed outside the SDK | salvage: docs/client.md "Bearer tokens" --> +<!-- code: const authProvider: AuthProvider = { token: async () => getStoredToken() } --> + +## Sign with a private key instead of a secret + +<!-- teaches: PrivateKeyJwtProvider (private_key_jwt token-endpoint auth) | salvage: docs/client.md "Private key JWT" --> +<!-- code: new PrivateKeyJwtProvider({ clientId, privateKey, algorithm: 'RS256' }) --> + +## Act for an enterprise user with cross-app access + +<!-- teaches: CrossAppAccessProvider (SEP-990), discoverAndRequestJwtAuthGrant; the IdP-token -> JAG -> access-token chain | salvage: docs/client.md "Cross-App Access (Enterprise Managed Authorization)" --> +<!-- code: new CrossAppAccessProvider({ assertion: async ctx => (await discoverAndRequestJwtAuthGrant({...})).jwtAuthGrant, clientId, clientSecret }) --> + +## Drop to the token-exchange utilities + +<!-- teaches: requestJwtAuthorizationGrant, discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant as standalone functions | salvage: docs/client.md "Cross-App Access" (Layer 2 list) --> +<!-- code: none — link the API reference for the three functions --> + +## Recap + +<!-- the claims this page will prove: +- Every flow on this page plugs in through the same authProvider transport option. +- ClientCredentialsProvider covers plain service-to-service; PrivateKeyJwtProvider replaces the shared secret with a signed assertion. +- A bare AuthProvider with only token() is enough when something else owns the token. +- CrossAppAccessProvider chains the enterprise IdP token through a JAG to an MCP access token (SEP-990). +- User-facing flows belong on clients/oauth. +--> diff --git a/docs-v2/clients/middleware.md b/docs-v2/clients/middleware.md new file mode 100644 index 0000000000..d979f69b8e --- /dev/null +++ b/docs-v2/clients/middleware.md @@ -0,0 +1,62 @@ +--- +status: scaffold +shape: how-to +--- +# Compose client middleware + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Compose request/response middleware. +teaches: createMiddleware, applyMiddlewares, Middleware, withLogging, withOAuth, the transport fetch option +source: mined from docs/client.md "Client middleware", "Trace context propagation" (middleware block); packages/client/src/client/middleware.ts +--> + +## Write a middleware + +<!-- teaches: createMiddleware((next, input, init) => ...) wrapping fetch | salvage: docs/client.md "Client middleware" --> + +```ts +// draft - API verified against packages/client/src/client/middleware.ts (createMiddleware, applyMiddlewares) and packages/client/src/client/streamableHttp.ts (fetch option) +import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const authMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'my-value'); + return next(input, { ...init, headers }); +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(authMiddleware)(fetch), +}); +``` + +<!-- result: every HTTP request the transport makes now carries the header; the middleware sees the raw Response on the way back. --> + +## Compose several middlewares + +<!-- teaches: applyMiddlewares(...mws) ordering — first argument is outermost | salvage: docs/client.md "Client middleware"; net-new ordering note --> +<!-- code: applyMiddlewares(retry, auth, logging)(fetch) with a one-line comment per layer --> + +## Use the built-in logging middleware + +<!-- teaches: withLogging(options) | salvage: net-new from packages/client/src/client/middleware.ts (withLogging) --> +<!-- code: applyMiddlewares(withLogging({ ... }))(fetch) --> + +## Combine middleware with an auth provider + +<!-- teaches: withOAuth — the auth provider expressed AS a middleware, for stacks that already own fetch | salvage: net-new from packages/client/src/client/middleware.ts (withOAuth) --> +<!-- code: applyMiddlewares(withOAuth(provider, serverUrl))(fetch) --> +<!-- aside: ::: tip — for the common case just pass authProvider to the transport (clients/oauth); withOAuth is for composing it with other middleware --> + +## Inspect the response + +<!-- teaches: middleware sees both directions — read response status/headers after awaiting next() | salvage: net-new; docs/client.md "Trace context propagation" (traceContext_middleware) as the worked case --> +<!-- code: const response = await next(input, init); read response.status; return response --> + +## Recap + +<!-- the claims this page will prove: +- Middleware wraps the transport's fetch; createMiddleware builds one, applyMiddlewares composes many. +- Pass the composed fetch to the transport's fetch option. +- A middleware sees the request before next() and the Response after it. +- withLogging and withOAuth ship in the box. +--> diff --git a/docs-v2/clients/oauth.md b/docs-v2/clients/oauth.md new file mode 100644 index 0000000000..7935e602f8 --- /dev/null +++ b/docs-v2/clients/oauth.md @@ -0,0 +1,66 @@ +--- +status: scaffold +shape: how-to +--- +# Authenticate a user with OAuth + +<!-- ROUTER (one line, first body line of the page — Felix ruling, proposal §3 path 4): +"Protecting a server you run → serving/authorization. Authenticating a user → this page. +No user → clients/machine-auth." --> + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: User-facing authorization-code flow. Opens with the one-line auth router. +teaches: OAuthClientProvider, StreamableHTTPClientTransport authProvider option, UnauthorizedError, StreamableHTTPClientTransport.finishAuth, IssuerMismatchError, OAuthClientProvider.validateResourceURL +source: mined from docs/client.md "Full OAuth with user authorization", "Resource indicators (RFC 8707)" +--> + +## Hand the transport an OAuth provider + +<!-- teaches: authProvider option on StreamableHTTPClientTransport; connect() throws UnauthorizedError when authorization is needed | salvage: docs/client.md "Full OAuth with user authorization" --> + +```ts +// draft - API verified against packages/client/src/client/streamableHttp.ts (authProvider option, finishAuth) and packages/client/src/client/auth.ts (OAuthClientProvider, UnauthorizedError) +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { + authProvider: provider, // your OAuthClientProvider +}); + +try { + await client.connect(transport); +} catch (error) { + if (!(error instanceof UnauthorizedError)) throw error; + // The provider's redirectToAuthorization() already sent the end user to the browser. +} +``` + +<!-- result: on a 401 the SDK runs discovery, registers (or looks up) the client, and calls your provider's redirectToAuthorization(url). --> + +## Implement OAuthClientProvider + +<!-- teaches: the OAuthClientProvider interface — redirectUrl, clientMetadata, clientInformation/saveClientInformation keyed by ctx.issuer, tokens/saveTokens, state, codeVerifier, saveDiscoveryState | salvage: docs/client.md "Full OAuth with user authorization" (MyOAuthProvider block) --> +<!-- code: a minimal OAuthClientProvider class; keep the issuer-keyed credential map (SEP-2352) --> + +## Finish the flow from the callback + +<!-- teaches: transport.finishAuth(URLSearchParams), state comparison, reconnect on a FRESH transport | salvage: docs/client.md "Full OAuth with user authorization" (auth_finishAuth block) --> +<!-- code: compare state, await transport.finishAuth(params), then client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })) --> + +## Handle issuer mismatch + +<!-- teaches: IssuerMismatchError (kind 'authorization_response' vs 'metadata'), never render error_description on mismatch | salvage: docs/client.md "Full OAuth with user authorization" (issuer-validation paragraph) --> +<!-- code: catch IssuerMismatchError around finishAuth --> +<!-- aside: ::: warning — security: a mix-up attacker controls error_description; do not show it. skipIssuerMetadataValidation exists but weakens this. --> + +## Pin the resource indicator + +<!-- teaches: OAuthClientProvider.validateResourceURL, checkResourceAllowed, resourceUrlFromServerUrl (RFC 8707) | salvage: docs/client.md "Resource indicators (RFC 8707)" --> +<!-- code: validateResourceURL override returning the URL to force, or undefined to omit --> + +## Recap + +<!-- the claims this page will prove: +- This page is for clients acting on behalf of a USER; machine-to-machine flows live on clients/machine-auth. +- Pass an OAuthClientProvider as the transport's authProvider; connect() throws UnauthorizedError when the end user must authorize. +- finishAuth(params) with the whole callback query lets the SDK validate iss (RFC 9207) and exchange the code. +- Always reconnect on a fresh transport; OAuth state lives on the provider. +- IssuerMismatchError is the mix-up defense; do not weaken it. +--> diff --git a/docs-v2/clients/roots.md b/docs-v2/clients/roots.md new file mode 100644 index 0000000000..c34a67ece9 --- /dev/null +++ b/docs-v2/clients/roots.md @@ -0,0 +1,59 @@ +--- +status: scaffold +shape: how-to +--- +# Provide roots + +::: warning Deprecated — SEP-2577 +<!-- SUNSET BANNER placeholder. Roots are deprecated as of protocol version 2026-07-28 +(SEP-2577) and remain functional for at least twelve months. Migration target named +FIRST: pass paths via tool arguments, resource URIs, or host configuration instead. +Link the deprecated-features registry. This banner is the first thing on the page. --> +::: + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Provide roots — SUNSET-FRAMED (SEP-2577), banner at top. +teaches: roots capability, client.setRequestHandler('roots/list'), Client.sendRootsListChanged +source: mined from docs/client.md "Roots" +--> + +## Migrate away first + +<!-- teaches: the replacement, not the feature. Name the targets (tool arguments, resource URIs, configuration) before showing any roots API | salvage: docs/client.md "Roots" warning block; net-new framing --> +<!-- code: none — this section is the off-ramp; one link to the deprecated-features registry --> + +## Declare the roots capability + +<!-- teaches: roots: { listChanged: true } in the Client constructor's capabilities option | salvage: docs/client.md "Handling server-initiated requests" (capabilities_declaration) --> +<!-- code: new Client(info, { capabilities: { roots: { listChanged: true } } }) --> + +## Answer roots/list + +<!-- teaches: client.setRequestHandler('roots/list', ...) returning { roots } | salvage: docs/client.md "Roots" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts (setRequestHandler) and the roots/list request type (a ServerRequest — the server sends it) in packages/core-internal/src/types/types.ts +client.setRequestHandler('roots/list', async () => { + return { + roots: [ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' }, + ], + }; +}); +``` + +<!-- result: a server that declares it uses roots receives this list and scopes its file operations to it. --> + +## Tell the server when the roots change + +<!-- teaches: Client.sendRootsListChanged | salvage: docs/client.md "Roots" (final paragraph) --> +<!-- code: await client.sendRootsListChanged() after the handler's backing list changes --> + +## Recap + +<!-- the claims this page will prove: +- Roots are deprecated (SEP-2577); the migration targets are tool arguments, resource URIs, and configuration. +- While you still need them: declare roots: { listChanged: true } and register a roots/list handler returning { roots }. +- sendRootsListChanged() tells the server to re-list. +--> diff --git a/docs-v2/clients/server-requests.md b/docs-v2/clients/server-requests.md new file mode 100644 index 0000000000..89014956a8 --- /dev/null +++ b/docs-v2/clients/server-requests.md @@ -0,0 +1,62 @@ +--- +status: scaffold +shape: how-to +--- +# Handle requests from the server + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Sampling/elicitation handlers; era unification told once via one cross-link. +teaches: Client capabilities option, Client.setRequestHandler, elicitation/create handler, sampling/createMessage handler, getSupportedElicitationModes, ClientOptions.inputRequired +source: mined from docs/client.md "Handling server-initiated requests", "Sampling", "Elicitation", "Manual multi-round-trip handling" +--> + +## Declare what your client can do + +<!-- teaches: ClientCapabilities via the Client constructor's options | salvage: docs/client.md "Handling server-initiated requests" (capabilities_declaration) --> + +```ts +// draft - API verified against packages/client/src/client/client.ts (Client constructor, ClientOptions.capabilities) +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {} }, + }, + } +); +``` + +<!-- result: the server only sends a request your client declared a capability for; the SDK enforces this on both sides. --> + +## Handle an elicitation request + +<!-- teaches: client.setRequestHandler('elicitation/create', ...), form vs URL mode, action accept/decline/cancel | salvage: docs/client.md "Elicitation" --> +<!-- code: setRequestHandler('elicitation/create') branching on request.params.mode, returning { action: 'accept', content } or { action: 'decline' } --> + +## Handle a sampling request + +<!-- teaches: client.setRequestHandler('sampling/createMessage', ...) | salvage: docs/client.md "Sampling" --> +<!-- code: setRequestHandler('sampling/createMessage') returning { model, role, content } from your LLM call --> +<!-- aside: ::: warning — sampling is deprecated (SEP-2577); link clients/roots.md? no — link /protocol-versions for the era story and the servers/sampling.md banner for the sunset --> + +## Register each handler once + +<!-- teaches: era unification — handlers are era-transparent (older push requests vs an input_required round trip reach the same handler); the page's SINGLE era cross-link | salvage: docs/client.md "Handling server-initiated requests" (era paragraph), "Manual multi-round-trip handling" --> +<!-- code: none — one ::: info container: "How these handlers are delivered differs by protocol version — see /protocol-versions." Nothing else era-shaped on this page. --> + +## Cap or disable automatic fulfilment + +<!-- teaches: ClientOptions.inputRequired ({ autoFulfill, maxRounds }), INPUT_REQUIRED_ROUNDS_EXCEEDED | salvage: docs/client.md "Manual multi-round-trip handling (2026-07-28)" --> +<!-- code: new Client(info, { inputRequired: { maxRounds: 3 } }) --> + +## Recap + +<!-- the claims this page will prove: +- Declare a capability in the Client constructor or the server never sends that request. +- setRequestHandler('elicitation/create') and setRequestHandler('sampling/createMessage') are the two handlers; each returns a plain result object. +- Register the handler once; the SDK delivers it the same way on every protocol version (one cross-link to /protocol-versions). +- inputRequired on ClientOptions caps the automatic interactive rounds. +--> diff --git a/docs-v2/clients/subscriptions.md b/docs-v2/clients/subscriptions.md new file mode 100644 index 0000000000..f4f130fadc --- /dev/null +++ b/docs-v2/clients/subscriptions.md @@ -0,0 +1,62 @@ +--- +status: scaffold +shape: how-to +--- +# Subscribe to changes + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: listen filters vs legacy subscribe. +teaches: Client.listen, McpSubscription (honoredFilter, closed, close), Client.setNotificationHandler, ClientOptions.listChanged, Client.subscribeResource, Client.unsubscribeResource +source: mined from docs/client.md "Subscription streams (2026-07-28)", "Automatic list-change tracking", "Manual notification handlers", "Subscribing to resource changes" +--> + +## Open a subscription stream + +<!-- teaches: Client.listen(filter) -> McpSubscription, setNotificationHandler dispatch | salvage: docs/client.md "Subscription streams (2026-07-28)" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts (listen(filter: SubscriptionFilter, options?): Promise<McpSubscription>) +client.setNotificationHandler('notifications/tools/list_changed', async () => { + const { tools } = await client.listTools(); + console.log('Tools changed:', tools.length); +}); + +const subscription = await client.listen({ + toolsListChanged: true, + resourceSubscriptions: ['config://app'], +}); +console.log('Server honored:', subscription.honoredFilter); +``` + +<!-- result: listen() resolves once the server acknowledges; honoredFilter is the subset the server actually agreed to deliver. --> + +## Handle the notifications + +<!-- teaches: Client.setNotificationHandler for notifications/resources/updated and the three list_changed methods | salvage: docs/client.md "Manual notification handlers" --> +<!-- code: setNotificationHandler('notifications/resources/updated', ...) re-reading the resource --> + +## Close the stream and react to closure + +<!-- teaches: subscription.close(), subscription.closed (resolves 'local' | 'graceful' | 'remote'), the re-listen loop | salvage: docs/client.md "Subscription streams" (watch-loop block) --> +<!-- code: await sub.closed; re-listen only when the reason is 'remote' --> + +## Let the SDK open the stream for you + +<!-- teaches: ClientOptions.listChanged, Client.autoOpenedSubscription | salvage: docs/client.md "Automatic list-change tracking" --> +<!-- code: new Client(info, { listChanged: { tools: true } }) — the SDK opens and filters the stream from the intersection with the server's capabilities --> + +## Fall back to legacy per-resource subscribe + +<!-- teaches: Client.subscribeResource / Client.unsubscribeResource (2025-era resources/subscribe) | salvage: docs/client.md "Subscribing to resource changes" --> +<!-- code: subscribeResource({ uri }), the same notifications/resources/updated handler, unsubscribeResource({ uri }) --> +<!-- aside: ::: info — one-line era cross-link to /protocol-versions: listen() is 2026-07-28; subscribeResource() is 2025-era. Each throws a typed error on the wrong era. --> + +## Recap + +<!-- the claims this page will prove: +- listen(filter) opens one stream carrying every change notification you asked for; honoredFilter tells you what the server granted. +- Notifications dispatch through setNotificationHandler regardless of how they arrived. +- closed resolves exactly once with the reason; there is no automatic re-listen. +- listChanged in ClientOptions opens and manages the stream for you. +- subscribeResource is the legacy per-resource path; which one your connection supports is an era question (/protocol-versions). +--> diff --git a/docs-v2/get-started/first-client.md b/docs-v2/get-started/first-client.md new file mode 100644 index 0000000000..cf1ed9eef7 --- /dev/null +++ b/docs-v2/get-started/first-client.md @@ -0,0 +1,93 @@ +--- +status: scaffold +shape: tutorial +--- + +# Build your first client + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Connect, list, call, read, close — neutral, no vendor SDK +teaches: Client, StdioClientTransport, connect, listTools, callTool, readResource, close +source: mined from docs/client-quickstart.md "Server connection management", "Query processing logic", + "Main entry point"; readResource is net-new on this path (from docs/client.md "Resources"). +vendor-neutral ruling: no Anthropic SDK in the main flow; the tool-use loop is a linked example +(proposal §3 path 1, §7 client-quickstart fate). Joins the e2e runner as a self-verifying story +(proposal §4.2) — every output block on this page is REAL once prose lands. +prereq: the weather server from get-started/first-server.md (or any stdio server script) — ONE +tool, `get-alerts(state)`, no resources, run with `npx tsx src/index.ts` (no build step). +Every command/output on this page must agree with that. +--> + +## Connect to a server + +<!-- teaches: Client, StdioClientTransport, connect | salvage: docs/client-quickstart.md "Server connection management" --> + +```ts +// draft - API verified against packages/client/src/client/client.ts and packages/client/src/client/stdio.ts +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const client = new Client({ name: 'my-first-client', version: '1.0.0' }); +const transport = new StdioClientTransport({ + // the weather server from "Build your first server" — adjust the path to where you put it + command: 'npx', + args: ['tsx', '../weather/src/index.ts'], +}); +await client.connect(transport); +``` + +<!-- result: the client spawns the server process and completes the initialize handshake; nothing prints yet + (the server's own banner lands on its stderr). --> +<!-- aside (::: info): npm install @modelcontextprotocol/client — project scaffolding is the same + setup-once step as first-server, linked not repeated. + salvage: docs/client-quickstart.md "Set up your environment". --> +<!-- aside (::: tip): the client owns the server's lifetime — never start the script yourself. + salvage: docs/client-quickstart.md "Common Error Messages" (spawn ENOENT). --> + +## List the server's tools + +<!-- teaches: listTools | salvage: docs/client-quickstart.md "Server connection management" (listTools call) --> +<!-- code: ts — await client.listTools(); log each tool's name + description --> +<!-- output: REAL — the one weather tool, `get-alerts`, with its description, verbatim from the runner. --> + +## Call a tool + +<!-- teaches: callTool, CallToolResult.content, isError | salvage: docs/client-quickstart.md "Query processing logic" (callTool + isError) --> +<!-- code: ts — await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } }); print the text content block --> +<!-- output: REAL — the formatted alert text (or "No active alerts for CA."), verbatim. --> +<!-- aside (::: tip): pass arguments that fail the tool's schema and the call returns an error + before the handler runs — the one validation-error output for this page. --> + +## Add a resource and read it + +<!-- teaches: listResources, readResource | source: net-new for the tutorial; mined from docs/client.md "Resources" --> +<!-- prereq honesty (MF1): the weather server registers NO resources, so this section first has the + reader add one (a single registerResource line in the weather project's src/index.ts, with a + cross-link to /servers/resources for depth) and then reads it from the client. --> +<!-- code: ts — await client.listResources() then await client.readResource({ uri }) on the first result --> +<!-- output: REAL — the one resource's uri/name from listResources, then its text contents. --> + +## Close the connection + +<!-- teaches: close | salvage: docs/client-quickstart.md "Interactive chat interface" (cleanup) + "Main entry point" (finally) --> +<!-- code: ts — await client.close() in a finally block --> +<!-- result: the spawned server process exits; the script terminates cleanly. --> + +## Hand the tool list to a model + +<!-- teaches: where an LLM slots in; this page stays vendor-neutral. + salvage: docs/client-quickstart.md "What's happening under the hood" (the loop, told without vendor code); + the full Anthropic tool-use loop survives as a linked, runner-excluded example (proposal §4.2). --> +<!-- code: none — one short paragraph: listTools() output is exactly what a tool-calling API wants; + link the tool-use-loop example and the host page (real-host.md). --> + +## Recap + +<!-- the 5-6 claims this page will prove: +- A Client plus one transport is a complete MCP client; connect() runs the handshake. +- StdioClientTransport spawns and owns the server process — you never start it by hand. +- listTools, callTool, readResource are the verbs; each returns typed results. +- Tool results arrive as content blocks; isError marks a failed call without throwing. +- close() tears down the transport and the spawned process. +- Nothing here needs a model; an LLM consumes listTools() output unchanged. +--> diff --git a/docs-v2/get-started/packages.md b/docs-v2/get-started/packages.md new file mode 100644 index 0000000000..b7cc9795be --- /dev/null +++ b/docs-v2/get-started/packages.md @@ -0,0 +1,79 @@ +--- +status: scaffold +shape: explanation +--- + +# Packages and subpath exports + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Which of the 10 packages, why subpaths exist +teaches: @modelcontextprotocol/server, /client, /core, /node, /express, /hono, /fastify, + /server-legacy, /codemod, the ./stdio subpath rule +source: mined from README.md "Packages" + "Installation"; packages/*/README.md; + the runtime-posture invariant (root entry web-standard, node-only code at ./stdio). +table-minimal (proposal §7): one short list, not a feature matrix; the package count (10, +incl. private core-internal) is re-verified at the GA freeze. Sits last in get-started, off +the corridor (crit 92 MF3). +--> + +## Start from one package + +<!-- teaches: @modelcontextprotocol/server root vs ./stdio subpath | salvage: README.md "Getting Started" imports --> + +```ts +// draft - API verified against packages/server/src/index.ts and packages/server/src/stdio.ts +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +``` + +<!-- result: everything in the server tutorial came from this one package; the second import + line is the only place a subpath appeared, and this page explains why. --> + +## Pick the package for your side of the protocol + +<!-- teaches: server vs client as the two installable starting points | salvage: README.md "Packages" + "Installation" --> +<!-- code: sh — `npm install @modelcontextprotocol/server` and `npm install @modelcontextprotocol/client`, + the only two install commands most readers ever run --> + +## Keep node-only code behind the ./stdio subpath + +<!-- teaches: WHY subpath exports exist — the root entry of server and client is runtime-neutral + (browser / Workers safe); anything that spawns processes or imports node: builtins lives at + ./stdio. | salvage: packages/server/src/stdio.ts and packages/client/src/stdio.ts header + comments; report-86 invariant. --> +<!-- code: ts — the failing counter-example as a comment: importing StdioClientTransport from the + root entry is not possible by design; the bundler never sees node:child_process unless you + import the subpath --> + +## Add a framework adapter when you serve over HTTP + +<!-- teaches: @modelcontextprotocol/node, /express, /hono, /fastify are optional thin adapters + around createMcpHandler — install only the one matching your framework. + salvage: README.md "Middleware packages (optional)" + "Optional middleware packages" install block. --> +<!-- code: sh — `npm install @modelcontextprotocol/express express` (one representative line; + the four recipe pages under serving/ carry their own) --> + +## Reach for core only to validate raw wire JSON + +<!-- teaches: @modelcontextprotocol/core ships ONLY the Zod schema constants; server and client + stay Zod-free on their public surface. Gateways and proxies are its audience. + salvage: packages/core/README.md opening paragraphs. --> +<!-- code: ts — CallToolResultSchema.safeParse(json) as the one canonical use --> + +## Leave server-legacy and codemod to the migration guide + +<!-- teaches: @modelcontextprotocol/server-legacy (v1-era SSE transport + OAuth Authorization + Server) and @modelcontextprotocol/codemod (the v1 -> v2 CLI) exist to be linked, not taught. + salvage: docs/faq.md "Why did we remove server SSE transport?" + "Where are the server auth + helpers?"; link target is /migration/upgrade-to-v2. --> +<!-- code: none — two sentences and a link. --> + +## Recap + +<!-- the 5 claims this page will prove: +- Two packages cover almost everyone: server to build servers, client to build clients. +- Package roots are runtime-neutral; node-only code (process spawning, stdio) lives at ./stdio. +- The framework adapters (node, express, hono, fastify) are optional and thin; pick one. +- core exists only for raw Zod schema validation of wire JSON. +- server-legacy and codemod are migration surfaces, reached from the migration guide. +--> diff --git a/docs-v2/get-started/real-host.md b/docs-v2/get-started/real-host.md new file mode 100644 index 0000000000..bb73c1081f --- /dev/null +++ b/docs-v2/get-started/real-host.md @@ -0,0 +1,85 @@ +--- +status: scaffold +shape: tutorial +--- + +# Plug into a real host + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Plug your server into Claude Code / VS Code / Cursor +teaches: the host launch contract (command + args), .vscode/mcp.json, host registration, agent-mode tool call +source: mined from docs/server-quickstart.md "Running the server", "Testing your server in VS Code", "What's happening under the hood", "Troubleshooting" +host order (Felix ruling): VS Code leads the main flow; Claude Code and Cursor are H3 subsections after it. +prereq: the weather server from get-started/first-server.md — one tool (`get-alerts(state)`), +run with `npx tsx src/index.ts` from the project root; there is no build step. +Every host on this page is given that one command. +--> + +## Hand the host a launch command + +<!-- teaches: the launch contract — a host starts your server as a child process from a command + args; first-server's `src/index.ts` already ends with the serve call, so `npx tsx src/index.ts` IS the entry. No new server code on this page. | salvage: docs/server-quickstart.md "Running the server" --> + +```ts +// draft - API verified against packages/server/src/server/serveStdio.ts +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// the last lines of src/index.ts from "Build your first server" — createServer is the factory you wrote there +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); +``` + +<!-- result: `npx tsx src/index.ts` (from the project root) starts it, waits silently on stdin, and logs only to stderr — the command and behavior every host below relies on. --> +<!-- aside (::: warning): console.error, never console.log — stdout is the JSON-RPC channel. + salvage: docs/server-quickstart.md IMPORTANT box (lines 362-363). --> + +## Register the server in VS Code + +<!-- teaches: .vscode/mcp.json (type: stdio, command, args) | salvage: docs/server-quickstart.md "Configure the MCP server" --> +<!-- code: json — .vscode/mcp.json with one "weather" stdio entry: command "npx", args ["tsx", "src/index.ts"] (the workspace root is the cwd) --> +<!-- result: VS Code prompts to trust the server; "MCP: List Servers" shows `weather` running. --> +<!-- aside (::: info): VS Code 1.99+, GitHub Copilot extension; Copilot Free is enough. + salvage: docs/server-quickstart.md "Prerequisites" under "Testing your server in VS Code". --> + +## Call the tool from Copilot Chat + +<!-- teaches: agent mode, tool approval, the conversion moment | salvage: docs/server-quickstart.md "Use the tools" --> +<!-- code: text — the prompt ("Are there any weather alerts in Texas?") and the assistant turn that shows + get-alerts being invoked; REAL transcript captured when prose lands. --> +<!-- result: the assistant calls get-alerts with a two-letter state code and answers from its output. --> + +## Trace the round trip + +<!-- teaches: host -> model -> tools/call -> server -> model loop | salvage: docs/server-quickstart.md "What's happening under the hood" --> +<!-- code: none — six-step numbered sequence (question -> model picks tool -> client sends tools/call -> + server handler runs -> result back to model -> answer). No new API. --> + +## Connect other hosts + +<!-- teaches: the same stdio command works in any MCP host; only the config file differs. + salvage: net-new (current docs cover VS Code only; modelcontextprotocol.io clients list is the link target). --> + +### Claude Code + +<!-- code: sh — `claude mcp add weather -- npx tsx src/index.ts` (verify exact CLI form in the prose tranche) --> +<!-- result: /mcp lists `weather` as connected; the same prompts work. --> + +### Cursor + +<!-- code: json — .cursor/mcp.json, same { command, args } shape as VS Code --> +<!-- result: the server appears under Cursor's MCP settings with `get-alerts` listed. --> + +## Fix a host that does not see your tools + +<!-- teaches: the three real failure modes | salvage: docs/server-quickstart.md "Troubleshooting" (VS Code <details>) --> +<!-- code: sh — `npx tsx src/index.ts` started by hand: it must sit and wait, not print to stdout and exit. --> +<!-- result: a server that starts, waits, and logs only to stderr is one the host can attach to. --> + +## Recap + +<!-- the 4-5 claims this page will prove: +- A host launches your server from a command + args; `npx tsx src/index.ts` is that command — no build step. +- One .vscode/mcp.json entry registers a stdio server in VS Code. +- In agent mode the model discovers your tools from their schemas and calls them unprompted. +- Claude Code and Cursor take the same command; only where you put it differs. +- stdout belongs to JSON-RPC; log to stderr or the host drops the connection. +--> diff --git a/docs-v2/migration/index.md b/docs-v2/migration/index.md new file mode 100644 index 0000000000..c9c4161b60 --- /dev/null +++ b/docs-v2/migration/index.md @@ -0,0 +1,54 @@ +--- +title: Migration Guides +children: + - ./upgrade-to-v2.md + - ./support-2026-07-28.md +--- + +# MCP TypeScript SDK — Migration Guides + +Pick the guide for your starting point. + +## Upgrading from v1.x (`@modelcontextprotocol/sdk`) + +→ **[upgrade-to-v2.md](./upgrade-to-v2.md)** + +You are on the monolithic `@modelcontextprotocol/sdk` package and want to move to the +v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …). + +Start by running the codemod: + +```bash +npx @modelcontextprotocol/codemod@alpha v1-to-v2 . +``` + +Run it at the package root (`.`) — real projects import the SDK from `test/`, +`scripts/`, and fixtures too, and those rewrites are missed when you point it at `./src`. + +The codemod handles most mechanical renames. The guide covers what it can't. The +codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 protocol +revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) is +architectural and not codemod-automatable; see [support-2026-07-28.md](./support-2026-07-28.md). + +## Already on v2, adopting protocol revision 2026-07-28 + +→ **[support-2026-07-28.md](./support-2026-07-28.md)** + +You are already on the v2 packages and want your server or client to speak the +2026-07-28 protocol revision (per-request `_meta` envelope, `createMcpHandler`, +`serveStdio`, `versionNegotiation`, multi-round-trip requests, per-era wire codecs). + +This guide also covers code written against an earlier **v2 alpha** that read +wire-only members (`resultType`, envelope keys) directly. + +## Using an LLM agent to migrate + +[upgrade-to-v2.md](./upgrade-to-v2.md) is the agent skill — it carries skill +frontmatter and is structured for mechanical application. Point the agent at +the codemod first; the guide is the codemod's companion for what's left. + +## See also + +- [`@modelcontextprotocol/codemod` README](../../packages/codemod/README.md) +- [FAQ](../faq.md) +- [Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) diff --git a/docs-v2/migration/support-2026-07-28.md b/docs-v2/migration/support-2026-07-28.md new file mode 100644 index 0000000000..ce733f729e --- /dev/null +++ b/docs-v2/migration/support-2026-07-28.md @@ -0,0 +1,633 @@ +--- +title: Supporting protocol revision 2026-07-28 +--- + +# Supporting protocol revision 2026-07-28 + +This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 +protocol revision — and for code written against an earlier **v2 alpha** that read +wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with +[upgrade-to-v2.md](./upgrade-to-v2.md) instead. + +> **Schema artifact:** until the revision is finalized, the spec repository publishes +> the 2026-07-28 schema under `schema/draft/` — there is no `schema/2026-07-28/` +> directory yet. Tooling that vendors per-revision schema artifacts should track +> `draft/` and note the divergence. + +Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed +`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written +for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries +below. + +## Contents + +- [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) +- [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) +- [Auth on 2026-07-28](#auth-on-2026-07-28) +- [Per-era wire codecs](#per-era-wire-codecs) +- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) +- [Multi-round-trip requests](#multi-round-trip-requests) +- [Legacy shim for `input_required`](#legacy-shim-for-input_required) +- [`subscriptions/listen`](#subscriptionslisten) +- [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) +- [Cache fields and cache hints](#cache-fields-and-cache-hints) +- [Tasks: deprecated wire vocabulary](#tasks-deprecated-wire-vocabulary) +- [Appendix: 2025-era vs 2026-era behavior matrix](#appendix-2025-era-vs-2026-era-behavior-matrix) + +--- + +## Serving the 2026-07-28 revision + +These entry points are documented in full in the server and client guides; this section +contextualizes them as the migration path. + +### Client side: `versionNegotiation` + +By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, +byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — +see [client.md › Protocol version negotiation](../client.md#protocol-version-negotiation-2026-07-28-revision). + +```typescript +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); +client.getProtocolEra(); // 'modern' | 'legacy' +``` + +- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. +- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on + the same connection against a 2025-only server (one extra round trip). +- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with + `SdkError(EraNegotiationFailed)` against a 2025-only server. + +`ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy +`initialize` handshake offers (see +[upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection--dispatch)) +— shapes `'auto'`: the modern candidates are the option's modern entries (when it lists +any; otherwise the SDK's default modern set), and legacy fallback is available only if +the list has a pre-2026 entry. A `{ pin }` is honored as given — it must name a modern +revision but is not checked against the list. + +#### Probe policy + +Failure semantics under `'auto'` are deliberately conservative but never silent about +infrastructure problems. Anything the probe does not positively recognize as modern +falls back to the legacy era — provided the supported-versions list still contains a +2025-era revision; with a modern-only list `connect()` rejects with +`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect +error. Probe timeouts are **transport-aware**: on **stdio** a server that does not +answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` +on the same stream (some legacy servers never respond to unknown pre-`initialize` +requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — +a dead HTTP server is never misreported as legacy. One browser-specific exception: an +opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because +deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers. + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000, // default: the standard request timeout + maxRetries: 0 // default: no retries — governs timeout re-sends only + } +} +``` + +`maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective +continuation — select-and-continue with a mutual version — is a separate negotiation step +and is never counted against it). + +**Who should not default to `'auto'`:** spawn-per-invocation CLI and debugging tools. +On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls +`connect()` for the full probe timeout before falling back; and the probe round trip +changes recorded transcripts/raw logs, which matters for tools whose value is +byte-stable observation. Such tools should keep the default and expose `'auto'` / +a pin as an explicit flag. + +The probe request itself already carries the per-request `_meta` envelope +(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`) — +**before** the era is known. Once a modern era is negotiated the client auto-attaches +the envelope to every outgoing request and notification. Tooling that classifies +traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback +path also begins with one enveloped probe. A gateway/worker fleet can skip the +probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. + +### Server over HTTP: `createMcpHandler` + +`createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry +that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also +serves 2025-era traffic per request through the established stateless idiom. One +factory, one endpoint, both eras. + +```typescript +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory backs both eras + return server; +}); +// Web-standard runtimes: export default handler; +// Node frameworks: app.all('/mcp', toNodeHandler(handler)) from @modelcontextprotocol/node +``` + +A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, +fresh transport per request) maps directly onto the default entry. An existing +**sessionful** v1 Streamable HTTP setup keeps serving 2025 clients by routing it in +front of a strict (`legacy: 'reject'`) entry with `isLegacyRequest(request)`: + +```typescript +const modern = createMcpHandler(factory, { legacy: 'reject' }); +export default { + async fetch(request: Request) { + if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); + return modern.fetch(request); + } +}; +``` + +`isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope +claim; route `false` traffic to the modern handler (a malformed modern claim is `false` +and answered `-32602` / `-32020` by the modern path). The handler is web-standards-only +(`{ fetch, close, notify, bus }`); on Node frameworks wrap once with +`toNodeHandler(handler, { onerror? })` from `@modelcontextprotocol/node`. The exported +`legacyStatelessFallback(factory)` is the same stateless 2025 serving as a standalone +fetch-shaped handler. + +> **If you were on a v2 alpha:** `handler.node(req, res, body)` is gone — replace with +> `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. +> `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from +> `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. + +### Server over stdio / long-lived connections: `serveStdio` + +A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` +serves only the 2025-era protocol — upgrading the SDK changes nothing about what it puts +on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the +connection-pinned `serveStdio(() => buildServer())` entry from +`@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, +and one factory instance is pinned per connection. See +[server.md › Serving the 2026-07-28 draft revision on stdio](../server.md#serving-the-2026-07-28-draft-revision-on-stdio). + +To migrate an existing stdio server, replace +`await server.connect(new StdioServerTransport())` with +`serveStdio(() => buildServer())`. Pass `{ legacy: 'reject' }` to refuse 2025-era +openings. On 2026-pinned connections, `getClientCapabilities()` / `getClientVersion()` +return `undefined` (no `initialize` ever runs there) and handlers read per-request +identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned +revision. + +A client whose connection negotiated a modern era drops inbound server→client JSON-RPC +requests (the 2026 era has no such channel) instead of answering them; legacy-era +connections are unchanged. + +### In-process testing + +There is no in-memory serving entry — `InMemoryTransport.createLinkedPair()` connects +2025-era instances only. To exercise 2026-07-28 behavior in tests without sockets, +drive `createMcpHandler` directly through its fetch function: + +```typescript +const handler = createMcpHandler(buildServer); +const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) +}); +``` + +The URL is never dialed — `handler.fetch` serves the request in-process. For stdio-era +coverage, spawn `serveStdio` as a child process. + +### Client cancellation on Streamable HTTP + +On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request +(`signal` / timeout) closes that request's SSE response stream — the spec cancellation +signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling +code. 2025-era connections and stdio at any era still send `notifications/cancelled`. +Custom `Transport` implementations that open one underlying request per outbound message +and honor `TransportSendOptions.requestSignal` may opt in by declaring +`readonly hasPerRequestStream = true`. + +### `ctx.mcpReq.log()` and the per-request `logLevel` + +On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the +`io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the +`logging/setLevel` RPC). When the key is **absent** the server emits no +`notifications/message` for that request — absence is opt-out, not "no filter". The SDK +`Client` does not auto-attach `logLevel`, so handler logs on a default 2026-era exchange +are silently suppressed until the client opts in. + +--- + +## Replacing per-session state: `requestState` + +The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per +request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the +session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an +opaque string the server returns with `inputRequired(...)` and the client echoes +byte-for-byte on the retry. Read it back with the typed accessor +`ctx.mcpReq.requestState<T>()` — it returns the payload your configured verify hook +decoded (see below), the raw wire string when no hook is configured, or `undefined` +when the round carried no state. + +`requestState` round-trips through the client and is therefore **untrusted input** — +integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating +method/parameters, and an expiry) and reject failed verification on re-entry. Configure +`ServerOptions.requestState.verify` and the seam runs it before the handler whenever +`requestState` is present (a thrown rejection answers `-32602` above the tool funnel). +The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns +`{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` +is exactly the function you assign to the hook. The codec is **signed, not encrypted** +(the client can base64url-decode the payload). `mint<T>` and +`ctx.mcpReq.requestState<T>()` are the typed encode/read pair: the seam captures what +`verify` returns and the accessor hands it to the handler already decoded — no second +`verify` call. See `examples/mrtr/server.ts` and +[Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. + +**Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry +carries only that round's responses, never earlier rounds' (the modern client driver +and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not +accumulate). A flow with more than one input round therefore threads everything it has +learned through `requestState`, as a discriminated union of phases, and switches on the +phase rather than probing which response keys arrived: + +```typescript +type BrainstormState = + | { step: 'awaiting-count' } + | { step: 'awaiting-custom-count'; topic: string } + | { step: 'awaiting-ideas'; topic: string; count: number }; + +const stateCodec = createRequestStateCodec<BrainstormState>({ key: SECRET }); +// ServerOptions: { requestState: { verify: stateCodec.verify } } + +async (args, ctx) => { + const state = ctx.mcpReq.requestState<BrainstormState>(); + switch (state?.step) { + case undefined: // first call — ask for the count + return inputRequired({ + inputRequests: { count: inputRequired.elicit({ … }) }, + requestState: await stateCodec.mint({ step: 'awaiting-count' }) + }); + case 'awaiting-count': { + const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA); + // …decide: follow-up question or the sampling round, carrying + // everything learned so far inside the next minted state… + } + case 'awaiting-ideas': { + const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); + return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic); + } + } +}; +``` + +Each `case` knows exactly which answer to read and which data is in scope — the state +machine is explicit, and the same handler runs unchanged on 2025-era connections +through the legacy shim. + +--- + +## Auth on 2026-07-28 + +The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` validation, +SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are +implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every +era once enabled. The migration steps live in +[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, +enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) +to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement +`discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle +`InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire +layer. + +--- + +## Per-era wire codecs + +The wire layer is split into per-revision codecs inside the (private, bundled) core: one +codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves +2026-07-28. The codec is selected by the negotiated protocol version, which is +**connection state** on the `Client`/`Server` instance (instances with no negotiated +version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) +no longer switches the era per message — it is validated against the instance era, and a +mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` +for requests; drop + `onerror` for notifications). + +Methods deleted by a protocol revision are **physically absent** from that era's +registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a +handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` +toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws +`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. + +If you were on a v2 alpha and consumed wire schemas directly: + +| v2-alpha pattern | Mechanical fix | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) | +| `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | +| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | + +The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x +impact** — these members did not exist before 2026-07-28. The neutral-model wire +tightening that **does** affect v1 code (`content` required, custom-handler `_meta` +passthrough, `specTypeSchemas` narrowing) is in +[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era). + +> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered: +> `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, +> `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never +> existed in v1); v2-alpha code that hard-coded the old literals must update — prefer +> `ProtocolErrorCode.*` / `HEADER_MISMATCH_ERROR_CODE`. + +--- + +## Wire-only members hidden from public types + +The 2026-07-28 wire-level bookkeeping is handled internally and never reaches +application code: the `resultType` discrimination field, the reserved per-request +`_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`), +and the multi-round-trip retry fields (`inputResponses`, `requestState`). + +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, + `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer + consumes it before results reach your code. +- **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / + `cacheScope` on `server/discover` are read by the client's response-cache layer and + are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — + but they are not removed at runtime: the returned object still carries both, readable + via a cast. The wire parse defaults absent or malformed hints to `0` / `'private'`, + so only tooling that must distinguish an omitted hint from an advertised default + needs raw frames. +- **High-level methods return the named public types** (`client.callTool()` → + `Promise<CallToolResult>`, etc.). Handler return positions are unaffected. +- **Reserved envelope keys and retry fields appear in no public params/result type.** + The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported. + +The protocol layer enforces the same boundary at runtime: + +- **Envelope lift.** On inbound requests and notifications, the reserved + `io.modelcontextprotocol/*` keys are lifted out of `params._meta` before handlers run. + For requests the envelope is readable at `ctx.mcpReq.envelope` + (typed `Partial<RequestMetaEnvelope>`); for notifications there is no per-message + context, so lifted envelope keys are dropped. On requests only, `inputResponses` / + `requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` / + the `ctx.mcpReq.requestState()` accessor; notification params are never touched. +- **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming + 2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too). + The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names + `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses + them as ordinary top-level params has them lifted out of `request.params` (still + readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`). +- **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed + and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver; + any other kind rejects with `SdkError(UnsupportedResultType)` (kind in + `error.data.resultType`). On a 2025-era connection a foreign `resultType` is stripped + before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is + a spec violation surfaced as a typed error. + +**If you were on a v2 alpha** and read the wire shape directly: + +| Pattern | Mechanical fix | +| -------------------------------------- | --------------------------------------------------------------------------------- | +| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | +| `Result['resultType']` type reference | remove; the member is no longer declared | +| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | + +`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) +for transports that classify inbound messages at the edge; dispatch validates it against +the instance's negotiated era. + +--- + +## Multi-round-trip requests + +The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers +obtain client input (elicitation, sampling, roots) **in-band** by returning +`inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the +client retries the original call with the responses. + +| Handler serving 2026-07-28 requests | Mechanical fix | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | +| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | +| handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests | + +`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from +`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs +(`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, +`ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) +fail with a typed local error before anything reaches the wire; their behavior toward +2025-era requests is unchanged. + +`requestState` round-trips as an opaque, **untrusted** string — see +[Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) +for the sealing helper and verification hook. + +**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers +`input_required`, the client fulfils the embedded requests through the same handlers +registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | +'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact +`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or +opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per +call with `allowInputRequired: true` plus `withInputRequired()`. Expect +`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. + +**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a +structural read with an unvalidated cast), two typed readers ship from +`@modelcontextprotocol/server`: + +- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous + Standard Schema, e.g. a zod object): validates the untrusted accepted content and + returns it typed, or `undefined` on mismatch/decline/missing. +- `inputResponse(responses, key)` — discriminated view + (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) + for decline/cancel detection and the non-elicitation kinds. + +Content conveniences stay in your code — e.g. the text of a sampling response is a +one-liner over the discriminated view: + +```typescript +const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); +const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; +const text = block?.type === 'text' ? block.text : undefined; +``` + +--- + +## Legacy shim for `input_required` + +An `input_required` return on a **2025-era** connection is served by the SDK's legacy +shim, on by default: each embedded request is sent as a real server→client request +(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — +stamped with the originating request's id, so on sessionful Streamable HTTP the +requests ride the originating POST's stream — and the handler is re-entered with the +collected `inputResponses` until it returns a final result. Handlers are **written +once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs +remain available for code that still calls them directly. + +The handler cannot tell which era fulfilled it — the shim mirrors the modern client +driver's semantics exactly: + +- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); + multi-step flows thread earlier answers through `requestState`. +- `requestState` is echoed byte-exact, and the configured + `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would + on a modern wire retry (so TTL expiry behaves identically; a rejection answers the + frozen `-32602`). +- Responses arrive as the bare result objects, era-wire-shape-validated only: + elicitation accepted content is NOT re-checked against `requestedSchema` — + exactly as on the modern era — so the handler validates with the + schema-aware `acceptedContent(responses, key, schema)` overload and can + re-issue the request instead of the call dying on a mistyped form field. +- Rounds with no embedded requests (requestState-only) are paced at 250ms. +- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the + 2025-11-25 wire requires one; the 2026 in-band shape has none). + +Knobs live at `ServerOptions.inputRequired`: + +| Member | Default | Meaning | +| --- | --- | --- | +| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | +| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | +| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | + +Failures surface **per family**: `tools/call` failures (capability refusal, a failed +leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts +already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC +errors. Server bugs (malformed input-required results) fail loudly on both eras. + +The shim emits no progress of its own. The originating request's `progressToken` +identifies a single must-increase stream that belongs to the handler — injecting +synthetic ticks into it cannot compose with handler-emitted progress (one stream, +one author), so the shim never writes to it: a 2025 client watching a multi-round +flow sees exactly what a hand-written 2025 push-style handler would have produced. +A handler that reports progress across rounds should derive its values from its +phase state so they increase across re-entries — the token spans the whole flow. + +**Inherited limits** (the same ones hand-written push-style handlers have today): + +- The shim pre-checks each embedded request kind against the client capabilities + declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration + counts as form support — the pre-mode meaning, same as the modern `-32021` gate). + Capability-less clients get a clean refusal, never a hang. +- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a + fresh instance per request: no initialize handshake, no return path for + server→client requests. The shim degrades to the clean capability refusal there — + full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. +- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client + requests mid-call: the transport drops them, so a shim leg waits out + `roundTimeoutMs` before failing per family — the same undeliverable class as + today's `elicitInput` in that configuration, which waits out its own 60s + default. Interactive tools need a streaming-capable session. +- The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation + is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation + response. + +--- + +## `subscriptions/listen` + +The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and +`resources/updated` only on a `subscriptions/listen` stream the client opened — the +server never sends an un-requested notification type. + +**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` +themselves. `createMcpHandler` returns +`.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed +publish sugar over an in-process bus (supply your own `ServerEventBus` for multi-process +deployments). On stdio, `serveStdio` routes the pinned instance's existing +`send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era +unsolicited delivery model is unchanged on legacy connections. + +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection +the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of +the configured sub-options and the server-advertised `listChanged` capabilities, so the +same handlers fire on every published change. `client.listen(filter)` opens a stream +explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request +`notifications/resources/updated` via the `resourceSubscriptions` field of the listen +filter instead. + +**Graceful close.** When the server closes the listen stream deliberately (entry +`close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before +closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close +without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen +if you still want events. + +--- + +## `Mcp-Param-*` and standard headers (SEP-2243) + +On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool +arguments designated with `x-mcp-header` in the tool's `inputSchema` into +`Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed), and +`createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a +present body value, malformed, or disagree with the body — `400 Bad Request` with +JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the +`Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler` +validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`, +`Mcp-Name`) against the body on the modern path with the same rejection. + +**Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request, +an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the +pending request id is delivered in-band as a `ProtocolError` (so the `-32020` recovery +retry can fire). Legacy-era exchanges and generic HTTP failures still surface as +`SdkHttpError`. + +Additive options: `CallToolRequestOptions.toolDefinition` (pass the tool definition +directly so mirroring and output-schema validation run without a prior `tools/list`), +`TransportSendOptions.headers` (per-request HTTP headers; reserved standard/auth header +names are skipped). Browser clients skip mirroring (dynamically named headers cannot be +statically allow-listed for credentialed CORS). + +--- + +## Cache fields and cache hints + +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results. +When serving that revision, the SDK always emits both fields, defaulting to `ttlMs: 0` +and `cacheScope: 'private'` (the most conservative policy). To advertise a real cache +policy, set `ServerOptions.cacheHints` (per-operation) or `cacheHint` on a +`registerResource` metadata object; resolution is per field, most-specific author first. +2025-era responses never carry these fields. + +--- + +## Tasks: deprecated wire vocabulary + +The task **wire surface** defined by the 2025-11-25 protocol revision is still exported +for interoperability with peers on that revision: the task Zod schemas and inferred +types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, +`GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, +`TaskAugmentedRequestParams`), the task members of the request/result/notification union +types, the `tasks` capability key, `isTaskAugmentedRequestParams`, and +`RELATED_TASK_META_KEY`. All are now `@deprecated` (importable wire vocabulary only; +removable at the major version that drops 2025-era support). + +Task methods are excluded from the typed method maps: `RequestMethod` / `RequestTypeMap` +/ `ResultTypeMap` / `NotificationTypeMap` have no `tasks/*` or +`notifications/tasks/status` entries, so the method-keyed overloads of `request()`, +`ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task +methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no +`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. Where +task interop is genuinely required, use the explicit-schema custom-method form +(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` +requests → `-32601`. + +The experimental tasks **interception** layer is removed entirely — see +[upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). + +--- + +## Appendix: 2025-era vs 2026-era behavior matrix + +| Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 | +| ------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | +| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | +| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | +| Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | +| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler | +| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | +| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | +| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) | +| `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) | +| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | diff --git a/docs-v2/migration/upgrade-to-v2.md b/docs-v2/migration/upgrade-to-v2.md new file mode 100644 index 0000000000..924d3174cc --- /dev/null +++ b/docs-v2/migration/upgrade-to-v2.md @@ -0,0 +1,1237 @@ +--- +title: Upgrading from v1.x to v2 +name: migrate-v1-to-v2 +description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. +--- + +# Upgrading from v1.x to v2 + +This guide covers upgrading from `@modelcontextprotocol/sdk` (v1.x) to the v2 packages. +It is written for shell-capable agents and humans alike: run the codemod first, then +work through the manual sections for what the codemod can't rewrite. + +If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, see +[support-2026-07-28.md](./support-2026-07-28.md) instead. + +## TL;DR — quick path + +1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM + only; CommonJS callers must use dynamic `import()`. +2. **Run the codemod.** + ```bash + npx @modelcontextprotocol/codemod@alpha v1-to-v2 . + ``` + Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`, + and real projects import the SDK from `test/`, `scripts/`, and fixtures too. +3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is + marked in place: + ```bash + grep -rn '@mcp-codemod-error' . + ``` +4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the + [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. +5. **Format.** The codemod rewrites the AST without reformatting — run your formatter on + the changed files (`prettier --write` / `eslint --fix` / `biome format --write`); the + codemod prints the exact command after it runs. +6. **Run your tests.** + +## Contents + +- [What the codemod handles](#what-the-codemod-handles) +- [What the codemod does NOT handle](#what-the-codemod-does-not-handle) +- [Manual changes](#manual-changes-what-the-codemod-does-not-handle) + - [Packaging & runtime](#packaging--runtime) + - [Imports & transports](#imports--transports) + - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) + - [Server registration API](#server-registration-api) + - [HTTP & headers](#http--headers) + - [Errors](#errors) + - [Auth](#auth) + - [Types & schemas](#types--schemas) + - [Behavioral changes](#behavioral-changes) +- [Enhancements](#enhancements) +- [Unchanged APIs](#unchanged-apis) +- [Need help?](#need-help) + +--- + +## What the codemod handles + +The codemod ([`@modelcontextprotocol/codemod`](../../packages/codemod/README.md)) +mechanically applies every rename whose mapping is fixed. The mappings are the +**source of truth** — they live in the codemod package and are not reproduced here: + +| Mapping | Source file | +| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | +| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | +| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | +| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | + +In addition the codemod: + +- Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages + your imports actually use). +- Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` + / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / + `uriSchema` raw Zod shapes with `z.object()`. +- Drops the result-schema argument from `client.request()` / `client.callTool()` for + spec methods. +- Routes the spec Zod `*Schema` constants imported from `sdk/types.js` to + `@modelcontextprotocol/core` (mixed imports are split; `.parse()` / `.safeParse()` + calls are left untouched). Task-handler schema constants + (`GetTaskRequestSchema` etc.) used as `setRequestHandler` args are **not** rewritten + — the experimental tasks feature was removed (SEP-2663), so each such registration + is marked with an action-required diagnostic instead (see + [Experimental tasks interception removed](#experimental-tasks-interception-removed)). +- Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members + (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode`. +- Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import + (constructor calls are marked for review — argument shape changed). +- Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the + import (a warning notes `Headers` uses `.get()`/`.set()`, not bracket access). +- Rewrites `SchemaInput<T>` → `StandardSchemaWithJSON.InferInput<T>`. +- Renames `RequestHandlerExtra` → `ServerContext` / `ClientContext` and the `extra` + parameter to `ctx`. +- Rewrites `vi.mock` / `jest.mock` and dynamic `import()` paths. +- Renames the `ResourceTemplate` **type** imported from `@modelcontextprotocol/sdk/types.js` + to `ResourceTemplateType` (the spec wire type). The `ResourceTemplate` URI-template + helper **class** from `server/mcp.js` keeps its name and is not renamed. +- Drops `@modelcontextprotocol/sdk/server/zod-compat.js` imports. + +## What the codemod does NOT handle + +Each of these maps to a manual section below. The codemod marks every site it +recognized but could not safely rewrite with an `@mcp-codemod-error` comment. + +- **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) +- **`new Headers()` / `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` + and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but + converting bracket access to `.get()` and wrapping plain objects with `new Headers()` + is manual. → [HTTP & headers](#http--headers) +- **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from + `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls + alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) +- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + + `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) +- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → + `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is + judgment. → [Errors](#errors) +- **Namespace schema access** — `import * as t from '…/types.js'` + + `t.CallToolResultSchema.parse(…)` can't be split per-symbol; the codemod flags it + action-required — re-import the schema from `@modelcontextprotocol/core` by hand. + → [Types & schemas](#types--schemas) +- **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator + compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) + +--- + +## Manual changes (what the codemod does not handle) + +### Packaging & runtime + +The single `@modelcontextprotocol/sdk` package is split: + +| v1 | v2 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | +| | `@modelcontextprotocol/core-internal` (internal — never import directly) | +| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | + +`@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared +types from `@modelcontextprotocol/core-internal`, so import types and error classes from +whichever package you already depend on. `@modelcontextprotocol/core-internal` is +`private: true` and is not published — **do not import from it directly.** +`@modelcontextprotocol/core` is the public Zod-schema package (raw `*Schema` constants +only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. + +After the codemod runs, verify the dependencies in `package.json`: the swap rewrites +the **nearest** manifest found walking up from the target directory — one manifest +total, so workspace-member manifests in a monorepo are not visited (remove the v1 +dependency from those by hand once nothing imports it). On already-migrated sources +the codemod still removes the v1 dependency but may not add the v2 packages you need +— check both directions. + +The framework adapter packages declare their framework as a **peer dependency** +(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the +`@modelcontextprotocol/*` packages your imports use, but does not add the framework +peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but +does **not** require the `hono` framework — your package manager may emit a harmless +unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). + +v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS +(`require()`), either migrate to ESM or use dynamic `import()`. + +### Imports & transports + +The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via +[`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts). +A few transports need a decision the codemod can't make: + +- **`StreamableHTTPServerTransport` → which runtime?** The codemod renames it to + `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. If you deploy + to a web-standard runtime (Cloudflare Workers, Deno, Bun), use + `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` + instead. **Decision rule:** if your handler receives a Node `IncomingMessage` / + `ServerResponse`, use `@modelcontextprotocol/node`; if it receives a web-standard + `Request` and returns a `Response`, use `@modelcontextprotocol/server`. +- **stdio transports moved to a `./stdio` subpath.** Import `StdioClientTransport`, + `getDefaultEnvironment`, `DEFAULT_INHERITED_ENV_VARS`, and `StdioServerParameters` + from `@modelcontextprotocol/client/stdio`; import `StdioServerTransport` from + `@modelcontextprotocol/server/stdio`. The package root barrels do **not** export + these (the root entries are runtime-neutral so browser/Workers bundlers can consume + them). The stdio utilities `ReadBuffer`, `serializeMessage`, `deserializeMessage` + stay in the root barrel. +- **Zod `*Schema` constants → `@modelcontextprotocol/core`.** A mixed + `import { CallToolResult, CallToolResultSchema } from '…/types.js'` is split by the + codemod — see [Types & schemas](#types--schemas). + + ```typescript + // v1 + import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + // v2 + import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + ``` + +- **`SSEServerTransport`** is removed. Migrate to Streamable HTTP. A frozen v1 copy is + available from `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. +- **`WebSocketClientTransport`** is removed (WebSocket is not a spec transport). Use + `StreamableHTTPClientTransport` for remote servers or `StdioClientTransport` for + local servers; the `Transport` interface is exported if you need a custom + implementation. +- **`InMemoryTransport`** is now exported from `@modelcontextprotocol/client` and + `@modelcontextprotocol/server` (both re-export it): + + ```typescript + // v1 + import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + // v2 + import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client + ``` + +- **`EventStore`, `StreamId`, `EventId`** are exported from `@modelcontextprotocol/server` + only (v1 re-exported them alongside the transport from `sdk/server/streamableHttp.js`; + `@modelcontextprotocol/node` does not). +- **Server auth split.** Resource Server helpers (`requireBearerAuth`, + `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) + → `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, + `OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`, + `authenticateClient`, `metadataHandler`, `createOAuthMetadata`, + `authorizationHandler` / `tokenHandler` / `revocationHandler` / + `clientRegistrationHandler`) → `@modelcontextprotocol/server-legacy/auth` + (deprecated, frozen v1 copy); migrate AS to a dedicated IdP/OAuth library. `AuthInfo` + is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. + + The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) + routes every `…/server/auth/**` deep path (including + `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, + `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to + `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / + `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The + AS→`server-legacy` routing is conservative — re-point RS-only call sites + (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. + +### Low-level protocol & handler context (`ctx`) + +The second parameter to every request handler — previously the flat `RequestHandlerExtra` +object named `extra` — is now a structured **context** object named `ctx`. This is the +`ctx` that appears throughout the rest of this guide. + +The codemod renames the parameter and remaps property access via +[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts). +A few mappings need optional-chaining adjustment (the `http` group is `undefined` on +stdio): + +| v1 (`extra.*`) | v2 (`ctx.*`) | Note | +| ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ | +| `extra.signal` | `ctx.mcpReq.signal` | | +| `extra.requestId` | `ctx.mcpReq.id` | | +| `extra._meta` | `ctx.mcpReq._meta` | | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | +| `extra.sessionId` | `ctx.sessionId` | | +| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | +| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | + +`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. +`ServerContext.mcpReq` adds convenience methods that replace calling `server.*` from +inside a handler: + +| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | +| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | +| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | + +#### Deprecated in v2 (SEP-2577) + +The roots, sampling, and logging subsystems are deprecated as of protocol version +2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked +`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the +[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) +instead. + +- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, + `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and + the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. +- **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. +- **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, + `LoggingMessageNotification` and params), the full Sampling stack + (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, + `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), + and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, + `RootsListChangedNotification`). +- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata + Documents per SEP-991. + +JSDoc/types only — wire behavior is unchanged and remains functional for at least the +twelve-month deprecation window. + +#### `setRequestHandler` / `setNotificationHandler` use method strings + +The low-level handler registration takes a **method string** instead of a Zod schema. +The codemod rewrites every spec-method registration via +[`schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts). + +```typescript +// v1 +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ... }); +// v2 +server.setRequestHandler('tools/call', async (request, ctx) => { ... }); +``` + +**Custom (non-spec) methods** use the 3-arg form `(method, { params, result? }, handler)` +where `params` and `result` are any [Standard Schema](https://standardschema.dev). The +handler receives the parsed `params` directly (not the full request envelope); `_meta` +is at `ctx.mcpReq._meta`. The 3-arg notification handler is `(params, notification) => void`. + +```typescript +server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { ... }); +``` + +#### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods + +For **spec** methods, drop the result-schema argument; the SDK resolves it from the +method name. The codemod drops it from `client.request()` and `client.callTool()`; drop +it from `ctx.mcpReq.send()` by hand. + +```typescript +// v1 +import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const r = await extra.sendRequest({ method: 'sampling/createMessage', params: { ... } }, CreateMessageResultSchema); + return { content: [{ type: 'text', text: 'done' }] }; +}); + +// v2 +server.setRequestHandler('tools/call', async (request, ctx) => { + const r = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: { ... } }); + return { content: [{ type: 'text', text: 'done' }] }; +}); +``` + +For **custom (non-spec)** methods, keep the result-schema argument: +`await client.request({ method: 'acme/search', params }, SearchResult)` — only drop the +schema when calling a spec method. + +**Forwarding arbitrary methods (gateways / proxies).** Dropping the schema changes +semantics, not just the signature: a schema-less spec-method call now **enforces** the +spec result schema (a non-conforming upstream result is rejected locally with +`SdkError(SdkErrorCode.InvalidResult)` and a conforming one is re-serialized in schema +key order), and a schema-less call for a **non-spec** method throws a `TypeError` at +the call site (`'…' is not a spec method; pass a result schema`). +A relay that forwards `{ method, params }` it does not understand must keep passing an +explicit result schema. The v1 idiom survives with an import-path change: + +```typescript +import { ResultSchema } from '@modelcontextprotocol/core'; +const result = await upstream.request({ method, params }, ResultSchema); // v1-identical passthrough +``` + +For byte-exact forwarding (member order preserved), pass your own accept-anything +Standard Schema instead. Check call sites whose `method` is **not a literal** — the +codemod may have dropped the schema argument there; restore it. + +The return type is inferred from the method name via `ResultTypeMap` (e.g. +`client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult>`). + +### Server registration API + +The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use +`registerTool` / `registerPrompt` / `registerResource` with an explicit config object. +The codemod converts the call shape and wraps `inputSchema` / `outputSchema` / +`argsSchema` / `uriSchema` raw shapes. + +```typescript +// v1 — raw shape, variadic +server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// v2 — config object, Standard Schema +server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); +``` + +`registerResource` requires a `metadata` argument — pass `{}` if you have none. + +#### Standard Schema objects (raw shapes deprecated) + +v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) +for `inputSchema`, `outputSchema`, and `argsSchema`. Raw `{ field: z.string() }` shapes +are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPrompt` +(auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; +prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. + +**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared +range** in your `package.json`, not just the installed version: a zod-3 range that +satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at +runtime — and quietly: registration swallows the conversion failure, the server starts +and connects normally, and the first `tools/list` (so `client.listTools()`) answers +with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the +deprecated unwrapped raw-shape form with zod-3 field values throws at registration, +with a message pointing at `zod/v4`.) Zod **≥4.2.0** self-converts via +`~standard.jsonSchema` — the supported path. Zod **4.0–4.1** lacks it, so the SDK falls +back to its bundled Zod's `z.toJSONSchema()` with a one-time `[mcp-sdk]` console +warning; and because `.describe()` field descriptions live in the _authoring_ Zod's +registry, the fallback **drops them** from the generated JSON Schema. Fix ladder: +(1) upgrade to `zod ^4.2.0`; (2) if you must pin an older or separate Zod, attach a +`~standard.jsonSchema` provider backed by _your_ Zod's `toJSONSchema` so conversion +(and descriptions) run through your instance; (3) author the schema as raw JSON Schema +via `fromJsonSchema()`. (Raw shapes are wrapped with the SDK's **bundled** Zod — built +with a foreign Zod they fail at registration or at the first `tools/list`; pass +`z.object()`-wrapped schemas from your own Zod instead.) + +The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. +`RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** +(`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to +`update()` is not auto-wrapped; wrap it with `z.object()` yourself. + +```typescript +import * as z from 'zod/v4'; +server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); + +// ArkType works too +import { type } from 'arktype'; +server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); + +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; +server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); + +// No-parameter tools: z.object({}) +``` + +Removed Zod-specific helpers (the codemod marks each call site `@mcp-codemod-error`): +`schemaToJson` — use `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON +Schema, or your schema library's native JSON-Schema conversion; `parseSchemaAsync` — use +your schema library's validation directly (e.g. Zod's `.safeParseAsync()`); +`getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` +have no replacement (internal Zod introspection). `SchemaInput<T>` → +`StandardSchemaWithJSON.InferInput<T>` is rewritten mechanically by the codemod. The +internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part +of the public surface — do not import them. + +v1's second compat module, `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`), is +also removed — and the codemod does **not** rewrite its import (expect `TS2307`). If you +build `Tool` / `Prompt` advertisements yourself, use your schema library's native +conversion: zod 4's `z.toJSONSchema(schema, { io: 'input', target: 'draft-2020-12' })` +produces the dialect v2 advertises. + +### HTTP & headers + +Transport APIs and `ctx.http?.req?.headers` use the Web Standard `Headers` object +(`IsomorphicHeaders` is removed). `ctx.http?.req` is a standard Web `Request`. + +```typescript +// v1 +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: 'Bearer token' } } +}); +const sessionId = extra.requestInfo?.headers['mcp-session-id']; + +// v2 +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: new Headers({ Authorization: 'Bearer token' }) } +}); +const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); +const debug = new URL(ctx.http!.req!.url).searchParams.get('debug'); +``` + +`StreamableHTTPClientTransport` now **appends** any custom `requestInit.headers.Accept` +value to the spec-required `application/json, text/event-stream` (v1 let it replace +them). The required media types are always present; additional types are kept for +proxy/gateway routing. + +`hostHeaderValidation()` and `localhostHostValidation()` moved to +`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the +same as every released v1.x — only the import path changes. Framework-agnostic helpers +(`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are +in `@modelcontextprotocol/server`. + +### Errors + +The SDK now distinguishes three error kinds: + +1. **`ProtocolError`** (renamed from `McpError`) — protocol errors that cross the wire + as JSON-RPC error responses. Uses `ProtocolErrorCode` (renamed from `ErrorCode`). +2. **`SdkError`** — local SDK errors that never cross the wire. Uses `SdkErrorCode`. +3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status` + and `.statusText`. + +The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` +(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and +`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof` +checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / +class to match per scenario: + +| Scenario | v1 | v2 | +| ------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------ | +| Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | +| Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | +| Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | +| Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | +| Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | +| Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | +| Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | +| Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | + +```typescript +// v1 +if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } +if (error instanceof StreamableHTTPError) { console.log('HTTP status:', error.code); } + +// v2 +import { SdkError, SdkHttpError, SdkErrorCode, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } +if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status, error.statusText); + switch (error.code) { + case SdkErrorCode.ClientHttpAuthentication: + case SdkErrorCode.ClientHttpForbidden: + case SdkErrorCode.ClientHttpFailedToOpenStream: + case SdkErrorCode.ClientHttpNotImplemented: + break; + } +} +``` + +`StreamableHTTPError` is removed. + +**Status read off `.code` by duck-typing.** Code that classified HTTP failures by the +status without an `instanceof` — `if ('code' in e && e.code === 403)` — silently stops +matching: on `SdkHttpError` the HTTP status moved to `.status` (its `.code` is a +`SdkErrorCode` string). The codemod renames `instanceof StreamableHTTPError`, but a +status read that never named the class is invisible to it. Watch the inconsistency: +`SseError` still carries its HTTP status on numeric `.code`, so one duck-typed +`.code === 401` that caught both transports in v1 now catches only SSE. + +```typescript +// v1 — one duck-typed check caught both Streamable HTTP and SSE +if ('code' in e && (e.code === 401 || e.code === 403)) reauth(); +// v2 — match each explicitly +if (e instanceof SdkHttpError && (e.status === 401 || e.status === 403)) reauth(); // Streamable HTTP +if (e instanceof SseError && (e.code === 401 || e.code === 403)) reauth(); // SSE still uses .code +``` + +Silent at runtime (no compile error) — grep for `.code ===` status comparisons. + +**Raw numeric code comparisons.** The codemod rewrites `ErrorCode.X` symbol references, +but a check against the raw JSON-RPC number — `(e as { code?: unknown }).code === -32000` +— is invisible to it and silently never matches in v2, because the two SDK-local codes +it usually targeted are now **string** `SdkErrorCode` values: + +| v1 numeric | v2 | +| --------------------------- | -------------------------------------------- | +| `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | +| `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | + +Replace the literal with the named code. Loud (`TS2367`) when the compared value is +typed `SdkErrorCode`; silent when the left side is `unknown` or a cast — grep for +`=== -32000` / `=== -32001`. + +**Dual-role processes: `instanceof` does not cross the packages.** +`@modelcontextprotocol/client` and `@modelcontextprotocol/server` each bundle their own +copy of these error classes, so in a process that uses both — a gateway, a host, an +in-process test — an error constructed by one package fails `instanceof` against the +class imported from the other, silently. When an error may originate from the other +package, match on stable fields instead of class identity: `error.code` values +(`SdkErrorCode` strings for SDK errors, numeric JSON-RPC codes for protocol errors, +`OAuthErrorCode` strings for OAuth errors) plus presence checks like `'status' in e`, +or reconstruct typed protocol errors with `ProtocolError.fromError(code, message, data)` +— it exists precisely because `instanceof` does not survive bundle boundaries. + +**Constructing the error (test stubs, custom transports).** v1 +`new StreamableHTTPError(code, message)` becomes +`new SdkHttpError(code, message, data)`: the first argument is now a `SdkErrorCode` +string (pick the branch from the scenario table above) and the HTTP status moves into +the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, +'Not Found', { status: 404, statusText: 'Not Found' })`. v1's implicit +`Streamable HTTP error: ` message prefix is gone; pass the full message you want. + +#### `SdkErrorCode` enum (complete) + +| Code | When thrown | +| ------------------------------------- | -------------------------------------------------------------------------- | +| `NotConnected` | Transport is not connected | +| `AlreadyConnected` | Transport is already connected | +| `NotInitialized` | Protocol is not initialized | +| `CapabilityNotSupported` | Required capability is not supported | +| `RequestTimeout` | Request timed out waiting for response | +| `ConnectionClosed` | Connection was closed | +| `SendFailed` | Failed to send message | +| `InvalidResult` | Response result failed local schema validation | +| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | +| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | +| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | +| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | +| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | +| `ClientHttpNotImplemented` | HTTP POST request failed | +| `ClientHttpAuthentication` | Server returned 401 after re-authentication | +| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | +| `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | +| `ClientHttpFailedToOpenStream` | Failed to open SSE stream | +| `ClientHttpFailedToTerminateSession` | Failed to terminate session | + +#### Typed `ProtocolError` subclasses + +`ResourceNotFoundError` (carries `.uri`) and `MissingRequiredClientCapabilityError` +(carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. +`resources/read` for an unknown URI now answers `-32602` on every protocol revision +(v1.x already emitted `-32602`; an interim `-32002` from earlier v2 alphas is mapped at +the encode seam). The encode-seam mapping applies to **your own throws too**: a handler +that deliberately throws `ProtocolError(ProtocolErrorCode.ResourceNotFound, …)` reaches +peers as `-32602` — a server can no longer emit `-32002` on the wire. +`ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as +receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers. +`ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from +code + data alone, so it works across bundle boundaries where `instanceof` doesn't. + +### Auth + +#### OAuth error consolidation + +The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. +The `OAUTH_ERRORS` constant is removed. The codemod does not rewrite `instanceof` checks +on these classes — switch on `error.code` instead. + +| v1 class | v2 equivalent | +| ------------------------------ | ------------------------------------------------------- | +| `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | +| `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | +| `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | +| `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | +| `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | +| `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | +| `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | +| `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | +| `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | +| `UnsupportedResponseTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedResponseType` | +| `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | +| `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | +| `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | +| `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | +| `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | +| `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | +| `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | +| `CustomOAuthError` | `new OAuthError(customCode, message)` | + +¹ Unrelated to the new transport-layer `InsufficientScopeError` (SEP-2350) exported from +`@modelcontextprotocol/client`, which carries an RFC 6750 challenge from the resource +server and extends `OAuthClientFlowError`, **not** `OAuthError`. Do not rewrite that one. + +```typescript +// v1 +if (error instanceof InvalidClientError) { ... } +// v2 +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } +``` + +⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from +`@modelcontextprotocol/express`) classifies the error your +`OAuthTokenVerifier.verifyAccessToken()` throws: a v2 +`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` + +`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from +`server-legacy`) or a generic `Error` falls through as unexpected — **invalid tokens +become HTTP `500`**. When you re-point `requireBearerAuth` at +`@modelcontextprotocol/express`, migrate the error classes your verifier throws in the +same change. + +A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from +`@modelcontextprotocol/server-legacy/auth` during migration. + +#### `AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option + +The transport `authProvider` option is widened to `AuthProvider | OAuthClientProvider`. +**`AuthProvider`** is a new minimal interface — `{ token(): Promise<string | undefined>; +onUnauthorized?(ctx): Promise<void> }` — for static-token / non-OAuth bearer auth. +Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry +once). Existing `OAuthClientProvider` implementations need no changes — transports adapt +them internally via the new `adaptOAuthProvider()` export. Also exported: +`isOAuthClientProvider()` (type guard) and `handleOAuthUnauthorized()` (the standard +OAuth `onUnauthorized` behavior, for composing your own adapter). + +#### OAuth client flow — behavioral changes + +- **Resolved scope passed to DCR (SEP-835).** `auth()` now computes the resolved scope + once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes + it to **both** the DCR POST body and the authorization request. `registerClient()` + gained an optional `scope` parameter that overrides `clientMetadata.scope` in the + registration body. +- **OAuth error on HTTP 200.** `exchangeAuthorization()` / `refreshAuthorization()` now + throw `OAuthError` when the AS returns HTTP 200 with a JSON `{error: ...}` body (e.g. + GitHub). v1 surfaced this as a Zod parse failure on the tokens schema. +- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` + treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of + throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. + +#### OAuth client flow errors (new) + +The OAuth client flow now throws dedicated classes from `@modelcontextprotocol/client` +(all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry +path will not catch them): + +| Throw site | v2 class | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | +| Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | +| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | +| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | +| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | + +#### `auth()` options are now `AuthOptions` + +The inline options object on `auth()` is now the named `AuthOptions` type. New fields: +`iss?: string` (the form-urldecoded `iss` from the authorization callback — pass it +alongside `authorizationCode` for RFC 9207 validation), `skipIssuerMetadataValidation?: +boolean` (security-weakening opt-out of the RFC 8414 §3.3 issuer-echo check), and +`forceReauthorization?: boolean` (skip the refresh-token branch — set by the transport's +step-up path; hosts driving step-up themselves set it under the same condition). + +#### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) — action required + +`transport.finishAuth()` and `auth()` now validate `iss` from the authorization callback +against the issuer recorded from validated AS metadata. A mismatched `iss` throws +`IssuerMismatchError` before the code is exchanged regardless of advertised support; a +**missing** `iss` throws only when the AS advertised +`authorization_response_iss_parameter_supported: true`. + +Pass the callback URL's `URLSearchParams` so the SDK can read `iss` alongside `code`. +The SDK does **not** validate `state`; compare it yourself before calling `finishAuth`: + +```typescript +const params = new URL(callbackUrl).searchParams; +if (params.get('state') !== expectedState) throw new Error('state mismatch'); +await transport.finishAuth(params); // SDK reads `code` + `iss` +``` + +`transport.finishAuth(code, iss)` remains supported. Do **not** display `error` / +`error_description` / `error_uri` from a callback that failed `iss` validation — those +values are attacker-controlled in a mix-up attack. + +`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not +exactly match the URL it was fetched for (RFC 8414 §3.3). Set +`skipIssuerMetadataValidation: true` only as a temporary workaround for a known-misconfigured AS. + +(`@modelcontextprotocol/server-legacy` AS implementers: `mcpAuthRouter()` now advertises +`authorization_response_iss_parameter_supported: true` by default and the bundled +authorize handler appends `iss` to every redirect issued via `res.redirect(...)` on the +supplied `res`. If you emit `Location` another way, append `params.issuer` as `iss` +yourself; if your callback is issued by an upstream AS you proxy to, set +`authorizationResponseIssParameterSupported = false` so the metadata does not over-claim.) + +#### Dynamic Client Registration defaults (SEP-837, SEP-2207) + +`auth()` now resolves `provider.clientMetadata` once via `resolveClientMetadata()` and +applies defaults to the DCR body: `grant_types` defaults to +`['authorization_code', 'refresh_token']`; `application_type` is derived from +`redirect_uris` (loopback / custom URI scheme → `'native'`, else `'web'`). A field you +set explicitly is never overwritten. The `grant_types` default applies to the DCR body +only — it does **not** drive the `offline_access` / `prompt=consent` augmentation on the +authorize request; statically-registered and CIMD clients that want that augmentation +must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no +`redirectUrl`) get no `grant_types` default. Direct `registerClient()` callers (⚠ +`@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) wanting the same +defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. DCR +rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, +`submittedMetadata`). + +#### Token endpoint must use TLS (SEP-2207) + +`exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App +Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not +`https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on +every path including refresh — switch any plain-`http:` AS on a non-loopback host to +TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your +`saveTokens()` implementation's responsibility. + +#### Scope step-up on `403 insufficient_scope` (SEP-2350) + +`StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` +(default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the +**union** of the previously-requested and challenged scope (`computeScopeUnion`); when +that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`), +the SDK bypasses the refresh-token branch and forces a fresh authorization request. On +`'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set +this for `client_credentials` / m2m clients where re-authorization can't widen scope, or +to gate the consent prompt behind UX. Step-up retries are hard-capped per send +(`maxStepUpRetries`, default 1). With a non-OAuth [`AuthProvider`](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option), +a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous +`SdkHttpError(ClientHttpNotImplemented)`. The GET listen-stream open path applies the +same handling as the POST send path. + +#### Credentials bound to the issuing authorization server (SEP-2352) + +`auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / +`saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those +methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` +names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. +**Round-trip the stored object verbatim and you're protected** — single-slot storage +works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that +rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — +reads still succeed and refresh keeps working, the per-AS issuer check simply does not +apply to that credential, and every read logs an `[mcp-sdk]` warning (`auth()` +re-stamps on first use where the provider can persist it). If you see that warning +repeating after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage +on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token +set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with +no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` +add an optional `issuer?: string` field on top of the wire types. + +`OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are +`@deprecated` (still written for back-compat, never read by the SDK). The bundled +`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and +`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define +`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the +callback leg can verify it is exchanging the code at the same AS the redirect targeted; +without it the SDK `console.warn`s once per callback (`discoveryState` must persist with +the same durability as `codeVerifier`). + +#### Conformance obligations for `OAuthClientProvider` implementers + +The SDK enforces every authorization MUST that lands in SDK code. The following live in +**your** implementation and the SDK structurally cannot enforce them: + +- **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the + value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. +- **Pass `expectedIssuer`** when constructing static-credential providers (SEP-2352). +- **Keep refresh tokens confidential in storage** (SEP-2207) — OS keychain or + encrypted-at-rest store, never `localStorage` / plain files / logs. +- **Extract `iss` from the callback URL** and pass it to `finishAuth` (SEP-2468); when + `IssuerMismatchError` is thrown, do not render the callback's `error*` values. +- **Set `application_type` correctly** when overriding the heuristic (SEP-837). +- **Track cross-request step-up failures yourself** (SEP-2350) — `maxStepUpRetries` is + per request; per-session backoff is host state. +- **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` + `scope` or PRM `scopes_supported` (SEP-2207). + +### Types & schemas + +#### Zod `*Schema` constants moved to `@modelcontextprotocol/core` + +The Zod schemas (`CallToolResultSchema`, `ListToolsResultSchema`, …) that v1 exported +from `types.js` now live in a separate **`@modelcontextprotocol/core`** package. Neither +`@modelcontextprotocol/client` nor `@modelcontextprotocol/server` re-exports them — both +packages stay Zod-free in their public surface. + +The v1→v2 change is just an import-path swap — `.parse()` / `.safeParse()` keep working +unchanged: + +```typescript +// v1 +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +if (CallToolResultSchema.safeParse(value).success) { ... } + +// v2 — same Zod schema, new package +import { CallToolResultSchema } from '@modelcontextprotocol/core'; +if (CallToolResultSchema.safeParse(value).success) { ... } +``` + +`@modelcontextprotocol/core` is the canonical home for the spec's Zod schema constants +(and the OAuth/OpenID metadata schemas). It is runtime-neutral (its only dependency is +`zod`) and is **not** required by `client` / `server` — install it only if you import the +raw schemas directly. + +If you would rather keep your project Zod-free, the **`isSpecType` / `specTypeSchemas`** +alternatives are exported from `@modelcontextprotocol/client` and `…/server`: + +```typescript +import { isSpecType, specTypeSchemas } from '@modelcontextprotocol/client'; +if (isSpecType.CallToolResult(value)) { ... } +const blocks = mixed.filter(isSpecType.ContentBlock); +const result = specTypeSchemas.CallToolResult['~standard'].validate(value); +``` + +`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of +every named type in the MCP spec — so you get autocomplete and a compile error on typos. +`specTypeSchemas.X` is a `StandardSchemaV1Sync<In, Out>` (`validate()` is synchronous). +`validate()` returns `{ value }` or `{ issues }` and never throws — unlike `.parse()` on +the real schema; code that caught a `ZodError` should inspect `result.issues` (or keep +`.parse()` on the schema imported from `@modelcontextprotocol/core`). +The pre-existing `isCallToolResult(value)` guard still works. + +**`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition +— `.extend()`, `.pick()`, `.omit()`, `.merge()`, `.shape`, `.passthrough()`, +`.parseAsync()` — does **not** compile on a `specTypeSchemas` entry; reach for the real +Zod schema from `@modelcontextprotocol/core` when you need to derive a tolerant variant +of a spec schema (e.g. +`ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array() })`). +The Zod-specific `AnySchema` / `SchemaOutput` types from `…/zod-compat.js` are removed — +replace with `StandardSchemaV1` / `StandardSchemaV1.InferOutput<T>` (the codemod's +removal message says the same). + +The role-aggregate unions (`ClientRequest`, `ServerResult`, `ServerRequest`, +`ClientResult`, `ClientNotification`, `ServerNotification`) and the typed-method maps +(`RequestMethod`, `RequestTypeMap`, `ResultTypeMap`, `NotificationTypeMap`) no longer +include task vocabulary; the deprecated `Task*` types remain importable on their own. + +#### Removed type aliases + +| Removed | Replacement | +| --------------------------------------------------------------- | --------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | +| `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` ² | +| `JSONRPCResponse` (result-only in v1) | `JSONRPCResultResponse` ² | +| `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | Web Standard `Headers` | +| `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | +| `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | + +² v2 introduces **new** `isJSONRPCResponse` / `JSONRPCResponse` / `JSONRPCResponseSchema` +with corrected semantics — they match **both** result and error responses (the schema is +`z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])`). v1's symbols only +matched results. To preserve v1 behavior, rename to `isJSONRPCResultResponse` / +`JSONRPCResultResponse` / `JSONRPCResultResponseSchema` (the codemod does this). + +³ The `ResourceTemplate` URI-template helper **class** (from `sdk/server/mcp.js`) is +**unchanged** — keep `new ResourceTemplate(...)` as-is. Only the like-named spec wire +type from `types.js` was renamed to `ResourceTemplateType` to resolve the v1 collision; +the codemod scopes the rename to imports from `sdk/types.js` only. + +All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original +names — import the TypeScript types, error classes, enums, and type guards from +`@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the Zod +`*Schema` constants from `@modelcontextprotocol/core`. + +The `Protocol` base class itself is no longer exported (it is internal engine). If you +were reaching into protocol internals — rare, mostly debugging tools — +`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every +inbound request that no registered handler matches, before capability gating. Delete +the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod +currently rewrites it to a named import from `@modelcontextprotocol/client` that does +not exist (a codemod fix is tracked) — delete that import. + +#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) + +The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` +instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas +declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. + +`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to +`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is +`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External +`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, +surfaced per-tool on `callTool`). + +| v1 pattern | Mechanical fix | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` | +| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | +| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | +| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | + +A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, +etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward +every era a non-object `structuredContent` with no `text` block of its own gets a +`JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](./support-2026-07-28.md#per-era-wire-codecs) for how the codec applies these per era. + +**Your advertised tool schemas change shape on the wire.** The same `registerTool` +calls produce `tools/list` entries whose generated `inputSchema` differs from v1: +JSON Schema 2020-12 idioms (zod 4 conversion), different `additionalProperties` +handling (no `additionalProperties: false` by default; passthrough objects emit +`"additionalProperties": {}` instead of `true`), and no `execution.taskSupport` member. +Golden tests, transcript pins, and strict client-side validators of your advertised +tool list need re-baselining — the new shapes are spec-conformant. + +### Behavioral changes + +These are runtime-behavior changes that may affect tests and assertions; no source +rewrite required unless noted. + +#### Error-shape changes (every era) + +- **Unknown / disabled tool calls now reject** with `ProtocolError(-32602 InvalidParams)` + instead of resolving `CallToolResult{isError: true}`. v1 callers that checked + `result.isError` for an unknown tool will get an unhandled rejection — catch the + rejected promise instead. +- **The `MCP error <code>: ` message prefix is gone.** v1 prefixed relayed JSON-RPC + error messages (`MCP error -32602: …`); v2's `ProtocolError.message` carries the + peer's message verbatim. Tests and log scrapers that matched the prefix or the numeric + code in rendered text should match `error.code` instead. +- **In-flight request handlers are aborted on transport close** — `ctx.mcpReq.signal` + fires (v1 let them run to completion). `InMemoryTransport.close()` no longer + double-fires `onclose` on the initiating side. +- **`Protocol.request()` with an already-aborted signal** rejects with + `SdkError(SdkErrorCode.RequestTimeout, reason)` instead of throwing the raw + `signal.reason`, matching the in-flight-abort path. +- **OAuth discovery (`discoverOAuthProtectedResourceMetadata` / `discoverOAuthMetadata`, + transitively `auth()`) throws on fetch `TypeError`** (DNS failure, `ECONNREFUSED`, + invalid URL) in Node and Cloudflare Workers instead of swallowing it as a CORS miss + → `undefined`. The CORS-swallow remains browser-only. + +#### Client connection & dispatch + +- **`connect()` skips the `initialize` handshake when the transport already exposes a + `sessionId`** — it assumes it is reconnecting to an existing session (unchanged from + v1.x, where the same guard has existed since 1.10.0; recorded here because the + far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction + silently skips initialization: `getServerCapabilities()` stays `undefined` and the + list verbs return empty results. Expose `sessionId` only after the first request has + been sent. +- **The typed verbs dispatch after async pre-work.** `Protocol.request()` itself still + hands the frame to the transport before its first `await` (v1-compatible). The typed + verbs on top of it — `callTool()` and the cacheable list verbs — perform async work + first (header-mirroring scan, response-cache freshness, output-validator resolution), + so an abort fired in the same tick can land before the frame is ever sent: the call + rejects with `SdkError(RequestTimeout, reason)` and **no `notifications/cancelled` is + emitted** (nothing was in flight). v1 sent the frame synchronously from these verbs. + Once the frame is on the wire, aborting still sends `notifications/cancelled` before + rejecting. +- **Protocol-version pinning is a first-class option.** + `ProtocolOptions.supportedProtocolVersions` pins the legacy `initialize` handshake: + the **first** pre-2026 entry in the list is offered (list order is preference order), + a counter-offer is accepted only if it is one of the list's pre-2026 entries, and a + list with no pre-2026 entry makes the handshake throw. Under + `versionNegotiation: 'auto'` the modern probe candidates are the list's modern + entries when it has any (otherwise the SDK's default modern set); a `{ pin }` is + honored as given and is not checked against the list (see + [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). + v1 had no public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — + replace any workaround that patched the offered version with this option. + +#### stdio transport + +- A configurable `maxBufferSize` (default **10 MB**) caps the stdio read buffer. A + single message that would push the buffer past the limit emits `onerror` and + **closes the connection** (v1 buffered unbounded). Configure via + `new StdioClientTransport({ ..., maxBufferSize })` / + `new StdioServerTransport(stdin, stdout, { maxBufferSize })`. +- `ReadBuffer.readMessage()` now **silently skips non-JSON stdout lines** instead of + throwing `SyntaxError` → `onerror`. Hot-reload tools (tsx, nodemon) that write debug + output to stdout no longer break the transport. Lines that parse as JSON but fail + JSON-RPC schema validation still throw. +- `StdioClientTransport` always sets `windowsHide: true` when spawning the server + process on Windows (previously Electron-only). Prevents stray console windows in + non-Electron Windows hosts. + +#### Client list methods + +- `listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` return + **empty results** when the server didn't advertise the corresponding capability, + instead of sending the request. Set `enforceStrictCapabilities: true` in `ClientOptions` + to restore the v1 throw. +- Called **without a `cursor`**, the same methods now **auto-aggregate every page** and + return `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual + pagination loops keep working (the first iteration returns everything); replace them + with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default + 64); overrun throws `SdkError(ListPaginationExceeded)`. There is no way to fetch only + the **first** page through the typed verbs — for page-level observation + (pagination tooling, per-page stats) drop to + `client.request({ method: 'tools/list', params })`, which never aggregates. +- Output-schema validator compilation is now **lazy** — validators compile on the first + `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. + In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` + succeeds and the compile failure surfaces when `callTool()` is invoked on the affected + tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, + before the request is sent. Validation is never silently skipped. +- On a 2026-07-28 connection the cacheable verbs honour the server-stamped `ttlMs` / + `cacheScope` (SEP-2549) and may return a still-fresh cached entry without a round + trip. Per-call override: `{ cacheMode: 'refresh' | 'bypass' }`. New `ClientOptions`: + `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; + `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). + +#### Server (Streamable HTTP transport) + +- Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` + callbacks) is only enabled for protocol versions in the transport's supported-versions + list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` + request body no longer enable it. +- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), + unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code + should key off the HTTP `404` status, not `-32001`. + +#### Server (deprecated accessors and app-factory Origin validation) + +- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` + are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a + localhost-class `host` now also validate the `Origin` header by default. Browser-served + clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default + localhost allowlist; validation cannot be disabled for localhost binds). Requests + without an `Origin` header are unaffected; a present `Origin` that cannot be parsed + — including the opaque **`Origin: null`** sent by sandboxed iframes, `file://` pages, + and cross-origin redirects — is **rejected with 403** and cannot be allowlisted via + `allowedOrigins`. Framework-agnostic helpers + (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) are in + `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships + `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. + +#### Server (McpServer / Streamable HTTP behavior) + +- **Eager capability-handler install.** `McpServer` now installs list/read/call handlers + for every primitive capability declared in `ServerOptions.capabilities`, even with + zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no + registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method +not found`. Low-level `Server` users remain responsible for registering handlers for + declared capabilities. +- **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.** + Request-related events emitted after `closeSSE()` — and the final response when no + per-request stream is connected — are now persisted to the configured `eventStore` for + replay (v1 dropped them / threw `"No connection established"`). Without an + `eventStore`, the same condition surfaces via `onerror` and the request id is retired. +- **`registerResource` reserves the `cacheHint` config key.** It is validated + (`RangeError` on invalid values) and stripped from the resource's list metadata; v1 + passed it through verbatim as ordinary metadata. Untyped callers that previously + smuggled a `cacheHint` key through resource metadata should rename it. + +#### `ctx.mcpReq.log()` is request-related on every era + +`ctx.mcpReq.log()` now emits its `notifications/message` request-related (it rides the +in-flight exchange like progress) on every era. On a 2025-era sessionful Streamable HTTP +transport this moves handler-emitted logs from the standalone GET stream onto the +per-request POST response stream — a spec-conformance correction. The session-scoped +`logging/setLevel` filter applies as before on 2025-era connections. (On 2026-07-28 +requests, the per-request `_meta.logLevel` envelope key is the filter — see +[support-2026-07-28.md](./support-2026-07-28.md#serving-the-2026-07-28-revision).) + +#### Wire tightening (every era) + +- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])` + affordance was removed. Tool handlers MUST include `content` (the TypeScript surface + always required it; `content: []` is fine). A handler result without it is rejected + with `-32602`. +- **`ElicitResult.content` values are typed and validated as + `string | number | boolean | string[]`.** v1's TypeScript surface accepted + `Record<string, unknown>` content values; an elicitation handler returning arbitrary + objects now fails to compile (and fails schema validation) — narrow to the primitives + the elicitation spec allows. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` + used to delete `params._meta` before validation; it now passes `_meta` through (minus + the reserved `io.modelcontextprotocol/*` envelope keys). If your params schema is + strict, add an optional `_meta` member. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept + `resultType`; the validators for the 2025-only task message types and + `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). +- **Sampling `hasTools` discriminant** now keys on `tools || toolChoice` (previously + `tools` only) when selecting the with-tools `CreateMessageResult` variant, on every + era. + +#### Experimental tasks interception removed + +The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). +No mechanical migration; remove usages. Gone: `ProtocolOptions.tasks`, +`protocol.taskManager`, `RequestOptions.task` / `relatedTask`, `BaseContext.task`, +`assertTaskCapability` / `assertTaskHandlerCapability`, `*.experimental.tasks.*` +accessors and `Experimental{Client,Server,McpServer}Tasks`, `requestStream` / +`callToolStream` / `createMessageStream` / `elicitInputStream` and the `ResponseMessage` +types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, +`CreateTaskRequestHandler`, `TaskMessageQueue`, `InMemoryTaskMessageQueue`, +`BaseQueuedMessage` / `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, +`TaskToolExecution`, `TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`, +and the `new McpServer(info, { taskStore, taskMessageQueue })` constructor option keys +(the codemod emits an action-required diagnostic at each — remove the option). + +The task **wire types** remain importable as `@deprecated` vocabulary for 2025-11-25 +interop — see [support-2026-07-28.md](./support-2026-07-28.md#tasks-deprecated-wire-vocabulary). + +#### Specification clarifications adopted (no SDK behavior change) + +The 2026-07-28 specification revision includes a number of documentation-only +clarifications recorded here so an audit of the revision's changelog against this guide +is complete; nothing in this list requires code changes: per-operation timeout guidance +removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio +shutdown wording; transports-as-bindings reframe; `resources/read` wording (the +`file://` path-sanitization MUST is server-author guidance — your handler must reject +traversal / symlink escapes itself); `PromptMessage` resource links (already in +`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string +cursors (already passed through verbatim); sampling host-requirement docs; elicitation +statefulness wording; cosmetic schema/JSDoc sweeps. + +--- + +## Enhancements + +### Automatic JSON Schema validator selection by runtime + +The SDK auto-selects the validator: Node.js → AJV; Cloudflare Workers (workerd) → +`@cfworker/json-schema`. Cloudflare Workers users can remove explicit +`jsonSchemaValidator` configuration. You don't need to install `ajv`, `ajv-formats`, or +`@cfworker/json-schema` for the default path. To customize the built-in backend, import +the named class from the explicit subpath +(`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing +from a subpath means the corresponding peer dep must be in your `package.json`. + +### `Client.connect(transport, { prior })` — zero-round-trip connect + +Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to +every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported +type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). + +### Serving the 2026-07-28 revision + +`createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests +(`requestState`), client cancellation via stream-close, `subscriptions/listen`, +`Mcp-Param-*` headers, and per-era wire codecs are covered in +**[support-2026-07-28.md](./support-2026-07-28.md)** — they are net-new in v2, not v1→v2 +breaks. + +--- + +## Unchanged APIs + +The following are unchanged between v1 and v2 (only the import path changed): + +- `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, + `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` + signatures changed (schema parameter dropped for spec methods). +- `McpServer` constructor, `server.connect(transport)`, `server.close()`. +- `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options. +- `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the + `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). +- All TypeScript **type** definitions from `types.ts` (except the aliases listed under + [Removed type aliases](#removed-type-aliases)). +- Tool, prompt, and resource callback return types. + +> The `Server` (low-level) constructor and **most** of its methods are unchanged, but +> `setRequestHandler` / `setNotificationHandler` and `request()` signatures changed +> ([Low-level protocol](#low-level-protocol--handler-context-ctx)). The Zod `*Schema` +> constants are **not** part of the unchanged surface — they moved to +> `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). + +--- + +## Need help? + +- The codemod's [`@mcp-codemod-error`](../../packages/codemod/README.md) markers point + at every site it could not safely rewrite. +- The [FAQ](../faq.md) covers common v2 questions. +- Runnable [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) + for every subsystem. +- Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues). diff --git a/docs-v2/protocol-versions.md b/docs-v2/protocol-versions.md new file mode 100644 index 0000000000..6e07e9bccc --- /dev/null +++ b/docs-v2/protocol-versions.md @@ -0,0 +1,70 @@ +--- +status: scaffold +shape: explanation +--- +# Protocol versions + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide. +teaches: ClientOptions.versionNegotiation, Client.getProtocolEra, ProtocolOptions.supportedProtocolVersions, createMcpHandler legacy option, serveStdio legacy option, SdkError(EraNegotiationFailed) +source: mined from docs/migration/support-2026-07-28.md "Serving the 2026-07-28 revision", "Client side: versionNegotiation", "Probe policy", "Appendix: 2025-era vs 2026-era behavior matrix" +NOTE: this is the ONE era page. Every other page's era caveat is a single line linking here +(CONVENTIONS R8 / proposal principle 3). The behavior matrix is MOVED here, not copied — +the support guide links to this page and stops owning it (one maintained copy, ever). +--> + +## Name the two eras +<!-- teaches: ProtocolEra ('legacy' | 'modern') | salvage: docs/migration/support-2026-07-28.md intro + agent-report 89 §1.2 --> +<!-- code: none — two short paragraphs: an "era" is a behavior family, not a version string; 2025-era = 2024-10-07 … 2025-11-25, 2026-era = 2026-07-28; why the SDK serves both --> + +## Negotiate the era from the client +<!-- teaches: ClientOptions.versionNegotiation, Client.getProtocolEra | salvage: docs/migration/support-2026-07-28.md "Client side: versionNegotiation" --> +`versionNegotiation` decides which handshake `connect()` performs; the default is the 2025 `initialize` handshake, byte for byte. + +```ts +// draft - API verified against packages/client/src/client/client.ts (ClientOptions.versionNegotiation L206, getProtocolEra L1272) and packages/client/src/client/versionNegotiation.ts (VersionNegotiationOptions.mode) +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' } }, +); +await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); + +client.getProtocolEra(); // 'modern' or 'legacy' once connected; undefined before +``` +<!-- result: against a 2026-07-28 server getProtocolEra() returns 'modern'; against a 2025-only server the same connect() falls back and returns 'legacy' --> + +## Pin an era +<!-- teaches: mode: 'legacy', mode: { pin: '2026-07-28' }, SdkError(EraNegotiationFailed) --> +<!-- code: the three mode values as a placeholder block: absent/'legacy' (no probe), 'auto' (probe + fallback), { pin } (modern only, connect() rejects with SdkError(EraNegotiationFailed) against a 2025-only server) --> + +## Understand the probe +<!-- teaches: versionNegotiation.probe (timeoutMs, maxRetries), supportedProtocolVersions | salvage: docs/migration/support-2026-07-28.md "Probe policy" --> +<!-- code: probe: { timeoutMs, maxRetries } placeholder; prose covers transport-aware timeouts (stdio falls back, HTTP rejects), the browser CORS exception, and who should NOT default to 'auto' (spawn-per-invocation CLI tools) --> + +## Serve both eras from one entry point +<!-- teaches: createMcpHandler legacy: 'stateless' | 'reject', serveStdio legacy option | salvage: docs/migration/support-2026-07-28.md "Server over HTTP: createMcpHandler", "Server over stdio / long-lived connections: serveStdio" --> +<!-- code: createMcpHandler(factory, { legacy: 'stateless' }) placeholder; one line linking /serving/legacy-clients, which owns the legacy: option and the full recipe --> + +## Compare the eras +<!-- teaches: the behavior matrix | salvage: docs/migration/support-2026-07-28.md "Appendix: 2025-era vs 2026-era behavior matrix" — MOVED here verbatim (the table carve-out is allowed on this reference-flavored page) --> +<!-- code: none — the nine-axis 2025-era vs 2026-07-28 table lands here as the page's centerpiece --> + +## Separate deprecation from era +<!-- teaches: SEP-2577 (sampling, roots, ctx.mcpReq.log) is deprecation, not an era caveat | salvage: agent-report 89 §1.2 + proposal principle 4 --> +<!-- code: none — one short paragraph: deprecated surfaces carry their own on-page sunset banner; this page is not where deprecation lives --> + +## Link here instead of explaining inline +<!-- teaches: the quarantine rule for every other page | salvage: proposal principle 3 ("Tell the era story exactly once") --> +<!-- code: none — the one-line cross-link form other pages use, shown as the example sentence authors copy --> + +## Recap +<!-- the claims this page will prove: +- An era is a behavior family; the SDK serves 2025-era and 2026-07-28 from the same entry points. +- versionNegotiation picks the client handshake; the default is the unchanged 2025 initialize. +- 'auto' probes with server/discover and falls back; a pin never falls back. +- getProtocolEra() tells you what was negotiated. +- The behavior matrix on this page is the only copy; every other page links here in one line. +- Deprecation (SEP-2577) is not an era difference. +--> diff --git a/docs-v2/servers/completion.md b/docs-v2/servers/completion.md new file mode 100644 index 0000000000..c81ce2a88b --- /dev/null +++ b/docs-v2/servers/completion.md @@ -0,0 +1,65 @@ +--- +status: scaffold +shape: how-to +--- +# Completion + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Autocomplete a schema field. +teaches: completable, CompleteCallback, ResourceTemplate complete callbacks +source: mined from docs/server.md "Completions" +--> + +## Wrap an argument with `completable` +<!-- teaches: completable(schema, complete) on a registerPrompt argsSchema field | salvage: docs/server.md "Completions" (registerPrompt_completion) --> + +```ts +// draft - API verified against packages/server/src/server/completable.ts (completable, line 51) +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ), + }), + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this ${language} code for best practices.` }, + }, + ], + }) +); +``` +<!-- result: a completion/complete request for `language` with value "ty" returns ["typescript"]. --> + +## Return suggestions from the complete callback +<!-- teaches: CompleteCallback signature - (value, context?) => values[] (sync or async) | source: packages/server/src/server/completable.ts CompleteCallback --> +<!-- code: an async complete callback that queries a list and filters by the typed prefix --> +<!-- result: the completion/complete result the client sees (values array) --> + +## Use the other arguments for context +<!-- teaches: the optional second parameter - context.arguments carries the values already filled in for the other arguments --> +<!-- code: a complete callback that narrows suggestions using context?.arguments?.someOtherField --> + +## Complete a resource template variable +<!-- teaches: ResourceTemplate's `complete` callback map keyed by variable name | source: packages/server/src/server/mcp.ts ResourceTemplate constructor --> +<!-- code: new ResourceTemplate('user://{userId}/profile', { list: ..., complete: { userId: async value => [...] } }) --> + +## Try it from a client +<!-- teaches: what the host does with completions (Inspector / a client's complete() call); the capability is advertised automatically --> +<!-- code: the completion/complete request and its result, verbatim --> + +## Recap +<!-- the claims this page will prove: +- completable(schema, callback) attaches autocompletion to one schema field; the schema still validates as before. +- The callback receives the partial value and returns the suggestion list. +- context.arguments lets one field's suggestions depend on another's value. +- Resource template variables complete through the template's `complete` map, not completable(). +- The server advertises the completions capability for you. +--> diff --git a/docs-v2/servers/elicitation.md b/docs-v2/servers/elicitation.md new file mode 100644 index 0000000000..a117aeef72 --- /dev/null +++ b/docs-v2/servers/elicitation.md @@ -0,0 +1,73 @@ +--- +status: scaffold +shape: how-to +--- +# Elicitation + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Ask the user (form mode, URL mode). +teaches: ctx.mcpReq.elicitInput, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult.action +source: mined from docs/server.md "Elicitation" +--> + +## Ask for input with a form +<!-- teaches: ctx.mcpReq.elicitInput({ mode: 'form', message, requestedSchema }) | salvage: docs/server.md "Elicitation" (registerTool_elicitation) --> + +```ts +// draft - API verified against packages/core-internal/src/shared/protocol.ts (ServerContext.mcpReq.elicitInput, line 470) +server.registerTool( + 'collect-feedback', + { + description: 'Collect user feedback via a form', + inputSchema: z.object({}), + }, + async (_args, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please share your feedback:', + requestedSchema: { + type: 'object', + properties: { + rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 }, + comment: { type: 'string', title: 'Comment' }, + }, + required: ['rating'], + }, + }); + if (result.action === 'accept') { + return { content: [{ type: 'text', text: `Thanks! ${JSON.stringify(result.content)}` }] }; + } + return { content: [{ type: 'text', text: 'Feedback declined.' }] }; + } +); +``` +<!-- result: the host renders the form; result.action is 'accept' | 'decline' | 'cancel' and result.content holds the fields. --> +<!-- aside (::: info): elicitInput is a push and throws on a 2026-07-28 connection, where a handler + RETURNS the request instead — one line, cross-link servers/input-required.md, which owns that + form. Era detail is one line linking /protocol-versions. --> + +## Handle every action +<!-- teaches: ElicitResult.action branches (accept / decline / cancel) and treating result.content as untrusted input --> +<!-- code: a switch over result.action returning a distinct CallToolResult per branch --> +<!-- result: the verbatim tool output for a decline --> + +## Send the end user to a URL +<!-- teaches: mode: 'url' for secure flows (sign-in, payment, API keys) | salvage: docs/server.md "Elicitation" URL mode --> +<!-- code: ctx.mcpReq.elicitInput({ mode: 'url', message, url, elicitationId }) --> + +## Keep secrets out of forms +<!-- teaches: the spec rule - never collect sensitive data via form mode; use URL mode or out-of-band | salvage: docs/server.md "Elicitation" IMPORTANT box --> +<!-- code: none --> +<!-- ::: warning placeholder: sensitive information must not be collected via form elicitation --> + +## Require the elicitation capability +<!-- teaches: the client must declare elicitation; calls against a client without it fail before reaching the wire --> +<!-- code: none; one line on the error the handler observes --> + +## Recap +<!-- the claims this page will prove: +- ctx.mcpReq.elicitInput sends an elicitation request mid-handler and resolves with the end user's answer. +- Form mode carries a JSON-Schema requestedSchema; the result's action is accept, decline, or cancel. +- URL mode hands the end user a browser flow; use it for anything sensitive. +- Elicitation only works against clients that declared the capability. +--> diff --git a/docs-v2/servers/errors.md b/docs-v2/servers/errors.md new file mode 100644 index 0000000000..788a44c5c6 --- /dev/null +++ b/docs-v2/servers/errors.md @@ -0,0 +1,72 @@ +--- +status: scaffold +shape: how-to +--- +# Errors + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out). +NOTE for the prose tranche: the v2 export is `ProtocolError` (+ `ProtocolErrorCode`), not v1's `McpError` — teach the real symbol, mention the rename once for v1 readers. +teaches: CallToolResult.isError, ProtocolError, ProtocolErrorCode, ResourceNotFoundError +source: mined from docs/server.md "Error handling" + docs/client.md "Error handling" +--> + +## Return a tool error with `isError` +<!-- teaches: isError: true is a tool-level error the model SEES and can self-correct on | salvage: docs/server.md "Error handling" (registerTool_errorHandling) --> + +```ts +// draft - API verified against packages/server/src/server/mcp.ts (registerTool, line 972) and CallToolResult.isError +server.registerTool( + 'fetch-data', + { + description: 'Fetch data from a URL', + inputSchema: z.object({ url: z.string() }), + }, + async ({ url }) => { + const res = await fetch(url); + if (!res.ok) { + return { + content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], + isError: true, + }; + } + return { content: [{ type: 'text', text: await res.text() }] }; + } +); +``` +<!-- result: the tools/call response is a normal result with isError: true; the model reads the message and retries. --> + +## Let a thrown exception become a tool error +<!-- teaches: the SDK catches handler throws and converts them to { isError: true }; explicit isError only buys you the message; output-schema validation is skipped on errors | salvage: docs/server.md "Error handling" closing paragraph --> +<!-- code: the same handler throwing; comment shows the converted result --> +<!-- result: the verbatim isError result a throw produces --> + +## Throw a protocol error +<!-- teaches: ProtocolError(code, message, data?) for failures the MODEL must not see (bad params, unknown resource); JSON-RPC error response, not a result | source: packages/core-internal/src/types/errors.ts ProtocolError --> +<!-- code: throw new ProtocolError(ProtocolErrorCode.InvalidParams, '...') from a resource read callback --> +<!-- result: the verbatim JSON-RPC error object on the wire --> + +## Choose between tool error and protocol error +<!-- teaches: the rule - recoverable, model-visible failures -> isError; malformed requests / missing things / infrastructure -> protocol error (hidden from the model) | salvage: docs/server.md "Error handling" + docs/client.md "Error handling" framing --> +<!-- code: none --> + +## Use the typed error subclasses +<!-- teaches: ResourceNotFoundError, UrlElicitationRequiredError, UnsupportedProtocolVersionError carry structured data and the right code | source: packages/core-internal/src/types/errors.ts --> +<!-- code: throw new ResourceNotFoundError(uri) from a read callback --> + +## Look up a protocol error code +<!-- teaches: ProtocolErrorCode enum; the table carve-out (the ONE table allowed on a narrative page) | source: packages/core-internal/src/types/enums.ts ProtocolErrorCode --> +<!-- table placeholder (bottom of page), values verified against ProtocolErrorCode: +ParseError -32700 · InvalidRequest -32600 · MethodNotFound -32601 · InvalidParams -32602 · InternalError -32603 · +ResourceNotFound -32002 (receive-tolerated only; the SDK answers -32602 and never emits -32002) · +MissingRequiredClientCapability -32021 · UnsupportedProtocolVersion -32022 · UrlElicitationRequired -32042 +--> + +## Recap +<!-- the claims this page will prove: +- isError: true is a successful JSON-RPC response carrying a tool failure the model can act on. +- A thrown exception in a tool handler becomes isError: true automatically. +- ProtocolError / its subclasses produce JSON-RPC error responses the model never sees. +- Pick by audience: model-recoverable -> isError; caller/infrastructure -> protocol error. +- The full code list lives in the table at the bottom of this page. +--> diff --git a/docs-v2/servers/input-required.md b/docs-v2/servers/input-required.md new file mode 100644 index 0000000000..afcc085d90 --- /dev/null +++ b/docs-v2/servers/input-required.md @@ -0,0 +1,75 @@ +--- +status: scaffold +shape: how-to +--- +# input_required + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Handle input_required (multi-round-trip requests). +teaches: inputRequired, inputRequired.elicit/elicitUrl/createMessage/listRoots, acceptedContent, ctx.mcpReq.inputResponses, ctx.mcpReq.requestState, createRequestStateCodec +source: mined from docs/server.md "Requesting input on 2026-07-28: input_required" + "Carrying state across rounds: requestState" +--> + +## Return `input_required` instead of pushing a request +<!-- teaches: the inversion - the handler RETURNS the embedded request and the client retries the call with the responses | salvage: docs/server.md "Server-initiated requests" intro + "Requesting input on 2026-07-28" (registerTool_inputRequired) --> + +```ts +// draft - API verified against packages/core-internal/src/shared/inputRequired.ts (inputRequired/acceptedContent, lines 120/147) +server.registerTool( + 'deploy', + { + description: 'Deploy after user confirmation', + inputSchema: z.object({ env: z.string() }), + }, + async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }, + }), + }, + }); + } + return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; + } +); +``` +<!-- result: round 1 returns resultType 'input_required'; the client answers and retries; round 2 returns the tool result. --> + +## Read the responses on re-entry +<!-- teaches: ctx.mcpReq.inputResponses + acceptedContent(key) (typed, schema-or-cast); responses are untrusted input | source: packages/core-internal/src/shared/inputRequired.ts acceptedContent --> +<!-- code: acceptedContent with a Zod schema overload, plus the rejected/declined branch --> + +## Write the handler write-once +<!-- teaches: the pattern - on every entry read what already arrived, ask only for what is still missing; never branch on era | salvage: docs/server.md "Requesting input on 2026-07-28" --> +<!-- code: the same handler asking for two inputs across two rounds, each guarded by acceptedContent --> + +## Pick the embedded request kind +<!-- teaches: inputRequired.elicit (form), inputRequired.elicitUrl (URL), inputRequired.createMessage (sampling), inputRequired.listRoots() | source: packages/core-internal/src/shared/inputRequired.ts InputRequiredBuilder --> +<!-- code: one inputRequests map naming all four builders --> + +## Carry state across rounds with `requestState` +<!-- teaches: nothing survives between rounds on the server; mint an opaque requestState, read it back with ctx.mcpReq.requestState<State>() | salvage: docs/server.md "Carrying state across rounds: requestState" (requestState_mintDecode) --> +<!-- code: mint requestState alongside the second-round request; read it on re-entry --> + +## Protect `requestState` with the codec +<!-- teaches: requestState round-trips through the client and is attacker-controlled; createRequestStateCodec (HMAC-SHA256) + the ServerOptions.requestState.verify hook; mint only what earlier rounds proved | salvage: docs/server.md requestState IMPORTANT box (requestState_codec) --> +<!-- code: createRequestStateCodec({ key, ttlSeconds }) wired into ServerOptions.requestState.verify --> +<!-- ::: warning placeholder: signed, not encrypted; tampered/expired state answers -32602 --> + +## Let the shim serve older clients +<!-- teaches: the on-by-default legacy shim fulfils input_required returns over the older push channels, so write-once handlers serve every connection | salvage: docs/server.md "Requesting input on 2026-07-28" closing paragraph --> +<!-- code: none; the era detail is ONE line linking /protocol-versions and the support guide --> + +## Recap +<!-- the claims this page will prove: +- On 2026-07-28 a handler asks for input by RETURNING inputRequired(...); the client retries with the responses. +- inputRequired carries inputRequests and/or requestState; it throws if it has neither. +- acceptedContent(ctx.mcpReq.inputResponses, key) reads what a previous round produced; treat it as untrusted. +- Write-once handlers re-derive their position on every entry instead of remembering it. +- requestState is the only cross-round memory; sign it with createRequestStateCodec and mint only what was proved. +- The legacy shim makes the same handler work for older clients. +--> diff --git a/docs-v2/servers/logging-progress-cancellation.md b/docs-v2/servers/logging-progress-cancellation.md new file mode 100644 index 0000000000..77fc65d8b0 --- /dev/null +++ b/docs-v2/servers/logging-progress-cancellation.md @@ -0,0 +1,72 @@ +--- +status: scaffold +shape: how-to +--- +# Logging, progress, and cancellation + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: The ctx every handler receives: logging, progress, cancellation. +teaches: ServerContext, ctx.mcpReq.notify, ctx.mcpReq.log, ctx.mcpReq.signal, ctx.mcpReq._meta +source: mined from docs/server.md "Logging", "Progress" + protocol cancellation behavior +--> + +## Report progress from a handler +<!-- teaches: ctx.mcpReq._meta.progressToken, ctx.mcpReq.notify('notifications/progress') | salvage: docs/server.md "Progress" (registerTool_progress) --> + +```ts +// draft - API verified against packages/core-internal/src/shared/protocol.ts (BaseContext.mcpReq._meta/notify, lines 375-433) +server.registerTool( + 'process-files', + { + description: 'Process files with progress updates', + inputSchema: z.object({ files: z.array(z.string()) }), + }, + async ({ files }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + for (let i = 0; i < files.length; i++) { + // ... process files[i] ... + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` }, + }); + } + } + + return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; + } +); +``` +<!-- result: the client's progress callback fires once per file with progress/total/message. --> + +## Skip progress when the client did not ask +<!-- teaches: progressToken is opt-in; progress must increase; total and message are optional | salvage: docs/server.md "Progress" closing rules --> +<!-- code: the same loop guarded on progressToken === undefined, one comment per rule --> + +## Log to the client +<!-- teaches: capabilities: { logging: {} } + ctx.mcpReq.log(level, data) | salvage: docs/server.md "Logging" (logging_capability, registerTool_logging) --> +<!-- code: declare the logging capability at construction, then ctx.mcpReq.log('info', ...) inside the handler --> +<!-- ::: warning placeholder: MCP logging is deprecated (SEP-2577); migrate to stderr (stdio) or OpenTelemetry --> + +## Respect the client's log level +<!-- teaches: per-request logLevel _meta key (2026-07-28) vs logging/setLevel (2025-era); silent no-op when unset | salvage: docs/server.md "Logging" closing paragraph --> +<!-- code: none; one-line era cross-link to /protocol-versions --> + +## Stop work when the request is cancelled +<!-- teaches: ctx.mcpReq.signal is an AbortSignal aborted by notifications/cancelled and client disconnects | source: packages/core-internal/src/shared/protocol.ts (signal, line 406) --> +<!-- code: a long-running loop that checks ctx.mcpReq.signal.aborted and returns early --> +<!-- result: the client sees no response for the cancelled request; the handler stops burning work --> + +## Pass the signal to your own I/O +<!-- teaches: forwarding ctx.mcpReq.signal into fetch / db calls so cancellation propagates --> +<!-- code: fetch(url, { signal: ctx.mcpReq.signal }) inside the handler --> + +## Recap +<!-- the claims this page will prove: +- Every handler receives a context as its second argument; request-scoped helpers live on ctx.mcpReq. +- notify() sends notifications/progress when the client supplied a progressToken; progress must increase. +- log(level, data) sends structured log notifications once the logging capability is declared; logging is sunset (SEP-2577). +- The client's level filter is per-request on 2026-07-28 and per-session on 2025-era connections. +- ctx.mcpReq.signal aborts on cancellation; check it and forward it to your own I/O. +--> diff --git a/docs-v2/servers/notifications.md b/docs-v2/servers/notifications.md new file mode 100644 index 0000000000..2ad761474f --- /dev/null +++ b/docs-v2/servers/notifications.md @@ -0,0 +1,51 @@ +--- +status: scaffold +shape: how-to +--- +# Notifications + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Notify clients of changes. +teaches: sendToolListChanged, sendPromptListChanged, sendResourceListChanged, sendResourceUpdated, handler.notify, ServerEventBus +source: mined from docs/server.md "Change notifications" +era note (R8): the main column tells the handler.notify story once; the 2025-era hand-wired +subscribe path is a labeled aside, not a peer H2. The page's one era line links /protocol-versions. +--> + +## Send a list-changed notification +<!-- teaches: McpServer.sendToolListChanged() (and the prompt/resource siblings) | salvage: docs/server.md "Change notifications" --> + +```ts +// draft - API verified against packages/server/src/server/mcp.ts (sendToolListChanged, line 1129) +server.sendToolListChanged(); +``` +<!-- result: connected clients that declared the capability receive notifications/tools/list_changed and re-list. --> + +## Let registration changes notify for you +<!-- teaches: registering, enabling, disabling, updating, or removing a tool/prompt/resource emits the matching list_changed automatically | salvage: docs/server.md "Change notifications" (List changes) --> +<!-- code: registeredTool.update(...) / .disable() with a comment on the notification each emits --> + +## Advertise the `listChanged` capability +<!-- teaches: McpServer advertises listChanged on registration; declare it up front only on the low-level Server | salvage: docs/server.md "Change notifications" --> +<!-- code: capabilities: { tools: { listChanged: true } } on a low-level Server --> + +## Publish a resource update through the handler +<!-- teaches: clients subscribe via the serving entries (subscriptions/listen); you publish through the handler.notify.resourceUpdated / toolsChanged facade | salvage: docs/server.md "Change notifications" (subscriptions_notify) --> +<!-- code: const handler = createMcpHandler(() => buildServer()); handler.notify.resourceUpdated('config://app') --> +<!-- result: every client subscribed to that URI receives notifications/resources/updated --> +<!-- aside (::: info Coming from 2025-era subscriptions, labeled): resources: { subscribe: true } plus + hand-wired resources/subscribe/unsubscribe handlers and sendResourceUpdated({ uri }) still work + on older connections — compressed to this aside; salvage docs/server.md (subscriptions_legacy). + The era detail is ONE line linking /protocol-versions. --> + +## Pick an event bus for multi-process deployments +<!-- teaches: InMemoryServerEventBus default; supply a ServerEventBus via the `bus` option when you run more than one process | salvage: docs/server.md "Change notifications" closing paragraph --> +<!-- code: createMcpHandler(factory, { bus: myBus }) placeholder; cross-link serving/sessions-state-scaling.md --> + +## Recap +<!-- the claims this page will prove: +- send*ListChanged() pushes a list_changed notification; registration changes already send it for you. +- Delivery is capability-gated: only clients (and servers) that declared listChanged participate. +- Clients subscribe to per-resource updates through the serving entry; you publish through the handler's notify facade. +- One process needs nothing; multiple processes share a ServerEventBus. +--> diff --git a/docs-v2/servers/prompts.md b/docs-v2/servers/prompts.md new file mode 100644 index 0000000000..1363c5ae3c --- /dev/null +++ b/docs-v2/servers/prompts.md @@ -0,0 +1,64 @@ +--- +status: scaffold +shape: how-to +--- +# Prompts + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Register prompts, message construction. +teaches: McpServer.registerPrompt, PromptCallback, argsSchema +source: mined from docs/server.md "Prompts" +--> + +## Register a prompt +<!-- teaches: registerPrompt(name, config, cb) | salvage: docs/server.md "Prompts" (registerPrompt_basic) --> + +```ts +// draft - API verified against packages/server/src/server/mcp.ts (registerPrompt, line 1031) +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string(), + }), + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please review this code:\n\n${code}` }, + }, + ], + }) +); +``` +<!-- result: the prompt appears in prompts/list; prompts/get returns the messages with the argument filled in. --> + +## Validate the arguments with the schema +<!-- teaches: argsSchema is a Zod object; the SDK validates prompts/get arguments before the callback and infers the callback's argument types --> +<!-- code: prompts/get with a missing `code` argument --> +<!-- result: the verbatim -32602 Invalid Params error the client receives --> +<!-- the schema-payoff sentence lands here, once --> + +## Build the messages +<!-- teaches: PromptMessage shape - role ('user' | 'assistant') and content item types | salvage: docs/server.md "Prompts" --> +<!-- code: a two-message prompt (user + assistant) showing the role/content structure --> + +## Embed a resource in a message +<!-- teaches: content: { type: 'resource', resource: { uri, text, mimeType } } inside a prompt message --> +<!-- code: a prompt message whose content embeds a resource the server also registers --> + +## Offer argument autocompletion +<!-- teaches: hand-off - wrap an argsSchema field with completable(); full treatment on servers/completion.md --> +<!-- code: one line - completable(z.string(), value => [...]) inside argsSchema; cross-link servers/completion.md --> + +## Recap +<!-- the claims this page will prove: +- registerPrompt(name, config, callback) registers a prompt; clients discover it via prompts/list. +- argsSchema is one Zod object: validated arguments, inferred callback types, the argument list clients see. +- The callback returns { messages: [...] }; each message names a role and one content item. +- Messages can embed resources, not only text. +- completable() adds per-argument autocompletion. +--> diff --git a/docs-v2/servers/resources.md b/docs-v2/servers/resources.md new file mode 100644 index 0000000000..5fa0460b95 --- /dev/null +++ b/docs-v2/servers/resources.md @@ -0,0 +1,63 @@ +--- +status: scaffold +shape: how-to +--- +# Resources + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Static + templated resources, list callbacks. +teaches: McpServer.registerResource, ResourceTemplate, ReadResourceCallback, ListResourcesCallback +source: mined from docs/server.md "Resources" +--> + +## Register a static resource +<!-- teaches: registerResource (string URI overload) | salvage: docs/server.md "Resources" --> + +```ts +// draft - API verified against packages/server/src/server/mcp.ts (registerResource, line 580) +server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain', + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }], + }) +); +``` +<!-- result: resources/list now returns config://app; resources/read on it returns the contents array. --> + +## Return the contents from the read callback +<!-- teaches: ReadResourceCallback, ReadResourceResult.contents (text vs blob) | salvage: docs/server.md "Resources" --> +<!-- code: the same callback returning a text item and a base64 blob item; uri.href echoed back --> + +## Add a resource template +<!-- teaches: ResourceTemplate (uriTemplate, registerResource template overload), template variables in the read callback | salvage: docs/server.md "Resources" (registerResource_template) --> +<!-- code: new ResourceTemplate('user://{userId}/profile', { list: undefined }) passed to registerResource; handler receives (uri, { userId }) --> + +## List the template's instances +<!-- teaches: ListResourcesCallback (the required `list` option) | salvage: docs/server.md "Resources" (list callback) --> +<!-- code: same template with list: async () => ({ resources: [{ uri, name }, ...] }) --> +<!-- result: resources/list output showing the two concrete user:// URIs --> + +## Sanitize file-backed paths +<!-- teaches: path-traversal guard for file:// resources | salvage: docs/server.md "Resources" IMPORTANT security note --> +<!-- code: resolve the requested path and reject anything that escapes the root (.. and symlinks) --> +<!-- ::: warning placeholder: never pass template variables or client URIs to filesystem APIs unchecked --> + +## Tell clients when a resource changes +<!-- teaches: list_changed is automatic on (de)registration; per-resource updates live on the notifications page | salvage: docs/server.md "Change notifications" --> +<!-- code: one line - server.sendResourceListChanged(); cross-link servers/notifications.md --> + +## Recap +<!-- the claims this page will prove: +- registerResource(name, uri, config, readCallback) registers a fixed-URI resource. +- The read callback returns { contents: [...] }; each item carries uri plus text or blob. +- A ResourceTemplate registers a whole URI pattern; variables arrive parsed in the callback. +- The template's list callback is what makes instances discoverable via resources/list. +- File-backed resources must reject paths that escape the root. +- Registration changes emit notifications/resources/list_changed automatically. +--> diff --git a/docs-v2/servers/sampling.md b/docs-v2/servers/sampling.md new file mode 100644 index 0000000000..d30ae1517e --- /dev/null +++ b/docs-v2/servers/sampling.md @@ -0,0 +1,67 @@ +--- +status: scaffold +shape: how-to +--- +# Sampling + +::: warning Deprecated — SEP-2577 +<!-- SUNSET BANNER placeholder. Sampling is deprecated as of protocol version 2026-07-28 +(SEP-2577) and remains functional on 2025-era connections for at least twelve months. +Migration target named FIRST: call your LLM provider's API directly from your server. +Link the deprecated-features registry. This banner is the first thing on the page. --> +::: + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first. +teaches: ctx.mcpReq.requestSampling, CreateMessageRequestParams +source: mined from docs/server.md "Sampling" +mrtr note: inputRequired.createMessage is owned by servers/input-required.md (proposal §1 +taxonomy delta); this page carries one cross-link aside, never a second code block. +--> + +## Replace sampling with a direct provider call + +<!-- teaches: the migration target, not the feature - call your LLM provider's SDK/API from the tool handler with your own key | source: SEP-2577 framing in docs/server.md sampling WARNING; net-new framing mirroring clients/roots.md "Migrate away first" --> +<!-- code: none — this section is the off-ramp; one link to the deprecated-features registry --> + +## Request a completion from the client +<!-- teaches: ctx.mcpReq.requestSampling({ messages, maxTokens }) | salvage: docs/server.md "Sampling" (registerTool_sampling) --> + +```ts +// draft - API verified against packages/core-internal/src/shared/protocol.ts (ServerContext.mcpReq.requestSampling, line 481) +server.registerTool( + 'summarize', + { + description: 'Summarize text using the client LLM', + inputSchema: z.object({ text: z.string() }), + }, + async ({ text }, ctx) => { + const response = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: `Please summarize:\n\n${text}` } }], + maxTokens: 500, + }); + return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; + } +); +``` +<!-- result: the client runs the prompt through its model and the handler gets back { model, role, content }. --> +<!-- aside (::: info): requestSampling is a push and throws on a 2026-07-28 connection, where a + handler RETURNS the embedded request instead — one line, cross-link servers/input-required.md, + which owns that form. Era detail is one line linking /protocol-versions. --> + +## Read the model's reply +<!-- teaches: CreateMessageResult shape - model, role, content; the client picks the model --> +<!-- code: none beyond the lead; the verbatim result object --> +<!-- result: the JSON the handler receives, verbatim --> + +## Require the sampling capability +<!-- teaches: the client must declare sampling; the SDK rejects the request before the wire when it did not --> +<!-- code: none; one line on the error surfaced to the handler --> + +## Recap +<!-- the claims this page will prove: +- Sampling is sunset (SEP-2577); the migration target is a direct LLM provider call from your server. +- ctx.mcpReq.requestSampling asks the connected client's model for a completion mid-handler. +- The client owns model choice; the result carries model, role, and content. +- It only works when the client declared the sampling capability. +--> diff --git a/docs-v2/serving/authorization.md b/docs-v2/serving/authorization.md new file mode 100644 index 0000000000..47c211c481 --- /dev/null +++ b/docs-v2/serving/authorization.md @@ -0,0 +1,59 @@ +--- +status: scaffold +shape: how-to +--- +# Require authorization + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router. +teaches: requireBearerAuth, OAuthTokenVerifier, mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl, ctx.http.authInfo, per-tool scope checks +source: mined from docs/server.md "Authorization (OAuth resource server)" (the best long example in the set — lens 89); examples/scoped-tools/README.md; examples/bearer-auth/ +--> + +<!-- opening (before any H2) — the one-line auth router, mandatory: +Protecting a server you run -> this page. Signing a user in from a client -> /clients/oauth. No user present -> /clients/machine-auth. --> + +## Require a bearer token +<!-- teaches: requireBearerAuth({ verifier, requiredScopes, resourceMetadataUrl }) in front of the MCP route; your server is an OAuth RESOURCE server — it verifies tokens, it never issues them | salvage: docs/server.md "Authorization (OAuth resource server)" lead --> + +```ts +// draft - API verified against packages/middleware/express/src/auth/bearerAuth.ts (requireBearerAuth) and packages/middleware/express/src/auth/metadataRouter.ts (getOAuthProtectedResourceMetadataUrl) +import { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; + +// continuing from the Express recipe: `verifier`, `app`, and `node` already exist +const mcpServerUrl = new URL('https://api.example.com/mcp'); + +const auth = requireBearerAuth({ + verifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), +}); + +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); +``` +<!-- result: one line — a request without a valid token gets 401 invalid_token with a WWW-Authenticate: Bearer challenge --> + +## Verify tokens your way +<!-- teaches: OAuthTokenVerifier — verifyAccessToken(token) -> AuthInfo; JWT verification, RFC 7662 introspection, or a call to your IdP | salvage: docs/server.md auth_resourceServer region (verifier half) --> +<!-- code: const verifier: OAuthTokenVerifier = { async verifyAccessToken(token) { ... return { token, clientId, scopes, expiresAt } } } --> + +## Publish protected resource metadata +<!-- teaches: mcpAuthMetadataRouter serves /.well-known/oauth-protected-resource (RFC 9728) so clients can discover your AS; the 401 challenge's resource_metadata points at it | salvage: docs/server.md auth_resourceServer region (metadata half) --> +<!-- code: app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })) --> + +## Read the caller in your handlers +<!-- teaches: requireBearerAuth sets req.auth; toNodeHandler forwards it; tool handlers read ctx.http.authInfo and factories read ctx.authInfo | salvage: docs/server.md "requireBearerAuth attaches the verified AuthInfo..." paragraph --> +<!-- code: async (args, ctx) => { const who = ctx.http?.authInfo?.clientId; ... } --> + +## Enforce per-tool scopes +<!-- teaches: requiredScopes gates the whole endpoint; per-tool scopes are checked in the handler against ctx.http?.authInfo?.scopes, returning isError with insufficient_scope | salvage: examples/scoped-tools/README.md --> +<!-- code: if (!ctx.http?.authInfo?.scopes?.includes('files:write')) return { content: [...], isError: true } --> +<!-- aside: SEP-2350 scope step-up (the client retries after a 403 insufficient_scope challenge) — one line, link /clients/oauth --> + +## Recap +<!-- the claims this page proves: +- requireBearerAuth + an OAuthTokenVerifier turn any Express-mounted MCP route into an OAuth resource server. +- The SDK never issues tokens; AS helpers live frozen in @modelcontextprotocol/server-legacy/auth. +- mcpAuthMetadataRouter publishes the RFC 9728 document the 401 challenge points at. +- Validated auth flows req.auth -> ctx.http.authInfo; per-tool scopes are a handler check. +--> diff --git a/docs-v2/serving/express.md b/docs-v2/serving/express.md new file mode 100644 index 0000000000..6bd56e0400 --- /dev/null +++ b/docs-v2/serving/express.md @@ -0,0 +1,63 @@ +--- +status: scaffold +shape: how-to +--- +# Serve with Express + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Express recipe — self-contained, install one-liner at top, one back-link to http.md. +teaches: createMcpExpressApp, toNodeHandler, express.json -> req.body, allowedHosts +source: mined from docs/server.md "Serving the 2026-07-28 draft revision over HTTP" (Express mount line) + "DNS rebinding protection"; packages/middleware/express/README.md +--> + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express +``` + +## Mount the handler +<!-- teaches: toNodeHandler + app.all('/mcp') | salvage: docs/server.md createMcpHandler_node region (Express variant) --> +<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> + +```ts +// draft - API verified against packages/middleware/express/src/express.ts, packages/middleware/node/src/toNodeHandler.ts, packages/server/src/server/createMcpHandler.ts +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import express from 'express'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) + return server; +}); + +const app = createMcpExpressApp(); +app.use(express.json()); + +const node = toNodeHandler(handler); +app.all('/mcp', (req, res) => void node(req, res, req.body)); + +app.listen(3000); +``` +<!-- result: one line — http://127.0.0.1:3000/mcp answers MCP POSTs --> + +## Protect against DNS rebinding +<!-- teaches: createMcpExpressApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> +<!-- code: createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +## Forward auth and the parsed body +<!-- teaches: the third toNodeHandler arg is the parsed body (express.json -> req.body); requireBearerAuth sets req.auth and toNodeHandler forwards it to ctx.http.authInfo --> +<!-- code: app.all('/mcp', auth, (req, res) => void node(req, res, req.body)) — one line; link /serving/authorization --> + +## Run it and verify +<!-- teaches: start the process, point the Inspector (or curl) at http://127.0.0.1:3000/mcp --> +<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> +<!-- result: verbatim tools/list output --> + +## Recap +<!-- the claims this page proves: +- One install line, one file: createMcpExpressApp + toNodeHandler(createMcpHandler(factory)). +- toNodeHandler converts the web-standard handler to (req, res, parsedBody) once. +- DNS rebinding protection is on by default for localhost binds. +- Auth is pass-through: req.auth in, ctx.http.authInfo out. +--> diff --git a/docs-v2/serving/fastify.md b/docs-v2/serving/fastify.md new file mode 100644 index 0000000000..bed370db14 --- /dev/null +++ b/docs-v2/serving/fastify.md @@ -0,0 +1,60 @@ +--- +status: scaffold +shape: how-to +--- +# Serve with Fastify + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Fastify recipe — same shape as express.md. +teaches: createMcpFastifyApp, toNodeHandler over request.raw/reply.raw, request.body, allowedHosts +source: mined from packages/middleware/fastify/README.md (server.md never names createMcpFastifyApp — net-new wiring against packages/middleware/fastify/src/fastify.ts) + docs/server.md "DNS rebinding protection" +--> + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/fastify @modelcontextprotocol/node fastify +``` + +## Mount the handler +<!-- teaches: toNodeHandler over request.raw / reply.raw; Fastify parses JSON by default | salvage: packages/middleware/fastify/README.md "Streamable HTTP endpoint (Fastify)" --> +<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> + +```ts +// draft - API verified against packages/middleware/fastify/src/fastify.ts, packages/middleware/node/src/toNodeHandler.ts, packages/server/src/server/createMcpHandler.ts +import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) + return server; +}); + +const app = createMcpFastifyApp(); +const node = toNodeHandler(handler); +app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body)); + +await app.listen({ port: 3000 }); +``` +<!-- result: one line — http://127.0.0.1:3000/mcp answers MCP POSTs --> + +## Protect against DNS rebinding +<!-- teaches: createMcpFastifyApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> +<!-- code: createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +## Forward auth and the parsed body +<!-- teaches: Fastify already parsed request.body — pass it as toNodeHandler's third arg; attach validated AuthInfo to req.raw.auth (or call node with options) so handlers read ctx.http.authInfo --> +<!-- code: node(request.raw, reply.raw, request.body) — one line; link /serving/authorization --> + +## Run it and verify +<!-- teaches: start the process, point the Inspector (or curl) at http://127.0.0.1:3000/mcp --> +<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> +<!-- result: verbatim tools/list output --> + +## Recap +<!-- the claims this page proves: +- One install line, one file: createMcpFastifyApp + toNodeHandler(createMcpHandler(factory)). +- Fastify hands the raw req/res pair to the Node adapter; the body is already parsed. +- DNS rebinding protection is on by default for localhost binds. +- Auth is pass-through to ctx.http.authInfo. +--> diff --git a/docs-v2/serving/hono.md b/docs-v2/serving/hono.md new file mode 100644 index 0000000000..4e3fdb7d50 --- /dev/null +++ b/docs-v2/serving/hono.md @@ -0,0 +1,63 @@ +--- +status: scaffold +shape: how-to +--- +# Serve with Hono + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Hono recipe — same shape as express.md. +teaches: createMcpHonoApp, handler.fetch(c.req.raw), c.get('parsedBody'), allowedHosts +source: mined from docs/server.md "Streamable HTTP" (web-standard mounting paragraph) + "DNS rebinding protection"; packages/middleware/hono/README.md; examples/hono/ +--> + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono +``` + +## Mount the handler +<!-- teaches: handler.fetch on c.req.raw — no Node adapter needed | salvage: packages/middleware/hono/README.md "Streamable HTTP endpoint (Hono)" + examples/hono/server.ts --> +<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> + +```ts +// draft - API verified against packages/middleware/hono/src/hono.ts, packages/server/src/server/createMcpHandler.ts +import type { Context } from 'hono'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) + return server; +}); + +const app = createMcpHonoApp(); +app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') })); + +export default app; +``` +<!-- result: one line — /mcp answers MCP POSTs on whatever runtime serves the Hono app --> +<!-- prose-tranche note: the explicit `c: Context` annotation is load-bearing, not style. + createMcpHonoApp() returns a plain Hono, so an inferred callback context narrows the + c.get key parameter to `never` and `c.get('parsedBody')` is a type error (TS2769). + Keep the annotation until createMcpHonoApp types its Variables env. --> + +## Protect against DNS rebinding +<!-- teaches: createMcpHonoApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> +<!-- code: createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +## Forward auth and the parsed body +<!-- teaches: createMcpHonoApp parses JSON into c.get('parsedBody'); pass validated auth as handler.fetch(c.req.raw, { authInfo, parsedBody }) --> +<!-- code: (c: Context) => handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') }) — one line; link /serving/authorization --> + +## Run it and verify +<!-- teaches: start the process (node/bun/wrangler dev), point the Inspector (or curl) at /mcp --> +<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> +<!-- result: verbatim tools/list output --> + +## Recap +<!-- the claims this page proves: +- One install line, one file: createMcpHonoApp + createMcpHandler(factory).fetch. +- Hono hands the raw Request straight to handler.fetch — no Node adapter. +- DNS rebinding protection is on by default for localhost binds. +- Auth is pass-through via the second fetch argument. +--> diff --git a/docs-v2/serving/http.md b/docs-v2/serving/http.md new file mode 100644 index 0000000000..6b1496155d --- /dev/null +++ b/docs-v2/serving/http.md @@ -0,0 +1,68 @@ +--- +status: scaffold +shape: how-to +--- +# Serve over HTTP + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: createMcpHandler; the per-request factory model lives HERE (recipes link back). +teaches: createMcpHandler, McpServerFactory, McpRequestContext, McpHttpHandler.fetch, toNodeHandler, CreateMcpHandlerOptions (responseMode, onerror), handler.close +era/legacy note: the legacy: posture is owned by serving/legacy-clients.md (proposal §1); this page carries one aside that links it. +section-top note (proposal §3 path 2): the approved tree has no serving/ landing page, so the +two-sentence transport orientation ("launched locally by a host -> stdio; hosted for many +clients -> HTTP") lives at first-server.md's exit ("Pick a transport"); "atop the serving +section" needs a sidebar/section-blurb decision in the site tranche, not a new page. +source: mined from docs/server.md "Streamable HTTP" + "Serving the 2026-07-28 draft revision over HTTP" + "DNS rebinding protection" + "Shutdown" +--> + +## Create a handler +<!-- teaches: createMcpHandler | salvage: docs/server.md "Serving the 2026-07-28 draft revision over HTTP" --> + +```ts +// draft - API verified against packages/server/src/server/createMcpHandler.ts +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) — a fresh instance serves every request + return server; +}); +``` +<!-- result: one line — handler.fetch is a web-standard (Request) => Promise<Response>; nothing is listening yet --> +<!-- aside (::: info Coming from v1?): createMcpHandler replaces the per-request StreamableHTTPServerTransport + connect() wiring — run the codemod, then see /migration/upgrade-to-v2. --> + +## Understand the per-request factory +<!-- teaches: McpServerFactory, McpRequestContext ({ era, authInfo, requestInfo }); factories must be cheap and side-effect-free. THE canonical home of the factory model — all four recipe pages back-link here --> +<!-- code: factory reading ctx — createMcpHandler(({ authInfo }) => buildServerFor(authInfo)) --> + +## Mount it on your runtime +<!-- teaches: handler.fetch (Workers/Deno/Bun: export default handler) vs toNodeHandler(handler) for Express/Fastify/node:http | salvage: docs/server.md "handler.fetch is a web-standard..." paragraph --> +<!-- code: createServer(toNodeHandler(handler)).listen(3000) --> +<!-- link strip: /serving/express · /serving/hono · /serving/fastify · /serving/web-standard --> + +## Validate Host and Origin in front of it +<!-- teaches: the entry does NO Host/Origin validation or token verification itself; createMcp*App factories arm it by default | salvage: docs/server.md "DNS rebinding protection" --> +<!-- code: createMcpExpressApp() / hostHeaderValidationResponse for bare fetch runtimes --> + +## Pass authentication through +<!-- teaches: handler.fetch(request, { authInfo }) / toNodeHandler forwards req.auth; read it as ctx.http.authInfo | salvage: docs/server.md "Options:" paragraph + "Authorization (OAuth resource server)" --> +<!-- code: app.all('/mcp', auth, (req, res) => void node(req, res, req.body)) — one line; link /serving/authorization --> + +## Shape the response stream +<!-- teaches: responseMode 'auto' (default) | 'sse' | 'json'; 'json' drops mid-call notifications --> +<!-- code: createMcpHandler(factory, { responseMode: 'json' }) --> +<!-- aside (::: info): older clients are served statelessly by default; the `legacy:` option and the + full story live on /serving/legacy-clients. Era detail is one line linking /protocol-versions. --> + +## Shut down +<!-- teaches: handler.close() aborts in-flight modern exchanges | salvage: docs/server.md "Shutdown" --> +<!-- code: process.on('SIGINT', () => handler.close()) --> + +## Recap +<!-- the claims this page proves: +- createMcpHandler(factory) returns { fetch, close, notify, bus }; fetch is web-standard. +- One fresh server instance per request — define tools once in the factory. +- export default on web-standard runtimes; toNodeHandler once for Node frameworks. +- The handler does no Host/Origin validation and no token verification; mount those in front. +- responseMode shapes the response stream; 'json' drops mid-call notifications. +--> diff --git a/docs-v2/serving/legacy-clients.md b/docs-v2/serving/legacy-clients.md new file mode 100644 index 0000000000..93ef694fd4 --- /dev/null +++ b/docs-v2/serving/legacy-clients.md @@ -0,0 +1,46 @@ +--- +status: scaffold +shape: how-to +--- +# Support legacy clients + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: The legacy: option; where SSE went. +teaches: CreateMcpHandlerOptions.legacy ('stateless' | 'reject'), ServeStdioOptions.legacy ('serve' | 'reject'), isLegacyRequest, legacyStatelessFallback, @modelcontextprotocol/server-legacy/sse +source: mined from docs/server.md "Serving the 2026-07-28 draft revision over HTTP" Options + routing paragraphs; docs/faq.md "Why did we remove server SSE transport?"; examples/legacy-routing/, examples/dual-era/ +--> + +## Choose a legacy posture +<!-- teaches: legacy: 'stateless' (default — 2025 clients served per request from the same factory) vs 'reject' (modern-only strict) | salvage: docs/server.md "Options:" paragraph under createMcpHandler --> + +```ts +// draft - API verified against packages/server/src/server/createMcpHandler.ts (CreateMcpHandlerOptions.legacy: 'stateless' | 'reject') +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler( + () => new McpServer({ name: 'notes', version: '1.0.0' }), + { legacy: 'reject' }, +); +``` +<!-- result: one line — a 2025-era request gets the unsupported-protocol-version error naming the supported revisions; modern traffic is unaffected --> +<!-- aside: what "legacy" means is one line linking /protocol-versions --> + +## Choose the same posture on stdio +<!-- teaches: ServeStdioOptions.legacy ('serve' default | 'reject'); the era is pinned once per connection | salvage: docs/server.md "Options:" paragraph under serveStdio --> +<!-- code: serveStdio(factory, { legacy: 'reject' }) --> + +## Keep a sessionful 2025 deployment running +<!-- teaches: there is no handler-valued legacy option — route in user land with isLegacyRequest in front of a strict handler and hand legacy traffic to your existing wiring (or legacyStatelessFallback) | salvage: docs/server.md "To keep an existing sessionful 2025 deployment..." paragraph; examples/legacy-routing/server.ts, examples/dual-era/server.ts --> +<!-- code: if (isLegacyRequest(body)) return legacyHandler(request); return strict.fetch(request); --> + +## Know where SSE went +<!-- teaches: the v2 server does not serve the HTTP+SSE (2024) transport; the client keeps SSEClientTransport to reach old servers; a frozen v1 copy lives at @modelcontextprotocol/server-legacy/sse — migrate to Streamable HTTP | salvage: docs/faq.md "Why did we remove server SSE transport?" --> +<!-- code: none — one migration link to /migration/upgrade-to-v2 --> + +## Recap +<!-- the claims this page proves: +- Both entries serve 2025 clients from the same factory by default; 'reject' makes them modern-only. +- 'stateless' legacy serving is per-request: 2025 GET/DELETE session operations answer 405. +- An existing sessionful deployment keeps working behind isLegacyRequest routing. +- v2 never serves SSE; the frozen transport lives in @modelcontextprotocol/server-legacy/sse. +--> diff --git a/docs-v2/serving/sessions-state-scaling.md b/docs-v2/serving/sessions-state-scaling.md new file mode 100644 index 0000000000..e58ade8019 --- /dev/null +++ b/docs-v2/serving/sessions-state-scaling.md @@ -0,0 +1,46 @@ +--- +status: scaffold +shape: how-to +--- +# Sessions, state, and scaling + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Sessions, Resumability, Multi-node — stateless ruling first, two sentences. +teaches: the stateless-by-default ruling (createMcpHandler), sessionIdGenerator, EventStore/eventStore, ServerEventBus (multi-node listen), the three deployment topologies +source: mined from docs/server.md "Streamable HTTP" Options paragraph + "Shutdown"; examples/README.md "Multi-node deployment patterns" +NOTE: the three H2 titles below are VERBATIM per the approved proposal (§1 + Appendix A "sessions H2s verbatim") — Felix ruling; this page is the one sanctioned exception to imperative micro-step headings, and to the 4-H2 floor. +--> + +<!-- opening (before any H2), exactly two sentences — the stateless ruling: +`createMcpHandler` builds a fresh server instance per request and holds nothing between requests, so a v2 HTTP server is stateless and horizontally scalable by default. Read on only if you run a sessionful 2025-era deployment or need cross-request state. --> + +## Sessions +<!-- teaches: sessionIdGenerator (stateful) vs undefined (stateless); sessions are a 2025-era hand-wired-transport concept | salvage: docs/server.md "Streamable HTTP" Options paragraph; examples/legacy-routing/server.ts --> + +```ts +// draft - API verified against packages/middleware/node/src/streamableHttp.ts (NodeStreamableHTTPServerTransport, StreamableHTTPServerTransportOptions.sessionIdGenerator) +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { randomUUID } from 'node:crypto'; + +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), +}); +``` +<!-- result: one line — responses carry an Mcp-Session-Id header and the client replays it on every request --> +<!-- code (follow-up placeholder): the per-session transports map + routing by Mcp-Session-Id (salvage: examples/legacy-routing/server.ts, docs/server.md "Shutdown" transports map) --> + +## Resumability +<!-- teaches: EventStore / the eventStore transport option; replaying missed SSE events after a dropped connection | salvage: examples/README.md "Persistent storage mode"; examples/shared/src/inMemoryEventStore.ts; examples/sse-polling/ --> +<!-- code: new NodeStreamableHTTPServerTransport({ sessionIdGenerator, eventStore }) --> + +## Multi-node +<!-- teaches: the three topologies — stateless (default, nothing to do), persistent storage (shared eventStore), pub/sub message routing; for subscriptions/listen across nodes, pass a shared ServerEventBus to createMcpHandler({ bus }) | salvage: examples/README.md "Multi-node deployment patterns" (all three ASCII diagrams collapse to prose here) --> +<!-- code: createMcpHandler(factory, { bus: myDistributedBus }) --> + +## Recap +<!-- the claims this page proves: +- createMcpHandler is stateless per request; multi-node needs no session affinity. +- Sessions belong to hand-wired 2025-era transports: sessionIdGenerator turns them on. +- An EventStore makes a dropped SSE stream resumable from any node that shares it. +- subscriptions/listen scales across nodes by sharing one ServerEventBus. +--> diff --git a/docs-v2/serving/stdio.md b/docs-v2/serving/stdio.md new file mode 100644 index 0000000000..091c04b85c --- /dev/null +++ b/docs-v2/serving/stdio.md @@ -0,0 +1,52 @@ +--- +status: scaffold +shape: how-to +--- +# Serve over stdio + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: serveStdio and the console.error gotcha. +teaches: serveStdio, StdioServerHandle, console.error-vs-console.log, handle.close +source: mined from docs/server.md "stdio" + "Serving the 2026-07-28 draft revision on stdio" + "Shutdown"; docs/server-quickstart.md "Running your server" (the console.error IMPORTANT box) +era/legacy note: the legacy: posture is owned by serving/legacy-clients.md (proposal §1); this page carries one aside that links it. +--> + +## Serve a factory over stdio +<!-- teaches: serveStdio | salvage: docs/server.md "Serving the 2026-07-28 draft revision on stdio" --> + +```ts +// draft - API verified against packages/server/src/server/serveStdio.ts +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) — the same factory serves every era a client opens with + return server; +}); +``` +<!-- result: one line — the process is an MCP server on stdin/stdout; a host that spawns it can call its tools --> +<!-- aside (::: info Coming from v1?): serveStdio replaces the StdioServerTransport + connect() wiring — run the codemod, then see /migration/upgrade-to-v2. --> +<!-- aside (::: info): older clients are served from the same factory by default; the `legacy:` option and the + full story live on /serving/legacy-clients. Era detail is one line linking /protocol-versions. --> + +## Log to stderr, never stdout +<!-- teaches: the console.error gotcha | salvage: docs/server-quickstart.md "Running your server" IMPORTANT box (the #1 real-world stdio bug) --> +<!-- code: console.error('server ready') vs console.log — stdout is the JSON-RPC channel; one console.log corrupts it --> +<!-- result: the verbatim parse-error a host shows when a server writes to stdout --> + +## Test it with the Inspector +<!-- teaches: npx @modelcontextprotocol/inspector | salvage: docs/server-quickstart.md "Testing your server" --> +<!-- code: sh placeholder — npx @modelcontextprotocol/inspector node ./build/server.js --> + +## Shut down cleanly +<!-- teaches: StdioServerHandle.close(); SIGINT | salvage: docs/server.md "Shutdown" (stdio half) --> +<!-- code: process.on('SIGINT', () => handle.close()) --> + +## Recap +<!-- the claims this page proves: +- serveStdio(factory) is the stdio entry point; it owns the transport and builds the instance that serves the connection. +- stdout is the protocol channel; log with console.error. +- The Inspector exercises a stdio server without a host. +- handle.close() tears down the pinned instance and the transport. +--> diff --git a/docs-v2/serving/web-standard.md b/docs-v2/serving/web-standard.md new file mode 100644 index 0000000000..3a4fda8264 --- /dev/null +++ b/docs-v2/serving/web-standard.md @@ -0,0 +1,54 @@ +--- +status: scaffold +shape: how-to +--- +# Serve on web-standard runtimes + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Web-standard runtimes (Workers etc.) recipe — same shape as express.md. +teaches: export default handler ({ fetch }), McpHttpHandler.fetch, hostHeaderValidationResponse/originValidationResponse for bare runtimes +source: mined from docs/server.md "handler.fetch is a web-standard..." paragraph + "DNS rebinding protection" (framework-agnostic helpers); examples/hono/ (web-standard leg) +--> + +```sh +npm install @modelcontextprotocol/server +``` + +## Mount the handler +<!-- teaches: the handler IS the { fetch } object Workers/Deno/Bun expect from export default | salvage: docs/server.md "on Cloudflare Workers, Deno, or Bun, export default handler is all the mounting you need" --> +<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> + +```ts +// draft - API verified against packages/server/src/server/createMcpHandler.ts (McpHttpHandler is the { fetch, close, notify, bus } shape Workers/Bun/Deno expect from export default) +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) + return server; +}); + +export default handler; +``` +<!-- result: one line — the deployed Worker (or `bun run` / `deno serve` process) answers MCP POSTs on its URL --> + +## Protect against DNS rebinding +<!-- teaches: no app factory here — call the framework-agnostic guards before handler.fetch: hostHeaderValidationResponse / originValidationResponse from @modelcontextprotocol/server | salvage: docs/server.md "When mounting a handler bare on a fetch-native runtime..." --> +<!-- code: const rejected = hostHeaderValidationResponse(request, ['api.example.com']); if (rejected) return rejected; --> + +## Forward auth and the parsed body +<!-- teaches: route the Request yourself and pass options: handler.fetch(request, { authInfo }); no body middleware exists — fetch reads the Request body itself --> +<!-- code: async fetch(request) { return handler.fetch(request, { authInfo: await verify(request) }); } — link /serving/authorization --> + +## Run it and verify +<!-- teaches: wrangler dev / deno serve / bun run, then point the Inspector (or curl) at /mcp --> +<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:8787/mcp --> +<!-- result: verbatim tools/list output --> + +## Recap +<!-- the claims this page proves: +- The handler is already the export-default shape web-standard runtimes expect. +- No Node adapter and no body middleware are involved. +- On a bare runtime you mount Host/Origin validation yourself with the exported response helpers. +- Auth is pass-through via handler.fetch's second argument. +--> diff --git a/docs-v2/testing.md b/docs-v2/testing.md new file mode 100644 index 0000000000..69c191446b --- /dev/null +++ b/docs-v2/testing.md @@ -0,0 +1,57 @@ +--- +status: scaffold +shape: how-to +--- +# Test a server + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: In-memory linked pair + handler.fetch — no sockets. +teaches: createMcpHandler, McpHttpHandler.fetch, StreamableHTTPClientTransportOptions.fetch, Client, InMemoryTransport.createLinkedPair, serveStdio +source: mined from docs/migration/support-2026-07-28.md "In-process testing"; relocated here per agent-report 89 §5 hole 4 ("No testing guide") +--> + +## Serve the handler in-process +<!-- teaches: createMcpHandler + StreamableHTTPClientTransport fetch option | salvage: docs/migration/support-2026-07-28.md "In-process testing" --> +Pass `handler.fetch` as the client transport's `fetch` — the URL is never dialed; every request is served in-process, no port, no socket. + +```ts +// draft - API verified against packages/server/src/server/createMcpHandler.ts (createMcpHandler L575, McpServerFactory L115, McpHttpHandler.fetch L214) and packages/client/src/client/streamableHttp.ts (StreamableHTTPClientTransportOptions.fetch L184) +import { McpServer, createMcpHandler } from '@modelcontextprotocol/server'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const handler = createMcpHandler(() => new McpServer({ name: 'app', version: '1.0.0' })); +const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)), +}); +``` +<!-- result: connecting a Client over this transport exercises the real 2026-07-28 HTTP path with zero network --> + +## Connect a client and call a tool +<!-- teaches: Client.connect, Client.callTool --> +<!-- code: new Client({...}) + await client.connect(transport) + await client.callTool({ name, arguments }) --> + +## Assert on the result +<!-- teaches: CallToolResult.content, structuredContent, isError | salvage: docs/server.md "Tools" result shape --> +<!-- code: expect(result.structuredContent).toEqual(...) and the isError-true branch --> + +## Tear down between tests +<!-- teaches: handler.close, Client.close | salvage: docs/migration/support-2026-07-28.md McpHttpHandler.close --> +<!-- code: afterEach: await client.close(); await handler.close() --> + +## Pair two instances in memory +<!-- teaches: InMemoryTransport.createLinkedPair | salvage: docs/migration/support-2026-07-28.md "In-process testing" ("connects 2025-era instances only") --> +<!-- code: const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); connect a Server and a Client to each end --> +<!-- era caveat: ONE line linking /protocol-versions — the linked pair is 2025-era only; use handler.fetch for 2026-07-28 coverage --> + +## Cover stdio by spawning the process +<!-- teaches: serveStdio under a child process, StdioClientTransport | salvage: docs/migration/support-2026-07-28.md "In-process testing" final line --> +<!-- code: StdioClientTransport({ command: 'node', args: ['dist/server.js'] }) --> + +## Recap +<!-- the claims this page will prove: +- handler.fetch serves a Request in-process; the transport URL is never dialed. +- One Client + one createMcpHandler is a complete no-socket integration test. +- InMemoryTransport.createLinkedPair() pairs 2025-era instances; it is not a 2026-era entry. +- stdio coverage means spawning the real process. +- Close the client and the handler between tests. +--> diff --git a/docs-v2/troubleshooting.md b/docs-v2/troubleshooting.md new file mode 100644 index 0000000000..adb7042ae3 --- /dev/null +++ b/docs-v2/troubleshooting.md @@ -0,0 +1,68 @@ +--- +status: scaffold +shape: reference +--- +# Troubleshooting + +<!-- SCAFFOLD - structure only; prose comes in a later tranche. +scope: Verbatim error message as each heading; seeded from faq.md; pruning rule stated. +teaches: serveStdio, console.error-on-stdio, zod dedupe, globalThis.crypto, SdkError(EraNegotiationFailed), SdkError(MethodNotSupportedByProtocolVersion), @modelcontextprotocol/server-legacy +source: mined from docs/faq.md (all four entries), docs/server-quickstart.md "IMPORTANT" stdio box, docs/migration/support-2026-07-28.md "Client side: versionNegotiation" +FORMAT RULE (reference page): every H2 below is the VERBATIM error message a reader +pastes into search — not an imperative micro-step. Entries are ordered by how often +they hit, not by topic. +--> + +::: info +<!-- PRUNING RULE (stated on-page, proposal §5): an entry lives only as long as the +surface that produces it. Entries tied to a removed era, package, or Node version are +deleted with it — this page never accretes. --> +::: + +## `SyntaxError: Unexpected token ... is not valid JSON` +<!-- teaches: stdout is the wire on stdio; log to stderr | salvage: docs/server-quickstart.md "IMPORTANT" box (the #1 real-world stdio bug, agent-report 89 §7) --> +On stdio, standard output carries JSON-RPC. One `console.log` corrupts the stream; log to `stderr`. + +```ts +// draft - API verified against packages/server/src/server/serveStdio.ts (serveStdio L375) and packages/server/src/stdio.ts (subpath export) +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'app', version: '1.0.0' }); + console.error('app server running on stdio'); // stderr — never console.log on stdio + return server; +}); +``` +<!-- result: the client parses every stdout line as JSON-RPC; the stderr line shows up in the host's log, not on the wire --> + +## `TS2589: Type instantiation is excessively deep and possibly infinite` +<!-- teaches: single zod version in the tree | salvage: docs/faq.md "Why do I see TS2589 ... after upgrading the SDK?" --> +<!-- code: sh block — npm ls zod / pnpm why zod, then the overrides/resolutions fix --> + +## `ReferenceError: crypto is not defined` +<!-- teaches: globalThis.crypto polyfill for the OAuth client helpers on Node 18 | salvage: docs/faq.md "How do I enable Web Crypto ..." --> +<!-- code: ts block — node:crypto webcrypto polyfill assignment, mirroring packages/client/vitest.setup.js --> + +## `SdkError: ERA_NEGOTIATION_FAILED` +<!-- teaches: connect() rejects when the mode/supported-versions list leaves no era both sides speak | salvage: docs/migration/support-2026-07-28.md "Client side: versionNegotiation" --> +<!-- code: the failing shape (mode: { pin: '2026-07-28' } against a 2025-only server) and the two fixes (mode: 'auto', or add a 2025 entry to supportedProtocolVersions) --> +<!-- era caveat: ONE line linking /protocol-versions for what an era is --> + +## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` +<!-- teaches: an outbound spec method that the negotiated era does not define | salvage: docs/migration/support-2026-07-28.md "Appendix" behavior matrix row --> +<!-- code: the failing call and the matrix-backed replacement; one line linking /protocol-versions --> + +## `Module '"@modelcontextprotocol/server"' has no exported member 'SSEServerTransport'` +<!-- teaches: where server SSE and the AS auth helpers went (@modelcontextprotocol/server-legacy) | salvage: docs/faq.md "Why did we remove server SSE transport?" + "Where are the server auth helpers?" --> +<!-- code: the import rewrite — server SSE from @modelcontextprotocol/server-legacy/sse, AS helpers from @modelcontextprotocol/server-legacy/auth; RS helpers are first-class in @modelcontextprotocol/express --> + +## Recap +<!-- the claims this page will prove: +- Every heading is the exact message you searched for. +- On stdio, stdout is the protocol; log with console.error. +- TS2589 means two zod copies in the tree. +- ERA_NEGOTIATION_FAILED and METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION are negotiation outcomes, explained once on the protocol-versions page. +- Server SSE and the AS helpers live in @modelcontextprotocol/server-legacy. +- Entries die with the surface that produced them. +--> From 072478ecb025cbeb29b99bcdef1605fa87102f7a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 05:54:48 +0000 Subject: [PATCH 02/27] docs: add calibration pages with typechecked companion examples Fully write index, get-started/first-server, and servers/tools. Their code fences are region-synced from new examples/guides companion files that typecheck in the examples workspace. --- docs-v2/get-started/first-server.md | 136 +++++++++++++++ docs-v2/index.md | 50 ++++++ docs-v2/servers/tools.md | 159 ++++++++++++++++++ .../get-started/firstServer.examples.ts | 93 ++++++++++ examples/guides/index.examples.ts | 32 ++++ examples/guides/servers/tools.examples.ts | 119 +++++++++++++ 6 files changed, 589 insertions(+) create mode 100644 docs-v2/get-started/first-server.md create mode 100644 docs-v2/index.md create mode 100644 docs-v2/servers/tools.md create mode 100644 examples/guides/get-started/firstServer.examples.ts create mode 100644 examples/guides/index.examples.ts create mode 100644 examples/guides/servers/tools.examples.ts diff --git a/docs-v2/get-started/first-server.md b/docs-v2/get-started/first-server.md new file mode 100644 index 0000000000..989013dbea --- /dev/null +++ b/docs-v2/get-started/first-server.md @@ -0,0 +1,136 @@ +--- +status: calibration +shape: tutorial +--- + +# Build your first server + +Build an MCP **server** — a program that exposes tools a model can call — and call its one tool, a US weather-alert lookup, from a client. + +## Set up the project + +You need Node.js 20 or later and nothing else. Create the project and install the SDK. + +```sh +mkdir weather && cd weather +npm init -y +npm pkg set type=module +npm install @modelcontextprotocol/server zod tsx +mkdir src +``` + +`type=module` matters — the SDK ships ES modules only. `tsx` runs TypeScript directly, so there is no build step. + +## Register a tool + +Create `src/index.ts`: a `createServer` factory that builds an `McpServer` and registers one **tool** — a function the connected model can call. + +```ts source="../../examples/guides/get-started/firstServer.examples.ts#firstServer_registerTool" +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const NWS_API = 'https://api.weather.gov'; + +interface AlertsResponse { + features: { properties: { event?: string; headline?: string } }[]; +} + +function createServer(): McpServer { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-alerts', + { + description: 'Get the active weather alerts for a US state', + inputSchema: z.object({ + state: z.string().length(2).describe('Two-letter US state code, e.g. CA') + }) + }, + async ({ state }) => { + const code = state.toUpperCase(); + const url = `${NWS_API}/alerts/active?area=${code}`; + const res = await fetch(url, { headers: { 'User-Agent': 'mcp-weather-tutorial/1.0' } }); + if (!res.ok) { + return { content: [{ type: 'text', text: `NWS API error: HTTP ${res.status}` }], isError: true }; + } + const { features } = (await res.json()) as AlertsResponse; + if (features.length === 0) { + return { content: [{ type: 'text', text: `No active alerts for ${code}.` }] }; + } + const lines = features.map(f => f.properties.headline ?? f.properties.event ?? 'Unnamed alert'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + ); + + return server; +} +``` + +`registerTool` takes a name, a config, and an async handler. `inputSchema` is a Zod schema — the only schema you write. From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. + +The handler returns **content**, a list of typed blocks — one `text` block here. `isError: true` marks a failed result the model can read and react to. + +::: tip +Call `get-alerts` with `{ "state": "California" }` and the SDK rejects it before your handler runs. The result is the failure the model sees: + +```text +Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters +``` + +::: + +## Serve it over stdio + +At the end of the file, hand the factory to `serveStdio`. + +```ts source="../../examples/guides/get-started/firstServer.examples.ts#firstServer_serve" +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); +``` + +`serveStdio` owns the **stdio transport**: it reads requests on stdin, writes responses to stdout, and calls `createServer` to build the instance that serves the connection. + +::: warning +stdout is the protocol channel. Log with `console.error` — one `console.log` corrupts the JSON-RPC stream. +::: + +## Run it + +Start the server from the project root. + +```sh +npx tsx src/index.ts +``` + +The banner lands on stderr, leaving stdout for the protocol: + +```text +weather MCP server running on stdio +``` + +Nothing else happens: an stdio server waits on stdin for a client to start the conversation. Stop it with `Ctrl+C`. + +## Call the tool + +The **MCP Inspector** is a local web app for calling a server's tools directly — it launches the command you give it and connects over stdio. + +```sh +npx @modelcontextprotocol/inspector npx tsx src/index.ts +``` + +In the browser tab it opens, click **Connect**, open the **Tools** tab, select `get-alerts`, enter a two-letter state code such as `TX`, and run it. The text block in the result lists each active alert headline for that state — the same content a model receives when it calls your tool. + +## Pick a transport + +Your server speaks stdio because a host launches it as a local process and owns its lifetime. To host one endpoint that many clients connect to, serve the same `createServer` factory over [HTTP](../serving/http.md) instead. + +Next on this path, [Plug into a real host](./real-host.md) registers this server in VS Code, Claude Code, and Cursor; [Tools](../servers/tools.md) goes deeper on what a tool can return. + +## Recap + +- `registerTool(name, config, handler)` registers a tool; `inputSchema` is the one Zod schema you write. +- The SDK validates every call against that schema and rejects bad arguments before your handler runs. +- `serveStdio(createServer)` builds the server from your factory and serves it on stdin/stdout. +- stdout carries the protocol; log to stderr. +- `npx @modelcontextprotocol/inspector <command>` exercises any stdio server without a host. diff --git a/docs-v2/index.md b/docs-v2/index.md new file mode 100644 index 0000000000..eb3464d819 --- /dev/null +++ b/docs-v2/index.md @@ -0,0 +1,50 @@ +--- +status: calibration +shape: landing +--- + +# MCP TypeScript SDK + +This is a complete MCP server: one tool, served over stdio. + +```ts source="../examples/guides/index.examples.ts#serveStdio_minimal" +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +serveStdio(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-forecast', + { + description: 'Get the weather forecast for a city', + inputSchema: z.object({ city: z.string() }) + }, + async ({ city }) => ({ + content: [{ type: 'text', text: `Sunny in ${city} all week.` }] + }) + ); + + return server; +}); +``` + +Any MCP host that launches this program lists and calls `get-forecast`; the SDK validates every call against that `z.object(...)` schema before your handler runs. [Build a server](./get-started/first-server.md) installs the packages and runs it end to end. + +The **Model Context Protocol** (MCP) is an open standard that connects AI applications to the systems where your data and tools already live. You write a **server** that exposes tools (and resources and prompts — data and templates a host can read); any MCP **host** (Claude Code, VS Code, Cursor, your own application) connects to it and lets a model use them. This SDK is the TypeScript implementation of both sides — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — and runs on Node.js, Bun, and Deno. + +## Pick a path + +- Expose your API or data to AI applications → **[Build a server](./get-started/first-server.md)** +- Build an application that talks to MCP servers → **[Build a client](./get-started/first-client.md)** +- Coming from v1 (`@modelcontextprotocol/sdk`) → **[Upgrade](./migration/index.md)** +- Drop MCP into the app you already run → **[Express](./serving/express.md)** · **[Hono](./serving/hono.md)** · **[Fastify](./serving/fastify.md)** · **[Workers](./serving/web-standard.md)** + +For exact signatures, go to the [API reference](https://ts.sdk.modelcontextprotocol.io/v2/). + +## Recap + +- MCP connects AI applications to the systems where your tools and data live; you build one side, a host brings the model. +- `registerTool(name, config, handler)` with a `z.object(...)` `inputSchema` defines a tool; `serveStdio` serves it over stdio. +- Four starting points: build a server, build a client, upgrade from v1, or drop into Express, Hono, Fastify, or Workers. diff --git a/docs-v2/servers/tools.md b/docs-v2/servers/tools.md new file mode 100644 index 0000000000..554669c144 --- /dev/null +++ b/docs-v2/servers/tools.md @@ -0,0 +1,159 @@ +--- +status: calibration +shape: how-to +--- + +# Tools + +A **tool** is an action a connected client — and the model driving it — can invoke on your server. + +## Add a tool + +`registerTool` takes a name, a config, and a handler. `inputSchema` is a Zod schema — the only schema you write. + +```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_search" +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const catalog = [ + { name: 'Espresso cup', price: 12 }, + { name: 'Travel mug', price: 24 }, + { name: 'Mug rack', price: 36 } +]; + +const server = new McpServer({ name: 'catalog', version: '1.0.0' }); + +server.registerTool( + 'search', + { + description: 'Search the product catalog', + inputSchema: z.object({ + query: z.string().describe('Substring to match against product names'), + limit: z.number().int().max(50).optional() + }) + }, + async ({ query, limit }) => { + const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); + const names = hits.slice(0, limit ?? 10).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; + } +); +``` + +From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. + +`tools/list` now advertises `search`, and the SDK has already parsed every call that reaches your handler. + +::: tip +`.describe()` survives the conversion: the JSON Schema advertised for `query` carries `Substring to match against product names` as its `description` — the only documentation the model gets for that argument. +::: + +::: info Coming from v1? +`registerTool` replaces `tool()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: + +## Call it + +Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Call the tool with valid arguments. + +```ts source="../../examples/guides/servers/tools.examples.ts#callTool_search" +const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } }); +console.log(result.content); +``` + +The handler's `content` comes back unchanged: + +``` +[ { type: 'text', text: 'Travel mug\nMug rack' } ] +``` + +## Send arguments the schema rejects + +Change one argument: a `limit` the schema caps at 50. + +```ts source="../../examples/guides/servers/tools.examples.ts#callTool_invalid" +const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } }); +console.log(rejected); +``` + +The SDK rejects the arguments before your handler runs: + +``` +{ + content: [ + { + type: 'text', + text: 'Input validation error: Invalid arguments for tool search: limit: Too big: expected number to be <=50' + } + ], + isError: true +} +``` + +The rejection is an ordinary tool result with `isError: true`, so the model reads the message and retries with arguments that fit the schema. Thrown errors and protocol-level failures are their own topic — see [Errors](errors.md). + +## Return structured output + +Add `outputSchema` and return the matching value as `structuredContent`, next to the human-readable `content`. + +```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_structured" +server.registerTool( + 'product-details', + { + description: 'Look up one product by its exact name', + inputSchema: z.object({ name: z.string() }), + outputSchema: z.object({ name: z.string(), price: z.number() }) + }, + async ({ name }) => { + const product = catalog.find(candidate => candidate.name === name); + if (!product) throw new Error(`No product named ${name}`); + const output = { name: product.name, price: product.price }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +``` + +The SDK validates `structuredContent` against `outputSchema` before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too. + +Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: + +``` +{ + content: [ { type: 'text', text: '{"name":"Travel mug","price":24}' } ], + structuredContent: { name: 'Travel mug', price: 24 } +} +``` + +The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md). + +## Annotate the tool + +`title` is the display name; `annotations` are behavior hints for the client. + +```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_annotations" +server.registerTool( + 'clear-catalog', + { + title: 'Clear the catalog', + description: 'Remove every product from the catalog', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true } + }, + async () => { + catalog.length = 0; + return { content: [{ type: 'text', text: 'Catalog cleared' }] }; + } +); +``` + +A tool that takes no arguments omits `inputSchema`. Annotations never change how the SDK runs the tool — clients use them to decide what to put in front of the end user: a host can auto-approve a read-only tool and require confirmation before a destructive one. + +## Recap + +- `registerTool(name, config, handler)` registers a tool; `inputSchema` is a Zod object schema. +- The one schema yields the advertised JSON Schema, argument validation, and the handler's argument types. +- Arguments that fail the schema come back as an `isError: true` tool result; the handler never runs. +- `outputSchema` plus `structuredContent` add machine-readable results, validated before they leave the server. +- `title` and `annotations` describe the tool to clients and never change execution. diff --git a/examples/guides/get-started/firstServer.examples.ts b/examples/guides/get-started/firstServer.examples.ts new file mode 100644 index 0000000000..aacaa66288 --- /dev/null +++ b/examples/guides/get-started/firstServer.examples.ts @@ -0,0 +1,93 @@ +/** + * Runnable, type-checked companion for `docs-v2/get-started/first-server.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions + * are one linear program — the `src/index.ts` the tutorial builds. Running the + * file (`npx tsx firstServer.examples.ts`) starts that server and then runs the + * harness below the regions, which proves the validation-error output the page + * quotes verbatim and exits. + * + * @module + */ +/* eslint-disable no-console */ + +//#region firstServer_registerTool +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const NWS_API = 'https://api.weather.gov'; + +interface AlertsResponse { + features: { properties: { event?: string; headline?: string } }[]; +} + +function createServer(): McpServer { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-alerts', + { + description: 'Get the active weather alerts for a US state', + inputSchema: z.object({ + state: z.string().length(2).describe('Two-letter US state code, e.g. CA') + }) + }, + async ({ state }) => { + const code = state.toUpperCase(); + const url = `${NWS_API}/alerts/active?area=${code}`; + const res = await fetch(url, { headers: { 'User-Agent': 'mcp-weather-tutorial/1.0' } }); + if (!res.ok) { + return { content: [{ type: 'text', text: `NWS API error: HTTP ${res.status}` }], isError: true }; + } + const { features } = (await res.json()) as AlertsResponse; + if (features.length === 0) { + return { content: [{ type: 'text', text: `No active alerts for ${code}.` }] }; + } + const lines = features.map(f => f.properties.headline ?? f.properties.event ?? 'Unnamed alert'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + ); + + return server; +} +//#endregion firstServer_registerTool + +//#region firstServer_serve +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); +//#endregion firstServer_serve + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). The page's ::: tip quotes the SDK's +// validation error for `get-alerts` called with `{ state: 'California' }` +// verbatim; this proves it. A second server instance from the same factory is +// driven by an in-memory client (any MCP client behaves the same), and the +// process exits non-zero if the produced text drifts from what the page quotes. +// `serveStdio` above is still waiting on stdin, so the harness exits explicitly. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const harnessServer = createServer(); +const harnessClient = new Client({ name: 'first-server-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await harnessServer.connect(serverTransport); +await harnessClient.connect(clientTransport); + +// "Call `get-alerts` with `{ "state": "California" }`" — the rejection the tip quotes. +const rejected = await harnessClient.callTool({ name: 'get-alerts', arguments: { state: 'California' } }); +const block = rejected.content[0]; +const quotedOnPage = + 'Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters'; +if (rejected.isError !== true || block?.type !== 'text' || block.text !== quotedOnPage) { + throw new Error(`first-server.md tip output drifted from the SDK: ${JSON.stringify(rejected)}`); +} + +await harnessClient.close(); +await harnessServer.close(); +// `serveStdio` above is still reading stdin; this file runs as a program, so end it here. +// eslint-disable-next-line unicorn/no-process-exit +process.exit(0); diff --git a/examples/guides/index.examples.ts b/examples/guides/index.examples.ts new file mode 100644 index 0000000000..2d3244655b --- /dev/null +++ b/examples/guides/index.examples.ts @@ -0,0 +1,32 @@ +/** + * Type-checked companion for `docs-v2/index.md` (the landing page). + * + * The single region below is the landing hero: a complete MCP server in one + * block. Imports live inside the region so the rendered block stands alone. + * Synced into the page by `pnpm sync:snippets`; typecheck-only, never executed. + * + * @module + */ + +//#region serveStdio_minimal +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +serveStdio(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-forecast', + { + description: 'Get the weather forecast for a city', + inputSchema: z.object({ city: z.string() }) + }, + async ({ city }) => ({ + content: [{ type: 'text', text: `Sunny in ${city} all week.` }] + }) + ); + + return server; +}); +//#endregion serveStdio_minimal diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts new file mode 100644 index 0000000000..e610e7404c --- /dev/null +++ b/examples/guides/servers/tools.examples.ts @@ -0,0 +1,119 @@ +/** + * Companion example for `docs-v2/servers/tools.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/tools.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerTool_search +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const catalog = [ + { name: 'Espresso cup', price: 12 }, + { name: 'Travel mug', price: 24 }, + { name: 'Mug rack', price: 36 } +]; + +const server = new McpServer({ name: 'catalog', version: '1.0.0' }); + +server.registerTool( + 'search', + { + description: 'Search the product catalog', + inputSchema: z.object({ + query: z.string().describe('Substring to match against product names'), + limit: z.number().int().max(50).optional() + }) + }, + async ({ query, limit }) => { + const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); + const names = hits.slice(0, limit ?? 10).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; + } +); +//#endregion registerTool_search + +//#region registerTool_structured +server.registerTool( + 'product-details', + { + description: 'Look up one product by its exact name', + inputSchema: z.object({ name: z.string() }), + outputSchema: z.object({ name: z.string(), price: z.number() }) + }, + async ({ name }) => { + const product = catalog.find(candidate => candidate.name === name); + if (!product) throw new Error(`No product named ${name}`); + const output = { name: product.name, price: product.price }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +//#endregion registerTool_structured + +//#region registerTool_annotations +server.registerTool( + 'clear-catalog', + { + title: 'Clear the catalog', + description: 'Remove every product from the catalog', + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true } + }, + async () => { + catalog.length = 0; + return { content: [{ type: 'text', text: 'Catalog cleared' }] }; + } +); +//#endregion registerTool_annotations + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output servers/tools.md quotes verbatim. Any MCP client behaves the same. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'tools-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Call it" — the happy path the page quotes. +//#region callTool_search +const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } }); +console.log(result.content); +//#endregion callTool_search + +// "Send arguments the schema rejects" — the rejection the page quotes. +//#region callTool_invalid +const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } }); +console.log(rejected); +//#endregion callTool_invalid + +// "Return structured output" — the structured result the page quotes. +const details = await client.callTool({ name: 'product-details', arguments: { name: 'Travel mug' } }); +console.log(details); + +// Proof for the page's ::: tip — `.describe()` lands in the JSON Schema that +// `tools/list` advertises for the `query` argument. Throws (non-zero exit) if +// the claim is false. +const { tools } = await client.listTools(); +const searchTool = tools.find(tool => tool.name === 'search'); +const properties = searchTool?.inputSchema.properties as Record<string, { description?: string }> | undefined; +if (properties?.['query']?.description !== 'Substring to match against product names') { + throw new Error(`tools.md tip claim failed: query.description is ${JSON.stringify(properties?.['query'])}`); +} + +await client.close(); +await server.close(); From 7efb305ed87acc17fc3183dfed030b34bd5c6115 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 14:11:02 +0000 Subject: [PATCH 03/27] docs: integrate the phase-2 page tree into docs/ Moves the docs-v2 scaffold into docs/ proper, wires the 45-page sidebar, keeps the existing guide pages under a collapsed 'Current guides (being replaced)' group, and replaces the README-generated landing with the calibration index. Dead-link checking is suspended on this branch only while most pages are scaffolds. --- .gitignore | 1 - docs-v2/migration/index.md | 54 - docs-v2/migration/support-2026-07-28.md | 633 --------- docs-v2/migration/upgrade-to-v2.md | 1237 ----------------- docs/.vitepress/config.mts | 85 +- {docs-v2 => docs}/_meta/CONVENTIONS.md | 0 {docs-v2 => docs/_meta}/_TREE.md | 0 {docs-v2 => docs}/advanced/custom-methods.md | 0 .../advanced/custom-transports.md | 0 {docs-v2 => docs}/advanced/gateway.md | 0 .../advanced/low-level-server.md | 0 .../advanced/schema-libraries.md | 0 {docs-v2 => docs}/advanced/wire-schemas.md | 0 {docs-v2 => docs}/clients/caching.md | 0 {docs-v2 => docs}/clients/calling.md | 0 {docs-v2 => docs}/clients/connect.md | 0 {docs-v2 => docs}/clients/machine-auth.md | 0 {docs-v2 => docs}/clients/middleware.md | 0 {docs-v2 => docs}/clients/oauth.md | 0 {docs-v2 => docs}/clients/roots.md | 0 {docs-v2 => docs}/clients/server-requests.md | 0 {docs-v2 => docs}/clients/subscriptions.md | 0 {docs-v2 => docs}/get-started/first-client.md | 0 {docs-v2 => docs}/get-started/first-server.md | 0 {docs-v2 => docs}/get-started/packages.md | 0 {docs-v2 => docs}/get-started/real-host.md | 0 {docs-v2 => docs}/index.md | 0 {docs-v2 => docs}/protocol-versions.md | 0 {docs-v2 => docs}/servers/completion.md | 0 {docs-v2 => docs}/servers/elicitation.md | 0 {docs-v2 => docs}/servers/errors.md | 0 {docs-v2 => docs}/servers/input-required.md | 0 .../servers/logging-progress-cancellation.md | 0 {docs-v2 => docs}/servers/notifications.md | 0 {docs-v2 => docs}/servers/prompts.md | 0 {docs-v2 => docs}/servers/resources.md | 0 {docs-v2 => docs}/servers/sampling.md | 0 {docs-v2 => docs}/servers/tools.md | 0 {docs-v2 => docs}/serving/authorization.md | 0 {docs-v2 => docs}/serving/express.md | 0 {docs-v2 => docs}/serving/fastify.md | 0 {docs-v2 => docs}/serving/hono.md | 0 {docs-v2 => docs}/serving/http.md | 0 {docs-v2 => docs}/serving/legacy-clients.md | 0 .../serving/sessions-state-scaling.md | 0 {docs-v2 => docs}/serving/stdio.md | 0 {docs-v2 => docs}/serving/web-standard.md | 0 {docs-v2 => docs}/testing.md | 0 {docs-v2 => docs}/troubleshooting.md | 0 package.json | 4 +- 50 files changed, 77 insertions(+), 1937 deletions(-) delete mode 100644 docs-v2/migration/index.md delete mode 100644 docs-v2/migration/support-2026-07-28.md delete mode 100644 docs-v2/migration/upgrade-to-v2.md rename {docs-v2 => docs}/_meta/CONVENTIONS.md (100%) rename {docs-v2 => docs/_meta}/_TREE.md (100%) rename {docs-v2 => docs}/advanced/custom-methods.md (100%) rename {docs-v2 => docs}/advanced/custom-transports.md (100%) rename {docs-v2 => docs}/advanced/gateway.md (100%) rename {docs-v2 => docs}/advanced/low-level-server.md (100%) rename {docs-v2 => docs}/advanced/schema-libraries.md (100%) rename {docs-v2 => docs}/advanced/wire-schemas.md (100%) rename {docs-v2 => docs}/clients/caching.md (100%) rename {docs-v2 => docs}/clients/calling.md (100%) rename {docs-v2 => docs}/clients/connect.md (100%) rename {docs-v2 => docs}/clients/machine-auth.md (100%) rename {docs-v2 => docs}/clients/middleware.md (100%) rename {docs-v2 => docs}/clients/oauth.md (100%) rename {docs-v2 => docs}/clients/roots.md (100%) rename {docs-v2 => docs}/clients/server-requests.md (100%) rename {docs-v2 => docs}/clients/subscriptions.md (100%) rename {docs-v2 => docs}/get-started/first-client.md (100%) rename {docs-v2 => docs}/get-started/first-server.md (100%) rename {docs-v2 => docs}/get-started/packages.md (100%) rename {docs-v2 => docs}/get-started/real-host.md (100%) rename {docs-v2 => docs}/index.md (100%) rename {docs-v2 => docs}/protocol-versions.md (100%) rename {docs-v2 => docs}/servers/completion.md (100%) rename {docs-v2 => docs}/servers/elicitation.md (100%) rename {docs-v2 => docs}/servers/errors.md (100%) rename {docs-v2 => docs}/servers/input-required.md (100%) rename {docs-v2 => docs}/servers/logging-progress-cancellation.md (100%) rename {docs-v2 => docs}/servers/notifications.md (100%) rename {docs-v2 => docs}/servers/prompts.md (100%) rename {docs-v2 => docs}/servers/resources.md (100%) rename {docs-v2 => docs}/servers/sampling.md (100%) rename {docs-v2 => docs}/servers/tools.md (100%) rename {docs-v2 => docs}/serving/authorization.md (100%) rename {docs-v2 => docs}/serving/express.md (100%) rename {docs-v2 => docs}/serving/fastify.md (100%) rename {docs-v2 => docs}/serving/hono.md (100%) rename {docs-v2 => docs}/serving/http.md (100%) rename {docs-v2 => docs}/serving/legacy-clients.md (100%) rename {docs-v2 => docs}/serving/sessions-state-scaling.md (100%) rename {docs-v2 => docs}/serving/stdio.md (100%) rename {docs-v2 => docs}/serving/web-standard.md (100%) rename {docs-v2 => docs}/testing.md (100%) rename {docs-v2 => docs}/troubleshooting.md (100%) diff --git a/.gitignore b/.gitignore index 64af8fe95a..317c96abca 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ dist/ .worktrees/ # Generated docs-site artifacts -docs/index.md docs/api/ docs/.vitepress/cache/ docs/.vitepress/dist/ diff --git a/docs-v2/migration/index.md b/docs-v2/migration/index.md deleted file mode 100644 index c9c4161b60..0000000000 --- a/docs-v2/migration/index.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Migration Guides -children: - - ./upgrade-to-v2.md - - ./support-2026-07-28.md ---- - -# MCP TypeScript SDK — Migration Guides - -Pick the guide for your starting point. - -## Upgrading from v1.x (`@modelcontextprotocol/sdk`) - -→ **[upgrade-to-v2.md](./upgrade-to-v2.md)** - -You are on the monolithic `@modelcontextprotocol/sdk` package and want to move to the -v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …). - -Start by running the codemod: - -```bash -npx @modelcontextprotocol/codemod@alpha v1-to-v2 . -``` - -Run it at the package root (`.`) — real projects import the SDK from `test/`, -`scripts/`, and fixtures too, and those rewrites are missed when you point it at `./src`. - -The codemod handles most mechanical renames. The guide covers what it can't. The -codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 protocol -revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) is -architectural and not codemod-automatable; see [support-2026-07-28.md](./support-2026-07-28.md). - -## Already on v2, adopting protocol revision 2026-07-28 - -→ **[support-2026-07-28.md](./support-2026-07-28.md)** - -You are already on the v2 packages and want your server or client to speak the -2026-07-28 protocol revision (per-request `_meta` envelope, `createMcpHandler`, -`serveStdio`, `versionNegotiation`, multi-round-trip requests, per-era wire codecs). - -This guide also covers code written against an earlier **v2 alpha** that read -wire-only members (`resultType`, envelope keys) directly. - -## Using an LLM agent to migrate - -[upgrade-to-v2.md](./upgrade-to-v2.md) is the agent skill — it carries skill -frontmatter and is structured for mechanical application. Point the agent at -the codemod first; the guide is the codemod's companion for what's left. - -## See also - -- [`@modelcontextprotocol/codemod` README](../../packages/codemod/README.md) -- [FAQ](../faq.md) -- [Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) diff --git a/docs-v2/migration/support-2026-07-28.md b/docs-v2/migration/support-2026-07-28.md deleted file mode 100644 index ce733f729e..0000000000 --- a/docs-v2/migration/support-2026-07-28.md +++ /dev/null @@ -1,633 +0,0 @@ ---- -title: Supporting protocol revision 2026-07-28 ---- - -# Supporting protocol revision 2026-07-28 - -This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 -protocol revision — and for code written against an earlier **v2 alpha** that read -wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with -[upgrade-to-v2.md](./upgrade-to-v2.md) instead. - -> **Schema artifact:** until the revision is finalized, the spec repository publishes -> the 2026-07-28 schema under `schema/draft/` — there is no `schema/2026-07-28/` -> directory yet. Tooling that vendors per-revision schema artifacts should track -> `draft/` and note the divergence. - -Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed -`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written -for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries -below. - -## Contents - -- [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) -- [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) -- [Auth on 2026-07-28](#auth-on-2026-07-28) -- [Per-era wire codecs](#per-era-wire-codecs) -- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) -- [Multi-round-trip requests](#multi-round-trip-requests) -- [Legacy shim for `input_required`](#legacy-shim-for-input_required) -- [`subscriptions/listen`](#subscriptionslisten) -- [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) -- [Cache fields and cache hints](#cache-fields-and-cache-hints) -- [Tasks: deprecated wire vocabulary](#tasks-deprecated-wire-vocabulary) -- [Appendix: 2025-era vs 2026-era behavior matrix](#appendix-2025-era-vs-2026-era-behavior-matrix) - ---- - -## Serving the 2026-07-28 revision - -These entry points are documented in full in the server and client guides; this section -contextualizes them as the migration path. - -### Client side: `versionNegotiation` - -By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, -byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — -see [client.md › Protocol version negotiation](../client.md#protocol-version-negotiation-2026-07-28-revision). - -```typescript -const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); -await client.connect(transport); -client.getProtocolEra(); // 'modern' | 'legacy' -``` - -- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. -- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on - the same connection against a 2025-only server (one extra round trip). -- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with - `SdkError(EraNegotiationFailed)` against a 2025-only server. - -`ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy -`initialize` handshake offers (see -[upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection--dispatch)) -— shapes `'auto'`: the modern candidates are the option's modern entries (when it lists -any; otherwise the SDK's default modern set), and legacy fallback is available only if -the list has a pre-2026 entry. A `{ pin }` is honored as given — it must name a modern -revision but is not checked against the list. - -#### Probe policy - -Failure semantics under `'auto'` are deliberately conservative but never silent about -infrastructure problems. Anything the probe does not positively recognize as modern -falls back to the legacy era — provided the supported-versions list still contains a -2025-era revision; with a modern-only list `connect()` rejects with -`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect -error. Probe timeouts are **transport-aware**: on **stdio** a server that does not -answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` -on the same stream (some legacy servers never respond to unknown pre-`initialize` -requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — -a dead HTTP server is never misreported as legacy. One browser-specific exception: an -opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because -deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers. - -```typescript -versionNegotiation: { - mode: 'auto', - probe: { - timeoutMs: 10_000, // default: the standard request timeout - maxRetries: 0 // default: no retries — governs timeout re-sends only - } -} -``` - -`maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective -continuation — select-and-continue with a mutual version — is a separate negotiation step -and is never counted against it). - -**Who should not default to `'auto'`:** spawn-per-invocation CLI and debugging tools. -On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls -`connect()` for the full probe timeout before falling back; and the probe round trip -changes recorded transcripts/raw logs, which matters for tools whose value is -byte-stable observation. Such tools should keep the default and expose `'auto'` / -a pin as an explicit flag. - -The probe request itself already carries the per-request `_meta` envelope -(`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`) — -**before** the era is known. Once a modern era is negotiated the client auto-attaches -the envelope to every outgoing request and notification. Tooling that classifies -traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback -path also begins with one enveloped probe. A gateway/worker fleet can skip the -probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. - -### Server over HTTP: `createMcpHandler` - -`createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry -that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also -serves 2025-era traffic per request through the established stateless idiom. One -factory, one endpoint, both eras. - -```typescript -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; - -const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once — the same factory backs both eras - return server; -}); -// Web-standard runtimes: export default handler; -// Node frameworks: app.all('/mcp', toNodeHandler(handler)) from @modelcontextprotocol/node -``` - -A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, -fresh transport per request) maps directly onto the default entry. An existing -**sessionful** v1 Streamable HTTP setup keeps serving 2025 clients by routing it in -front of a strict (`legacy: 'reject'`) entry with `isLegacyRequest(request)`: - -```typescript -const modern = createMcpHandler(factory, { legacy: 'reject' }); -export default { - async fetch(request: Request) { - if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); - return modern.fetch(request); - } -}; -``` - -`isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope -claim; route `false` traffic to the modern handler (a malformed modern claim is `false` -and answered `-32602` / `-32020` by the modern path). The handler is web-standards-only -(`{ fetch, close, notify, bus }`); on Node frameworks wrap once with -`toNodeHandler(handler, { onerror? })` from `@modelcontextprotocol/node`. The exported -`legacyStatelessFallback(factory)` is the same stateless 2025 serving as a standalone -fetch-shaped handler. - -> **If you were on a v2 alpha:** `handler.node(req, res, body)` is gone — replace with -> `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. -> `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from -> `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. - -### Server over stdio / long-lived connections: `serveStdio` - -A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` -serves only the 2025-era protocol — upgrading the SDK changes nothing about what it puts -on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the -connection-pinned `serveStdio(() => buildServer())` entry from -`@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, -and one factory instance is pinned per connection. See -[server.md › Serving the 2026-07-28 draft revision on stdio](../server.md#serving-the-2026-07-28-draft-revision-on-stdio). - -To migrate an existing stdio server, replace -`await server.connect(new StdioServerTransport())` with -`serveStdio(() => buildServer())`. Pass `{ legacy: 'reject' }` to refuse 2025-era -openings. On 2026-pinned connections, `getClientCapabilities()` / `getClientVersion()` -return `undefined` (no `initialize` ever runs there) and handlers read per-request -identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned -revision. - -A client whose connection negotiated a modern era drops inbound server→client JSON-RPC -requests (the 2026 era has no such channel) instead of answering them; legacy-era -connections are unchanged. - -### In-process testing - -There is no in-memory serving entry — `InMemoryTransport.createLinkedPair()` connects -2025-era instances only. To exercise 2026-07-28 behavior in tests without sockets, -drive `createMcpHandler` directly through its fetch function: - -```typescript -const handler = createMcpHandler(buildServer); -const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { - fetch: (url, init) => handler.fetch(new Request(url, init)) -}); -``` - -The URL is never dialed — `handler.fetch` serves the request in-process. For stdio-era -coverage, spawn `serveStdio` as a child process. - -### Client cancellation on Streamable HTTP - -On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request -(`signal` / timeout) closes that request's SSE response stream — the spec cancellation -signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling -code. 2025-era connections and stdio at any era still send `notifications/cancelled`. -Custom `Transport` implementations that open one underlying request per outbound message -and honor `TransportSendOptions.requestSignal` may opt in by declaring -`readonly hasPerRequestStream = true`. - -### `ctx.mcpReq.log()` and the per-request `logLevel` - -On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the -`io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the -`logging/setLevel` RPC). When the key is **absent** the server emits no -`notifications/message` for that request — absence is opt-out, not "no filter". The SDK -`Client` does not auto-attach `logLevel`, so handler logs on a default 2026-era exchange -are silently suppressed until the client opts in. - ---- - -## Replacing per-session state: `requestState` - -The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per -request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the -session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an -opaque string the server returns with `inputRequired(...)` and the client echoes -byte-for-byte on the retry. Read it back with the typed accessor -`ctx.mcpReq.requestState<T>()` — it returns the payload your configured verify hook -decoded (see below), the raw wire string when no hook is configured, or `undefined` -when the round carried no state. - -`requestState` round-trips through the client and is therefore **untrusted input** — -integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating -method/parameters, and an expiry) and reject failed verification on re-entry. Configure -`ServerOptions.requestState.verify` and the seam runs it before the handler whenever -`requestState` is present (a thrown rejection answers `-32602` above the tool funnel). -The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns -`{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` -is exactly the function you assign to the hook. The codec is **signed, not encrypted** -(the client can base64url-decode the payload). `mint<T>` and -`ctx.mcpReq.requestState<T>()` are the typed encode/read pair: the seam captures what -`verify` returns and the accessor hands it to the handler already decoded — no second -`verify` call. See `examples/mrtr/server.ts` and -[Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. - -**Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry -carries only that round's responses, never earlier rounds' (the modern client driver -and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not -accumulate). A flow with more than one input round therefore threads everything it has -learned through `requestState`, as a discriminated union of phases, and switches on the -phase rather than probing which response keys arrived: - -```typescript -type BrainstormState = - | { step: 'awaiting-count' } - | { step: 'awaiting-custom-count'; topic: string } - | { step: 'awaiting-ideas'; topic: string; count: number }; - -const stateCodec = createRequestStateCodec<BrainstormState>({ key: SECRET }); -// ServerOptions: { requestState: { verify: stateCodec.verify } } - -async (args, ctx) => { - const state = ctx.mcpReq.requestState<BrainstormState>(); - switch (state?.step) { - case undefined: // first call — ask for the count - return inputRequired({ - inputRequests: { count: inputRequired.elicit({ … }) }, - requestState: await stateCodec.mint({ step: 'awaiting-count' }) - }); - case 'awaiting-count': { - const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA); - // …decide: follow-up question or the sampling round, carrying - // everything learned so far inside the next minted state… - } - case 'awaiting-ideas': { - const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); - return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic); - } - } -}; -``` - -Each `case` knows exactly which answer to read and which data is in scope — the state -machine is explicit, and the same handler runs unchanged on 2025-era connections -through the legacy shim. - ---- - -## Auth on 2026-07-28 - -The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` validation, -SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are -implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every -era once enabled. The migration steps live in -[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, -enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) -to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement -`discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle -`InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire -layer. - ---- - -## Per-era wire codecs - -The wire layer is split into per-revision codecs inside the (private, bundled) core: one -codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves -2026-07-28. The codec is selected by the negotiated protocol version, which is -**connection state** on the `Client`/`Server` instance (instances with no negotiated -version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) -no longer switches the era per message — it is validated against the instance era, and a -mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` -for requests; drop + `onerror` for notifications). - -Methods deleted by a protocol revision are **physically absent** from that era's -registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a -handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` -toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws -`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. - -If you were on a v2 alpha and consumed wire schemas directly: - -| v2-alpha pattern | Mechanical fix | -| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | -| `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) | -| `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | -| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | - -The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x -impact** — these members did not exist before 2026-07-28. The neutral-model wire -tightening that **does** affect v1 code (`content` required, custom-handler `_meta` -passthrough, `specTypeSchemas` narrowing) is in -[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era). - -> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered: -> `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, -> `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never -> existed in v1); v2-alpha code that hard-coded the old literals must update — prefer -> `ProtocolErrorCode.*` / `HEADER_MISMATCH_ERROR_CODE`. - ---- - -## Wire-only members hidden from public types - -The 2026-07-28 wire-level bookkeeping is handled internally and never reaches -application code: the `resultType` discrimination field, the reserved per-request -`_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`), -and the multi-round-trip retry fields (`inputResponses`, `requestState`). - -- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, - `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer - consumes it before results reach your code. -- **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / - `cacheScope` on `server/discover` are read by the client's response-cache layer and - are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — - but they are not removed at runtime: the returned object still carries both, readable - via a cast. The wire parse defaults absent or malformed hints to `0` / `'private'`, - so only tooling that must distinguish an omitted hint from an advertised default - needs raw frames. -- **High-level methods return the named public types** (`client.callTool()` → - `Promise<CallToolResult>`, etc.). Handler return positions are unaffected. -- **Reserved envelope keys and retry fields appear in no public params/result type.** - The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported. - -The protocol layer enforces the same boundary at runtime: - -- **Envelope lift.** On inbound requests and notifications, the reserved - `io.modelcontextprotocol/*` keys are lifted out of `params._meta` before handlers run. - For requests the envelope is readable at `ctx.mcpReq.envelope` - (typed `Partial<RequestMetaEnvelope>`); for notifications there is no per-message - context, so lifted envelope keys are dropped. On requests only, `inputResponses` / - `requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` / - the `ctx.mcpReq.requestState()` accessor; notification params are never touched. -- **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming - 2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too). - The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names - `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses - them as ordinary top-level params has them lifted out of `request.params` (still - readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`). -- **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed - and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver; - any other kind rejects with `SdkError(UnsupportedResultType)` (kind in - `error.data.resultType`). On a 2025-era connection a foreign `resultType` is stripped - before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is - a spec violation surfaced as a typed error. - -**If you were on a v2 alpha** and read the wire shape directly: - -| Pattern | Mechanical fix | -| -------------------------------------- | --------------------------------------------------------------------------------- | -| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | -| `Result['resultType']` type reference | remove; the member is no longer declared | -| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | - -`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) -for transports that classify inbound messages at the edge; dispatch validates it against -the instance's negotiated era. - ---- - -## Multi-round-trip requests - -The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers -obtain client input (elicitation, sampling, roots) **in-band** by returning -`inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the -client retries the original call with the responses. - -| Handler serving 2026-07-28 requests | Mechanical fix | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | -| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | -| handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests | - -`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from -`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs -(`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, -`ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) -fail with a typed local error before anything reaches the wire; their behavior toward -2025-era requests is unchanged. - -`requestState` round-trips as an opaque, **untrusted** string — see -[Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) -for the sealing helper and verification hook. - -**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers -`input_required`, the client fulfils the embedded requests through the same handlers -registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | -'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact -`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or -opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per -call with `allowInputRequired: true` plus `withInputRequired()`. Expect -`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. - -**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a -structural read with an unvalidated cast), two typed readers ship from -`@modelcontextprotocol/server`: - -- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous - Standard Schema, e.g. a zod object): validates the untrusted accepted content and - returns it typed, or `undefined` on mismatch/decline/missing. -- `inputResponse(responses, key)` — discriminated view - (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) - for decline/cancel detection and the non-elicitation kinds. - -Content conveniences stay in your code — e.g. the text of a sampling response is a -one-liner over the discriminated view: - -```typescript -const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); -const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; -const text = block?.type === 'text' ? block.text : undefined; -``` - ---- - -## Legacy shim for `input_required` - -An `input_required` return on a **2025-era** connection is served by the SDK's legacy -shim, on by default: each embedded request is sent as a real server→client request -(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — -stamped with the originating request's id, so on sessionful Streamable HTTP the -requests ride the originating POST's stream — and the handler is re-entered with the -collected `inputResponses` until it returns a final result. Handlers are **written -once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs -remain available for code that still calls them directly. - -The handler cannot tell which era fulfilled it — the shim mirrors the modern client -driver's semantics exactly: - -- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); - multi-step flows thread earlier answers through `requestState`. -- `requestState` is echoed byte-exact, and the configured - `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would - on a modern wire retry (so TTL expiry behaves identically; a rejection answers the - frozen `-32602`). -- Responses arrive as the bare result objects, era-wire-shape-validated only: - elicitation accepted content is NOT re-checked against `requestedSchema` — - exactly as on the modern era — so the handler validates with the - schema-aware `acceptedContent(responses, key, schema)` overload and can - re-issue the request instead of the call dying on a mistyped form field. -- Rounds with no embedded requests (requestState-only) are paced at 250ms. -- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the - 2025-11-25 wire requires one; the 2026 in-band shape has none). - -Knobs live at `ServerOptions.inputRequired`: - -| Member | Default | Meaning | -| --- | --- | --- | -| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | -| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | -| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | - -Failures surface **per family**: `tools/call` failures (capability refusal, a failed -leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts -already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC -errors. Server bugs (malformed input-required results) fail loudly on both eras. - -The shim emits no progress of its own. The originating request's `progressToken` -identifies a single must-increase stream that belongs to the handler — injecting -synthetic ticks into it cannot compose with handler-emitted progress (one stream, -one author), so the shim never writes to it: a 2025 client watching a multi-round -flow sees exactly what a hand-written 2025 push-style handler would have produced. -A handler that reports progress across rounds should derive its values from its -phase state so they increase across re-entries — the token spans the whole flow. - -**Inherited limits** (the same ones hand-written push-style handlers have today): - -- The shim pre-checks each embedded request kind against the client capabilities - declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration - counts as form support — the pre-mode meaning, same as the modern `-32021` gate). - Capability-less clients get a clean refusal, never a hang. -- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a - fresh instance per request: no initialize handshake, no return path for - server→client requests. The shim degrades to the clean capability refusal there — - full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. -- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client - requests mid-call: the transport drops them, so a shim leg waits out - `roundTimeoutMs` before failing per family — the same undeliverable class as - today's `elicitInput` in that configuration, which waits out its own 60s - default. Interactive tools need a streaming-capable session. -- The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation - is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation - response. - ---- - -## `subscriptions/listen` - -The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and -`resources/updated` only on a `subscriptions/listen` stream the client opened — the -server never sends an un-requested notification type. - -**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` -themselves. `createMcpHandler` returns -`.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed -publish sugar over an in-process bus (supply your own `ServerEventBus` for multi-process -deployments). On stdio, `serveStdio` routes the pinned instance's existing -`send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era -unsolicited delivery model is unchanged on legacy connections. - -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection -the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of -the configured sub-options and the server-advertised `listChanged` capabilities, so the -same handlers fire on every published change. `client.listen(filter)` opens a stream -explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request -`notifications/resources/updated` via the `resourceSubscriptions` field of the listen -filter instead. - -**Graceful close.** When the server closes the listen stream deliberately (entry -`close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before -closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close -without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen -if you still want events. - ---- - -## `Mcp-Param-*` and standard headers (SEP-2243) - -On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool -arguments designated with `x-mcp-header` in the tool's `inputSchema` into -`Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed), and -`createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a -present body value, malformed, or disagree with the body — `400 Bad Request` with -JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the -`Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler` -validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`, -`Mcp-Name`) against the body on the modern path with the same rejection. - -**Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request, -an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the -pending request id is delivered in-band as a `ProtocolError` (so the `-32020` recovery -retry can fire). Legacy-era exchanges and generic HTTP failures still surface as -`SdkHttpError`. - -Additive options: `CallToolRequestOptions.toolDefinition` (pass the tool definition -directly so mirroring and output-schema validation run without a prior `tools/list`), -`TransportSendOptions.headers` (per-request HTTP headers; reserved standard/auth header -names are skipped). Browser clients skip mirroring (dynamically named headers cannot be -statically allow-listed for credentialed CORS). - ---- - -## Cache fields and cache hints - -The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results. -When serving that revision, the SDK always emits both fields, defaulting to `ttlMs: 0` -and `cacheScope: 'private'` (the most conservative policy). To advertise a real cache -policy, set `ServerOptions.cacheHints` (per-operation) or `cacheHint` on a -`registerResource` metadata object; resolution is per field, most-specific author first. -2025-era responses never carry these fields. - ---- - -## Tasks: deprecated wire vocabulary - -The task **wire surface** defined by the 2025-11-25 protocol revision is still exported -for interoperability with peers on that revision: the task Zod schemas and inferred -types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, -`GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, -`TaskAugmentedRequestParams`), the task members of the request/result/notification union -types, the `tasks` capability key, `isTaskAugmentedRequestParams`, and -`RELATED_TASK_META_KEY`. All are now `@deprecated` (importable wire vocabulary only; -removable at the major version that drops 2025-era support). - -Task methods are excluded from the typed method maps: `RequestMethod` / `RequestTypeMap` -/ `ResultTypeMap` / `NotificationTypeMap` have no `tasks/*` or -`notifications/tasks/status` entries, so the method-keyed overloads of `request()`, -`ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task -methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no -`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. Where -task interop is genuinely required, use the explicit-schema custom-method form -(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` -requests → `-32601`. - -The experimental tasks **interception** layer is removed entirely — see -[upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). - ---- - -## Appendix: 2025-era vs 2026-era behavior matrix - -| Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 | -| ------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | -| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | -| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | -| Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | -| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler | -| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | -| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | -| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) | -| `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) | -| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | diff --git a/docs-v2/migration/upgrade-to-v2.md b/docs-v2/migration/upgrade-to-v2.md deleted file mode 100644 index 924d3174cc..0000000000 --- a/docs-v2/migration/upgrade-to-v2.md +++ /dev/null @@ -1,1237 +0,0 @@ ---- -title: Upgrading from v1.x to v2 -name: migrate-v1-to-v2 -description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. ---- - -# Upgrading from v1.x to v2 - -This guide covers upgrading from `@modelcontextprotocol/sdk` (v1.x) to the v2 packages. -It is written for shell-capable agents and humans alike: run the codemod first, then -work through the manual sections for what the codemod can't rewrite. - -If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, see -[support-2026-07-28.md](./support-2026-07-28.md) instead. - -## TL;DR — quick path - -1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM - only; CommonJS callers must use dynamic `import()`. -2. **Run the codemod.** - ```bash - npx @modelcontextprotocol/codemod@alpha v1-to-v2 . - ``` - Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`, - and real projects import the SDK from `test/`, `scripts/`, and fixtures too. -3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is - marked in place: - ```bash - grep -rn '@mcp-codemod-error' . - ``` -4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the - [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. -5. **Format.** The codemod rewrites the AST without reformatting — run your formatter on - the changed files (`prettier --write` / `eslint --fix` / `biome format --write`); the - codemod prints the exact command after it runs. -6. **Run your tests.** - -## Contents - -- [What the codemod handles](#what-the-codemod-handles) -- [What the codemod does NOT handle](#what-the-codemod-does-not-handle) -- [Manual changes](#manual-changes-what-the-codemod-does-not-handle) - - [Packaging & runtime](#packaging--runtime) - - [Imports & transports](#imports--transports) - - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) - - [Server registration API](#server-registration-api) - - [HTTP & headers](#http--headers) - - [Errors](#errors) - - [Auth](#auth) - - [Types & schemas](#types--schemas) - - [Behavioral changes](#behavioral-changes) -- [Enhancements](#enhancements) -- [Unchanged APIs](#unchanged-apis) -- [Need help?](#need-help) - ---- - -## What the codemod handles - -The codemod ([`@modelcontextprotocol/codemod`](../../packages/codemod/README.md)) -mechanically applies every rename whose mapping is fixed. The mappings are the -**source of truth** — they live in the codemod package and are not reproduced here: - -| Mapping | Source file | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | -| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | -| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | -| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | - -In addition the codemod: - -- Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages - your imports actually use). -- Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` - / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / - `uriSchema` raw Zod shapes with `z.object()`. -- Drops the result-schema argument from `client.request()` / `client.callTool()` for - spec methods. -- Routes the spec Zod `*Schema` constants imported from `sdk/types.js` to - `@modelcontextprotocol/core` (mixed imports are split; `.parse()` / `.safeParse()` - calls are left untouched). Task-handler schema constants - (`GetTaskRequestSchema` etc.) used as `setRequestHandler` args are **not** rewritten - — the experimental tasks feature was removed (SEP-2663), so each such registration - is marked with an action-required diagnostic instead (see - [Experimental tasks interception removed](#experimental-tasks-interception-removed)). -- Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members - (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode`. -- Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import - (constructor calls are marked for review — argument shape changed). -- Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the - import (a warning notes `Headers` uses `.get()`/`.set()`, not bracket access). -- Rewrites `SchemaInput<T>` → `StandardSchemaWithJSON.InferInput<T>`. -- Renames `RequestHandlerExtra` → `ServerContext` / `ClientContext` and the `extra` - parameter to `ctx`. -- Rewrites `vi.mock` / `jest.mock` and dynamic `import()` paths. -- Renames the `ResourceTemplate` **type** imported from `@modelcontextprotocol/sdk/types.js` - to `ResourceTemplateType` (the spec wire type). The `ResourceTemplate` URI-template - helper **class** from `server/mcp.js` keeps its name and is not renamed. -- Drops `@modelcontextprotocol/sdk/server/zod-compat.js` imports. - -## What the codemod does NOT handle - -Each of these maps to a manual section below. The codemod marks every site it -recognized but could not safely rewrite with an `@mcp-codemod-error` comment. - -- **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) -- **`new Headers()` / `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` - and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but - converting bracket access to `.get()` and wrapping plain objects with `new Headers()` - is manual. → [HTTP & headers](#http--headers) -- **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from - `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls - alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) -- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + - `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) -- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → - `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is - judgment. → [Errors](#errors) -- **Namespace schema access** — `import * as t from '…/types.js'` + - `t.CallToolResultSchema.parse(…)` can't be split per-symbol; the codemod flags it - action-required — re-import the schema from `@modelcontextprotocol/core` by hand. - → [Types & schemas](#types--schemas) -- **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator - compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) - ---- - -## Manual changes (what the codemod does not handle) - -### Packaging & runtime - -The single `@modelcontextprotocol/sdk` package is split: - -| v1 | v2 | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | -| | `@modelcontextprotocol/server` (server implementation) | -| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | -| | `@modelcontextprotocol/core-internal` (internal — never import directly) | -| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | - -`@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared -types from `@modelcontextprotocol/core-internal`, so import types and error classes from -whichever package you already depend on. `@modelcontextprotocol/core-internal` is -`private: true` and is not published — **do not import from it directly.** -`@modelcontextprotocol/core` is the public Zod-schema package (raw `*Schema` constants -only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. - -After the codemod runs, verify the dependencies in `package.json`: the swap rewrites -the **nearest** manifest found walking up from the target directory — one manifest -total, so workspace-member manifests in a monorepo are not visited (remove the v1 -dependency from those by hand once nothing imports it). On already-migrated sources -the codemod still removes the v1 dependency but may not add the v2 packages you need -— check both directions. - -The framework adapter packages declare their framework as a **peer dependency** -(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the -`@modelcontextprotocol/*` packages your imports use, but does not add the framework -peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but -does **not** require the `hono` framework — your package manager may emit a harmless -unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). - -v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS -(`require()`), either migrate to ESM or use dynamic `import()`. - -### Imports & transports - -The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via -[`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts). -A few transports need a decision the codemod can't make: - -- **`StreamableHTTPServerTransport` → which runtime?** The codemod renames it to - `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. If you deploy - to a web-standard runtime (Cloudflare Workers, Deno, Bun), use - `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` - instead. **Decision rule:** if your handler receives a Node `IncomingMessage` / - `ServerResponse`, use `@modelcontextprotocol/node`; if it receives a web-standard - `Request` and returns a `Response`, use `@modelcontextprotocol/server`. -- **stdio transports moved to a `./stdio` subpath.** Import `StdioClientTransport`, - `getDefaultEnvironment`, `DEFAULT_INHERITED_ENV_VARS`, and `StdioServerParameters` - from `@modelcontextprotocol/client/stdio`; import `StdioServerTransport` from - `@modelcontextprotocol/server/stdio`. The package root barrels do **not** export - these (the root entries are runtime-neutral so browser/Workers bundlers can consume - them). The stdio utilities `ReadBuffer`, `serializeMessage`, `deserializeMessage` - stay in the root barrel. -- **Zod `*Schema` constants → `@modelcontextprotocol/core`.** A mixed - `import { CallToolResult, CallToolResultSchema } from '…/types.js'` is split by the - codemod — see [Types & schemas](#types--schemas). - - ```typescript - // v1 - import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - // v2 - import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; - ``` - -- **`SSEServerTransport`** is removed. Migrate to Streamable HTTP. A frozen v1 copy is - available from `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. -- **`WebSocketClientTransport`** is removed (WebSocket is not a spec transport). Use - `StreamableHTTPClientTransport` for remote servers or `StdioClientTransport` for - local servers; the `Transport` interface is exported if you need a custom - implementation. -- **`InMemoryTransport`** is now exported from `@modelcontextprotocol/client` and - `@modelcontextprotocol/server` (both re-export it): - - ```typescript - // v1 - import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; - // v2 - import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client - ``` - -- **`EventStore`, `StreamId`, `EventId`** are exported from `@modelcontextprotocol/server` - only (v1 re-exported them alongside the transport from `sdk/server/streamableHttp.js`; - `@modelcontextprotocol/node` does not). -- **Server auth split.** Resource Server helpers (`requireBearerAuth`, - `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) - → `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, - `OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`, - `authenticateClient`, `metadataHandler`, `createOAuthMetadata`, - `authorizationHandler` / `tokenHandler` / `revocationHandler` / - `clientRegistrationHandler`) → `@modelcontextprotocol/server-legacy/auth` - (deprecated, frozen v1 copy); migrate AS to a dedicated IdP/OAuth library. `AuthInfo` - is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. - - The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) - routes every `…/server/auth/**` deep path (including - `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, - `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to - `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / - `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The - AS→`server-legacy` routing is conservative — re-point RS-only call sites - (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. - -### Low-level protocol & handler context (`ctx`) - -The second parameter to every request handler — previously the flat `RequestHandlerExtra` -object named `extra` — is now a structured **context** object named `ctx`. This is the -`ctx` that appears throughout the rest of this guide. - -The codemod renames the parameter and remaps property access via -[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts). -A few mappings need optional-chaining adjustment (the `http` group is `undefined` on -stdio): - -| v1 (`extra.*`) | v2 (`ctx.*`) | Note | -| ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ | -| `extra.signal` | `ctx.mcpReq.signal` | | -| `extra.requestId` | `ctx.mcpReq.id` | | -| `extra._meta` | `ctx.mcpReq._meta` | | -| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | -| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | -| `extra.sessionId` | `ctx.sessionId` | | -| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | -| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | -| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | - -`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. -`ServerContext.mcpReq` adds convenience methods that replace calling `server.*` from -inside a handler: - -| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | -| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | -| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | - -#### Deprecated in v2 (SEP-2577) - -The roots, sampling, and logging subsystems are deprecated as of protocol version -2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked -`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the -[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) -instead. - -- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, - `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and - the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. -- **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. -- **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, - `LoggingMessageNotification` and params), the full Sampling stack - (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, - `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), - and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, - `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata - Documents per SEP-991. - -JSDoc/types only — wire behavior is unchanged and remains functional for at least the -twelve-month deprecation window. - -#### `setRequestHandler` / `setNotificationHandler` use method strings - -The low-level handler registration takes a **method string** instead of a Zod schema. -The codemod rewrites every spec-method registration via -[`schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts). - -```typescript -// v1 -server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ... }); -// v2 -server.setRequestHandler('tools/call', async (request, ctx) => { ... }); -``` - -**Custom (non-spec) methods** use the 3-arg form `(method, { params, result? }, handler)` -where `params` and `result` are any [Standard Schema](https://standardschema.dev). The -handler receives the parsed `params` directly (not the full request envelope); `_meta` -is at `ctx.mcpReq._meta`. The 3-arg notification handler is `(params, notification) => void`. - -```typescript -server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { ... }); -``` - -#### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods - -For **spec** methods, drop the result-schema argument; the SDK resolves it from the -method name. The codemod drops it from `client.request()` and `client.callTool()`; drop -it from `ctx.mcpReq.send()` by hand. - -```typescript -// v1 -import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; -server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - const r = await extra.sendRequest({ method: 'sampling/createMessage', params: { ... } }, CreateMessageResultSchema); - return { content: [{ type: 'text', text: 'done' }] }; -}); - -// v2 -server.setRequestHandler('tools/call', async (request, ctx) => { - const r = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: { ... } }); - return { content: [{ type: 'text', text: 'done' }] }; -}); -``` - -For **custom (non-spec)** methods, keep the result-schema argument: -`await client.request({ method: 'acme/search', params }, SearchResult)` — only drop the -schema when calling a spec method. - -**Forwarding arbitrary methods (gateways / proxies).** Dropping the schema changes -semantics, not just the signature: a schema-less spec-method call now **enforces** the -spec result schema (a non-conforming upstream result is rejected locally with -`SdkError(SdkErrorCode.InvalidResult)` and a conforming one is re-serialized in schema -key order), and a schema-less call for a **non-spec** method throws a `TypeError` at -the call site (`'…' is not a spec method; pass a result schema`). -A relay that forwards `{ method, params }` it does not understand must keep passing an -explicit result schema. The v1 idiom survives with an import-path change: - -```typescript -import { ResultSchema } from '@modelcontextprotocol/core'; -const result = await upstream.request({ method, params }, ResultSchema); // v1-identical passthrough -``` - -For byte-exact forwarding (member order preserved), pass your own accept-anything -Standard Schema instead. Check call sites whose `method` is **not a literal** — the -codemod may have dropped the schema argument there; restore it. - -The return type is inferred from the method name via `ResultTypeMap` (e.g. -`client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult>`). - -### Server registration API - -The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use -`registerTool` / `registerPrompt` / `registerResource` with an explicit config object. -The codemod converts the call shape and wraps `inputSchema` / `outputSchema` / -`argsSchema` / `uriSchema` raw shapes. - -```typescript -// v1 — raw shape, variadic -server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// v2 — config object, Standard Schema -server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); -``` - -`registerResource` requires a `metadata` argument — pass `{}` if you have none. - -#### Standard Schema objects (raw shapes deprecated) - -v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) -for `inputSchema`, `outputSchema`, and `argsSchema`. Raw `{ field: z.string() }` shapes -are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPrompt` -(auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; -prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. - -**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared -range** in your `package.json`, not just the installed version: a zod-3 range that -satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at -runtime — and quietly: registration swallows the conversion failure, the server starts -and connects normally, and the first `tools/list` (so `client.listTools()`) answers -with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the -deprecated unwrapped raw-shape form with zod-3 field values throws at registration, -with a message pointing at `zod/v4`.) Zod **≥4.2.0** self-converts via -`~standard.jsonSchema` — the supported path. Zod **4.0–4.1** lacks it, so the SDK falls -back to its bundled Zod's `z.toJSONSchema()` with a one-time `[mcp-sdk]` console -warning; and because `.describe()` field descriptions live in the _authoring_ Zod's -registry, the fallback **drops them** from the generated JSON Schema. Fix ladder: -(1) upgrade to `zod ^4.2.0`; (2) if you must pin an older or separate Zod, attach a -`~standard.jsonSchema` provider backed by _your_ Zod's `toJSONSchema` so conversion -(and descriptions) run through your instance; (3) author the schema as raw JSON Schema -via `fromJsonSchema()`. (Raw shapes are wrapped with the SDK's **bundled** Zod — built -with a foreign Zod they fail at registration or at the first `tools/list`; pass -`z.object()`-wrapped schemas from your own Zod instead.) - -The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. -`RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** -(`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to -`update()` is not auto-wrapped; wrap it with `z.object()` yourself. - -```typescript -import * as z from 'zod/v4'; -server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); - -// ArkType works too -import { type } from 'arktype'; -server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); - -// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) -import { fromJsonSchema } from '@modelcontextprotocol/server'; -server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); - -// No-parameter tools: z.object({}) -``` - -Removed Zod-specific helpers (the codemod marks each call site `@mcp-codemod-error`): -`schemaToJson` — use `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON -Schema, or your schema library's native JSON-Schema conversion; `parseSchemaAsync` — use -your schema library's validation directly (e.g. Zod's `.safeParseAsync()`); -`getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` -have no replacement (internal Zod introspection). `SchemaInput<T>` → -`StandardSchemaWithJSON.InferInput<T>` is rewritten mechanically by the codemod. The -internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part -of the public surface — do not import them. - -v1's second compat module, `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`), is -also removed — and the codemod does **not** rewrite its import (expect `TS2307`). If you -build `Tool` / `Prompt` advertisements yourself, use your schema library's native -conversion: zod 4's `z.toJSONSchema(schema, { io: 'input', target: 'draft-2020-12' })` -produces the dialect v2 advertises. - -### HTTP & headers - -Transport APIs and `ctx.http?.req?.headers` use the Web Standard `Headers` object -(`IsomorphicHeaders` is removed). `ctx.http?.req` is a standard Web `Request`. - -```typescript -// v1 -const transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers: { Authorization: 'Bearer token' } } -}); -const sessionId = extra.requestInfo?.headers['mcp-session-id']; - -// v2 -const transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers: new Headers({ Authorization: 'Bearer token' }) } -}); -const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); -const debug = new URL(ctx.http!.req!.url).searchParams.get('debug'); -``` - -`StreamableHTTPClientTransport` now **appends** any custom `requestInit.headers.Accept` -value to the spec-required `application/json, text/event-stream` (v1 let it replace -them). The required media types are always present; additional types are kept for -proxy/gateway routing. - -`hostHeaderValidation()` and `localhostHostValidation()` moved to -`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the -same as every released v1.x — only the import path changes. Framework-agnostic helpers -(`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are -in `@modelcontextprotocol/server`. - -### Errors - -The SDK now distinguishes three error kinds: - -1. **`ProtocolError`** (renamed from `McpError`) — protocol errors that cross the wire - as JSON-RPC error responses. Uses `ProtocolErrorCode` (renamed from `ErrorCode`). -2. **`SdkError`** — local SDK errors that never cross the wire. Uses `SdkErrorCode`. -3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status` - and `.statusText`. - -The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` -(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and -`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof` -checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / -class to match per scenario: - -| Scenario | v1 | v2 | -| ------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------ | -| Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | -| Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | -| Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | -| Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | -| Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | -| Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | -| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | -| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | -| `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | -| 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | -| Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | -| Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | - -```typescript -// v1 -if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } -if (error instanceof StreamableHTTPError) { console.log('HTTP status:', error.code); } - -// v2 -import { SdkError, SdkHttpError, SdkErrorCode, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } -if (error instanceof SdkHttpError) { - console.log('HTTP status:', error.status, error.statusText); - switch (error.code) { - case SdkErrorCode.ClientHttpAuthentication: - case SdkErrorCode.ClientHttpForbidden: - case SdkErrorCode.ClientHttpFailedToOpenStream: - case SdkErrorCode.ClientHttpNotImplemented: - break; - } -} -``` - -`StreamableHTTPError` is removed. - -**Status read off `.code` by duck-typing.** Code that classified HTTP failures by the -status without an `instanceof` — `if ('code' in e && e.code === 403)` — silently stops -matching: on `SdkHttpError` the HTTP status moved to `.status` (its `.code` is a -`SdkErrorCode` string). The codemod renames `instanceof StreamableHTTPError`, but a -status read that never named the class is invisible to it. Watch the inconsistency: -`SseError` still carries its HTTP status on numeric `.code`, so one duck-typed -`.code === 401` that caught both transports in v1 now catches only SSE. - -```typescript -// v1 — one duck-typed check caught both Streamable HTTP and SSE -if ('code' in e && (e.code === 401 || e.code === 403)) reauth(); -// v2 — match each explicitly -if (e instanceof SdkHttpError && (e.status === 401 || e.status === 403)) reauth(); // Streamable HTTP -if (e instanceof SseError && (e.code === 401 || e.code === 403)) reauth(); // SSE still uses .code -``` - -Silent at runtime (no compile error) — grep for `.code ===` status comparisons. - -**Raw numeric code comparisons.** The codemod rewrites `ErrorCode.X` symbol references, -but a check against the raw JSON-RPC number — `(e as { code?: unknown }).code === -32000` -— is invisible to it and silently never matches in v2, because the two SDK-local codes -it usually targeted are now **string** `SdkErrorCode` values: - -| v1 numeric | v2 | -| --------------------------- | -------------------------------------------- | -| `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | -| `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | - -Replace the literal with the named code. Loud (`TS2367`) when the compared value is -typed `SdkErrorCode`; silent when the left side is `unknown` or a cast — grep for -`=== -32000` / `=== -32001`. - -**Dual-role processes: `instanceof` does not cross the packages.** -`@modelcontextprotocol/client` and `@modelcontextprotocol/server` each bundle their own -copy of these error classes, so in a process that uses both — a gateway, a host, an -in-process test — an error constructed by one package fails `instanceof` against the -class imported from the other, silently. When an error may originate from the other -package, match on stable fields instead of class identity: `error.code` values -(`SdkErrorCode` strings for SDK errors, numeric JSON-RPC codes for protocol errors, -`OAuthErrorCode` strings for OAuth errors) plus presence checks like `'status' in e`, -or reconstruct typed protocol errors with `ProtocolError.fromError(code, message, data)` -— it exists precisely because `instanceof` does not survive bundle boundaries. - -**Constructing the error (test stubs, custom transports).** v1 -`new StreamableHTTPError(code, message)` becomes -`new SdkHttpError(code, message, data)`: the first argument is now a `SdkErrorCode` -string (pick the branch from the scenario table above) and the HTTP status moves into -the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, -'Not Found', { status: 404, statusText: 'Not Found' })`. v1's implicit -`Streamable HTTP error: ` message prefix is gone; pass the full message you want. - -#### `SdkErrorCode` enum (complete) - -| Code | When thrown | -| ------------------------------------- | -------------------------------------------------------------------------- | -| `NotConnected` | Transport is not connected | -| `AlreadyConnected` | Transport is already connected | -| `NotInitialized` | Protocol is not initialized | -| `CapabilityNotSupported` | Required capability is not supported | -| `RequestTimeout` | Request timed out waiting for response | -| `ConnectionClosed` | Connection was closed | -| `SendFailed` | Failed to send message | -| `InvalidResult` | Response result failed local schema validation | -| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | -| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | -| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | -| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | -| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | -| `ClientHttpNotImplemented` | HTTP POST request failed | -| `ClientHttpAuthentication` | Server returned 401 after re-authentication | -| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | -| `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | -| `ClientHttpFailedToOpenStream` | Failed to open SSE stream | -| `ClientHttpFailedToTerminateSession` | Failed to terminate session | - -#### Typed `ProtocolError` subclasses - -`ResourceNotFoundError` (carries `.uri`) and `MissingRequiredClientCapabilityError` -(carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. -`resources/read` for an unknown URI now answers `-32602` on every protocol revision -(v1.x already emitted `-32602`; an interim `-32002` from earlier v2 alphas is mapped at -the encode seam). The encode-seam mapping applies to **your own throws too**: a handler -that deliberately throws `ProtocolError(ProtocolErrorCode.ResourceNotFound, …)` reaches -peers as `-32602` — a server can no longer emit `-32002` on the wire. -`ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as -receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers. -`ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from -code + data alone, so it works across bundle boundaries where `instanceof` doesn't. - -### Auth - -#### OAuth error consolidation - -The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. -The `OAUTH_ERRORS` constant is removed. The codemod does not rewrite `instanceof` checks -on these classes — switch on `error.code` instead. - -| v1 class | v2 equivalent | -| ------------------------------ | ------------------------------------------------------- | -| `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | -| `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | -| `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | -| `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | -| `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | -| `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | -| `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | -| `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | -| `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | -| `UnsupportedResponseTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedResponseType` | -| `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | -| `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | -| `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | -| `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | -| `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | -| `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -¹ Unrelated to the new transport-layer `InsufficientScopeError` (SEP-2350) exported from -`@modelcontextprotocol/client`, which carries an RFC 6750 challenge from the resource -server and extends `OAuthClientFlowError`, **not** `OAuthError`. Do not rewrite that one. - -```typescript -// v1 -if (error instanceof InvalidClientError) { ... } -// v2 -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } -``` - -⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from -`@modelcontextprotocol/express`) classifies the error your -`OAuthTokenVerifier.verifyAccessToken()` throws: a v2 -`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` + -`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from -`server-legacy`) or a generic `Error` falls through as unexpected — **invalid tokens -become HTTP `500`**. When you re-point `requireBearerAuth` at -`@modelcontextprotocol/express`, migrate the error classes your verifier throws in the -same change. - -A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from -`@modelcontextprotocol/server-legacy/auth` during migration. - -#### `AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option - -The transport `authProvider` option is widened to `AuthProvider | OAuthClientProvider`. -**`AuthProvider`** is a new minimal interface — `{ token(): Promise<string | undefined>; -onUnauthorized?(ctx): Promise<void> }` — for static-token / non-OAuth bearer auth. -Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry -once). Existing `OAuthClientProvider` implementations need no changes — transports adapt -them internally via the new `adaptOAuthProvider()` export. Also exported: -`isOAuthClientProvider()` (type guard) and `handleOAuthUnauthorized()` (the standard -OAuth `onUnauthorized` behavior, for composing your own adapter). - -#### OAuth client flow — behavioral changes - -- **Resolved scope passed to DCR (SEP-835).** `auth()` now computes the resolved scope - once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes - it to **both** the DCR POST body and the authorization request. `registerClient()` - gained an optional `scope` parameter that overrides `clientMetadata.scope` in the - registration body. -- **OAuth error on HTTP 200.** `exchangeAuthorization()` / `refreshAuthorization()` now - throw `OAuthError` when the AS returns HTTP 200 with a JSON `{error: ...}` body (e.g. - GitHub). v1 surfaced this as a Zod parse failure on the tokens schema. -- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` - treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of - throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. - -#### OAuth client flow errors (new) - -The OAuth client flow now throws dedicated classes from `@modelcontextprotocol/client` -(all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry -path will not catch them): - -| Throw site | v2 class | -| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | -| Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | -| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | -| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | -| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | - -#### `auth()` options are now `AuthOptions` - -The inline options object on `auth()` is now the named `AuthOptions` type. New fields: -`iss?: string` (the form-urldecoded `iss` from the authorization callback — pass it -alongside `authorizationCode` for RFC 9207 validation), `skipIssuerMetadataValidation?: -boolean` (security-weakening opt-out of the RFC 8414 §3.3 issuer-echo check), and -`forceReauthorization?: boolean` (skip the refresh-token branch — set by the transport's -step-up path; hosts driving step-up themselves set it under the same condition). - -#### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) — action required - -`transport.finishAuth()` and `auth()` now validate `iss` from the authorization callback -against the issuer recorded from validated AS metadata. A mismatched `iss` throws -`IssuerMismatchError` before the code is exchanged regardless of advertised support; a -**missing** `iss` throws only when the AS advertised -`authorization_response_iss_parameter_supported: true`. - -Pass the callback URL's `URLSearchParams` so the SDK can read `iss` alongside `code`. -The SDK does **not** validate `state`; compare it yourself before calling `finishAuth`: - -```typescript -const params = new URL(callbackUrl).searchParams; -if (params.get('state') !== expectedState) throw new Error('state mismatch'); -await transport.finishAuth(params); // SDK reads `code` + `iss` -``` - -`transport.finishAuth(code, iss)` remains supported. Do **not** display `error` / -`error_description` / `error_uri` from a callback that failed `iss` validation — those -values are attacker-controlled in a mix-up attack. - -`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not -exactly match the URL it was fetched for (RFC 8414 §3.3). Set -`skipIssuerMetadataValidation: true` only as a temporary workaround for a known-misconfigured AS. - -(`@modelcontextprotocol/server-legacy` AS implementers: `mcpAuthRouter()` now advertises -`authorization_response_iss_parameter_supported: true` by default and the bundled -authorize handler appends `iss` to every redirect issued via `res.redirect(...)` on the -supplied `res`. If you emit `Location` another way, append `params.issuer` as `iss` -yourself; if your callback is issued by an upstream AS you proxy to, set -`authorizationResponseIssParameterSupported = false` so the metadata does not over-claim.) - -#### Dynamic Client Registration defaults (SEP-837, SEP-2207) - -`auth()` now resolves `provider.clientMetadata` once via `resolveClientMetadata()` and -applies defaults to the DCR body: `grant_types` defaults to -`['authorization_code', 'refresh_token']`; `application_type` is derived from -`redirect_uris` (loopback / custom URI scheme → `'native'`, else `'web'`). A field you -set explicitly is never overwritten. The `grant_types` default applies to the DCR body -only — it does **not** drive the `offline_access` / `prompt=consent` augmentation on the -authorize request; statically-registered and CIMD clients that want that augmentation -must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no -`redirectUrl`) get no `grant_types` default. Direct `registerClient()` callers (⚠ -`@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) wanting the same -defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. DCR -rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, -`submittedMetadata`). - -#### Token endpoint must use TLS (SEP-2207) - -`exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App -Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not -`https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on -every path including refresh — switch any plain-`http:` AS on a non-loopback host to -TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your -`saveTokens()` implementation's responsibility. - -#### Scope step-up on `403 insufficient_scope` (SEP-2350) - -`StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` -(default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the -**union** of the previously-requested and challenged scope (`computeScopeUnion`); when -that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`), -the SDK bypasses the refresh-token branch and forces a fresh authorization request. On -`'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set -this for `client_credentials` / m2m clients where re-authorization can't widen scope, or -to gate the consent prompt behind UX. Step-up retries are hard-capped per send -(`maxStepUpRetries`, default 1). With a non-OAuth [`AuthProvider`](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option), -a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous -`SdkHttpError(ClientHttpNotImplemented)`. The GET listen-stream open path applies the -same handling as the POST send path. - -#### Credentials bound to the issuing authorization server (SEP-2352) - -`auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / -`saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those -methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` -names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. -**Round-trip the stored object verbatim and you're protected** — single-slot storage -works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that -rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — -reads still succeed and refresh keeps working, the per-AS issuer check simply does not -apply to that credential, and every read logs an `[mcp-sdk]` warning (`auth()` -re-stamps on first use where the provider can persist it). If you see that warning -repeating after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage -on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token -set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with -no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` -add an optional `issuer?: string` field on top of the wire types. - -`OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are -`@deprecated` (still written for back-compat, never read by the SDK). The bundled -`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and -`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define -`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the -callback leg can verify it is exchanging the code at the same AS the redirect targeted; -without it the SDK `console.warn`s once per callback (`discoveryState` must persist with -the same durability as `codeVerifier`). - -#### Conformance obligations for `OAuthClientProvider` implementers - -The SDK enforces every authorization MUST that lands in SDK code. The following live in -**your** implementation and the SDK structurally cannot enforce them: - -- **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the - value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. -- **Pass `expectedIssuer`** when constructing static-credential providers (SEP-2352). -- **Keep refresh tokens confidential in storage** (SEP-2207) — OS keychain or - encrypted-at-rest store, never `localStorage` / plain files / logs. -- **Extract `iss` from the callback URL** and pass it to `finishAuth` (SEP-2468); when - `IssuerMismatchError` is thrown, do not render the callback's `error*` values. -- **Set `application_type` correctly** when overriding the heuristic (SEP-837). -- **Track cross-request step-up failures yourself** (SEP-2350) — `maxStepUpRetries` is - per request; per-session backoff is host state. -- **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` - `scope` or PRM `scopes_supported` (SEP-2207). - -### Types & schemas - -#### Zod `*Schema` constants moved to `@modelcontextprotocol/core` - -The Zod schemas (`CallToolResultSchema`, `ListToolsResultSchema`, …) that v1 exported -from `types.js` now live in a separate **`@modelcontextprotocol/core`** package. Neither -`@modelcontextprotocol/client` nor `@modelcontextprotocol/server` re-exports them — both -packages stay Zod-free in their public surface. - -The v1→v2 change is just an import-path swap — `.parse()` / `.safeParse()` keep working -unchanged: - -```typescript -// v1 -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -if (CallToolResultSchema.safeParse(value).success) { ... } - -// v2 — same Zod schema, new package -import { CallToolResultSchema } from '@modelcontextprotocol/core'; -if (CallToolResultSchema.safeParse(value).success) { ... } -``` - -`@modelcontextprotocol/core` is the canonical home for the spec's Zod schema constants -(and the OAuth/OpenID metadata schemas). It is runtime-neutral (its only dependency is -`zod`) and is **not** required by `client` / `server` — install it only if you import the -raw schemas directly. - -If you would rather keep your project Zod-free, the **`isSpecType` / `specTypeSchemas`** -alternatives are exported from `@modelcontextprotocol/client` and `…/server`: - -```typescript -import { isSpecType, specTypeSchemas } from '@modelcontextprotocol/client'; -if (isSpecType.CallToolResult(value)) { ... } -const blocks = mixed.filter(isSpecType.ContentBlock); -const result = specTypeSchemas.CallToolResult['~standard'].validate(value); -``` - -`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of -every named type in the MCP spec — so you get autocomplete and a compile error on typos. -`specTypeSchemas.X` is a `StandardSchemaV1Sync<In, Out>` (`validate()` is synchronous). -`validate()` returns `{ value }` or `{ issues }` and never throws — unlike `.parse()` on -the real schema; code that caught a `ZodError` should inspect `result.issues` (or keep -`.parse()` on the schema imported from `@modelcontextprotocol/core`). -The pre-existing `isCallToolResult(value)` guard still works. - -**`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition -— `.extend()`, `.pick()`, `.omit()`, `.merge()`, `.shape`, `.passthrough()`, -`.parseAsync()` — does **not** compile on a `specTypeSchemas` entry; reach for the real -Zod schema from `@modelcontextprotocol/core` when you need to derive a tolerant variant -of a spec schema (e.g. -`ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array() })`). -The Zod-specific `AnySchema` / `SchemaOutput` types from `…/zod-compat.js` are removed — -replace with `StandardSchemaV1` / `StandardSchemaV1.InferOutput<T>` (the codemod's -removal message says the same). - -The role-aggregate unions (`ClientRequest`, `ServerResult`, `ServerRequest`, -`ClientResult`, `ClientNotification`, `ServerNotification`) and the typed-method maps -(`RequestMethod`, `RequestTypeMap`, `ResultTypeMap`, `NotificationTypeMap`) no longer -include task vocabulary; the deprecated `Task*` types remain importable on their own. - -#### Removed type aliases - -| Removed | Replacement | -| --------------------------------------------------------------- | --------------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | -| `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` ² | -| `JSONRPCResponse` (result-only in v1) | `JSONRPCResultResponse` ² | -| `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | Web Standard `Headers` | -| `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | -| `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | - -² v2 introduces **new** `isJSONRPCResponse` / `JSONRPCResponse` / `JSONRPCResponseSchema` -with corrected semantics — they match **both** result and error responses (the schema is -`z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])`). v1's symbols only -matched results. To preserve v1 behavior, rename to `isJSONRPCResultResponse` / -`JSONRPCResultResponse` / `JSONRPCResultResponseSchema` (the codemod does this). - -³ The `ResourceTemplate` URI-template helper **class** (from `sdk/server/mcp.js`) is -**unchanged** — keep `new ResourceTemplate(...)` as-is. Only the like-named spec wire -type from `types.js` was renamed to `ResourceTemplateType` to resolve the v1 collision; -the codemod scopes the rename to imports from `sdk/types.js` only. - -All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original -names — import the TypeScript types, error classes, enums, and type guards from -`@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the Zod -`*Schema` constants from `@modelcontextprotocol/core`. - -The `Protocol` base class itself is no longer exported (it is internal engine). If you -were reaching into protocol internals — rare, mostly debugging tools — -`client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every -inbound request that no registered handler matches, before capability gating. Delete -the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod -currently rewrites it to a named import from `@modelcontextprotocol/client` that does -not exist (a codemod fix is tracked) — delete that import. - -#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) - -The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` -instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas -declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. - -`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to -`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is -`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External -`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, -surfaced per-tool on `callTool`). - -| v1 pattern | Mechanical fix | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `result.structuredContent.<key>` / `result.structuredContent?.<k>` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '<k>' in sc) { sc.<k> }` | -| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | -| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | -| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | -| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | - -A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, -etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward -every era a non-object `structuredContent` with no `text` block of its own gets a -`JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](./support-2026-07-28.md#per-era-wire-codecs) for how the codec applies these per era. - -**Your advertised tool schemas change shape on the wire.** The same `registerTool` -calls produce `tools/list` entries whose generated `inputSchema` differs from v1: -JSON Schema 2020-12 idioms (zod 4 conversion), different `additionalProperties` -handling (no `additionalProperties: false` by default; passthrough objects emit -`"additionalProperties": {}` instead of `true`), and no `execution.taskSupport` member. -Golden tests, transcript pins, and strict client-side validators of your advertised -tool list need re-baselining — the new shapes are spec-conformant. - -### Behavioral changes - -These are runtime-behavior changes that may affect tests and assertions; no source -rewrite required unless noted. - -#### Error-shape changes (every era) - -- **Unknown / disabled tool calls now reject** with `ProtocolError(-32602 InvalidParams)` - instead of resolving `CallToolResult{isError: true}`. v1 callers that checked - `result.isError` for an unknown tool will get an unhandled rejection — catch the - rejected promise instead. -- **The `MCP error <code>: ` message prefix is gone.** v1 prefixed relayed JSON-RPC - error messages (`MCP error -32602: …`); v2's `ProtocolError.message` carries the - peer's message verbatim. Tests and log scrapers that matched the prefix or the numeric - code in rendered text should match `error.code` instead. -- **In-flight request handlers are aborted on transport close** — `ctx.mcpReq.signal` - fires (v1 let them run to completion). `InMemoryTransport.close()` no longer - double-fires `onclose` on the initiating side. -- **`Protocol.request()` with an already-aborted signal** rejects with - `SdkError(SdkErrorCode.RequestTimeout, reason)` instead of throwing the raw - `signal.reason`, matching the in-flight-abort path. -- **OAuth discovery (`discoverOAuthProtectedResourceMetadata` / `discoverOAuthMetadata`, - transitively `auth()`) throws on fetch `TypeError`** (DNS failure, `ECONNREFUSED`, - invalid URL) in Node and Cloudflare Workers instead of swallowing it as a CORS miss - → `undefined`. The CORS-swallow remains browser-only. - -#### Client connection & dispatch - -- **`connect()` skips the `initialize` handshake when the transport already exposes a - `sessionId`** — it assumes it is reconnecting to an existing session (unchanged from - v1.x, where the same guard has existed since 1.10.0; recorded here because the - far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction - silently skips initialization: `getServerCapabilities()` stays `undefined` and the - list verbs return empty results. Expose `sessionId` only after the first request has - been sent. -- **The typed verbs dispatch after async pre-work.** `Protocol.request()` itself still - hands the frame to the transport before its first `await` (v1-compatible). The typed - verbs on top of it — `callTool()` and the cacheable list verbs — perform async work - first (header-mirroring scan, response-cache freshness, output-validator resolution), - so an abort fired in the same tick can land before the frame is ever sent: the call - rejects with `SdkError(RequestTimeout, reason)` and **no `notifications/cancelled` is - emitted** (nothing was in flight). v1 sent the frame synchronously from these verbs. - Once the frame is on the wire, aborting still sends `notifications/cancelled` before - rejecting. -- **Protocol-version pinning is a first-class option.** - `ProtocolOptions.supportedProtocolVersions` pins the legacy `initialize` handshake: - the **first** pre-2026 entry in the list is offered (list order is preference order), - a counter-offer is accepted only if it is one of the list's pre-2026 entries, and a - list with no pre-2026 entry makes the handshake throw. Under - `versionNegotiation: 'auto'` the modern probe candidates are the list's modern - entries when it has any (otherwise the SDK's default modern set); a `{ pin }` is - honored as given and is not checked against the list (see - [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). - v1 had no public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — - replace any workaround that patched the offered version with this option. - -#### stdio transport - -- A configurable `maxBufferSize` (default **10 MB**) caps the stdio read buffer. A - single message that would push the buffer past the limit emits `onerror` and - **closes the connection** (v1 buffered unbounded). Configure via - `new StdioClientTransport({ ..., maxBufferSize })` / - `new StdioServerTransport(stdin, stdout, { maxBufferSize })`. -- `ReadBuffer.readMessage()` now **silently skips non-JSON stdout lines** instead of - throwing `SyntaxError` → `onerror`. Hot-reload tools (tsx, nodemon) that write debug - output to stdout no longer break the transport. Lines that parse as JSON but fail - JSON-RPC schema validation still throw. -- `StdioClientTransport` always sets `windowsHide: true` when spawning the server - process on Windows (previously Electron-only). Prevents stray console windows in - non-Electron Windows hosts. - -#### Client list methods - -- `listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` return - **empty results** when the server didn't advertise the corresponding capability, - instead of sending the request. Set `enforceStrictCapabilities: true` in `ClientOptions` - to restore the v1 throw. -- Called **without a `cursor`**, the same methods now **auto-aggregate every page** and - return `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual - pagination loops keep working (the first iteration returns everything); replace them - with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default - 64); overrun throws `SdkError(ListPaginationExceeded)`. There is no way to fetch only - the **first** page through the typed verbs — for page-level observation - (pagination tooling, per-page stats) drop to - `client.request({ method: 'tools/list', params })`, which never aggregates. -- Output-schema validator compilation is now **lazy** — validators compile on the first - `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. - In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` - succeeds and the compile failure surfaces when `callTool()` is invoked on the affected - tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, - before the request is sent. Validation is never silently skipped. -- On a 2026-07-28 connection the cacheable verbs honour the server-stamped `ttlMs` / - `cacheScope` (SEP-2549) and may return a still-fresh cached entry without a round - trip. Per-call override: `{ cacheMode: 'refresh' | 'bypass' }`. New `ClientOptions`: - `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; - `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). - -#### Server (Streamable HTTP transport) - -- Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` - callbacks) is only enabled for protocol versions in the transport's supported-versions - list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` - request body no longer enable it. -- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), - unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code - should key off the HTTP `404` status, not `-32001`. - -#### Server (deprecated accessors and app-factory Origin validation) - -- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` - are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. -- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a - localhost-class `host` now also validate the `Origin` header by default. Browser-served - clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default - localhost allowlist; validation cannot be disabled for localhost binds). Requests - without an `Origin` header are unaffected; a present `Origin` that cannot be parsed - — including the opaque **`Origin: null`** sent by sandboxed iframes, `file://` pages, - and cross-origin redirects — is **rejected with 403** and cannot be allowlisted via - `allowedOrigins`. Framework-agnostic helpers - (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) are in - `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships - `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. - -#### Server (McpServer / Streamable HTTP behavior) - -- **Eager capability-handler install.** `McpServer` now installs list/read/call handlers - for every primitive capability declared in `ServerOptions.capabilities`, even with - zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no - registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method -not found`. Low-level `Server` users remain responsible for registering handlers for - declared capabilities. -- **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.** - Request-related events emitted after `closeSSE()` — and the final response when no - per-request stream is connected — are now persisted to the configured `eventStore` for - replay (v1 dropped them / threw `"No connection established"`). Without an - `eventStore`, the same condition surfaces via `onerror` and the request id is retired. -- **`registerResource` reserves the `cacheHint` config key.** It is validated - (`RangeError` on invalid values) and stripped from the resource's list metadata; v1 - passed it through verbatim as ordinary metadata. Untyped callers that previously - smuggled a `cacheHint` key through resource metadata should rename it. - -#### `ctx.mcpReq.log()` is request-related on every era - -`ctx.mcpReq.log()` now emits its `notifications/message` request-related (it rides the -in-flight exchange like progress) on every era. On a 2025-era sessionful Streamable HTTP -transport this moves handler-emitted logs from the standalone GET stream onto the -per-request POST response stream — a spec-conformance correction. The session-scoped -`logging/setLevel` filter applies as before on 2025-era connections. (On 2026-07-28 -requests, the per-request `_meta.logLevel` envelope key is the filter — see -[support-2026-07-28.md](./support-2026-07-28.md#serving-the-2026-07-28-revision).) - -#### Wire tightening (every era) - -- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])` - affordance was removed. Tool handlers MUST include `content` (the TypeScript surface - always required it; `content: []` is fine). A handler result without it is rejected - with `-32602`. -- **`ElicitResult.content` values are typed and validated as - `string | number | boolean | string[]`.** v1's TypeScript surface accepted - `Record<string, unknown>` content values; an elicitation handler returning arbitrary - objects now fails to compile (and fails schema validation) — narrow to the primitives - the elicitation spec allows. -- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` - used to delete `params._meta` before validation; it now passes `_meta` through (minus - the reserved `io.modelcontextprotocol/*` envelope keys). If your params schema is - strict, add an optional `_meta` member. -- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept - `resultType`; the validators for the 2025-only task message types and - `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). -- **Sampling `hasTools` discriminant** now keys on `tools || toolChoice` (previously - `tools` only) when selecting the with-tools `CreateMessageResult` variant, on every - era. - -#### Experimental tasks interception removed - -The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). -No mechanical migration; remove usages. Gone: `ProtocolOptions.tasks`, -`protocol.taskManager`, `RequestOptions.task` / `relatedTask`, `BaseContext.task`, -`assertTaskCapability` / `assertTaskHandlerCapability`, `*.experimental.tasks.*` -accessors and `Experimental{Client,Server,McpServer}Tasks`, `requestStream` / -`callToolStream` / `createMessageStream` / `elicitInputStream` and the `ResponseMessage` -types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, -`CreateTaskRequestHandler`, `TaskMessageQueue`, `InMemoryTaskMessageQueue`, -`BaseQueuedMessage` / `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, -`TaskToolExecution`, `TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`, -and the `new McpServer(info, { taskStore, taskMessageQueue })` constructor option keys -(the codemod emits an action-required diagnostic at each — remove the option). - -The task **wire types** remain importable as `@deprecated` vocabulary for 2025-11-25 -interop — see [support-2026-07-28.md](./support-2026-07-28.md#tasks-deprecated-wire-vocabulary). - -#### Specification clarifications adopted (no SDK behavior change) - -The 2026-07-28 specification revision includes a number of documentation-only -clarifications recorded here so an audit of the revision's changelog against this guide -is complete; nothing in this list requires code changes: per-operation timeout guidance -removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio -shutdown wording; transports-as-bindings reframe; `resources/read` wording (the -`file://` path-sanitization MUST is server-author guidance — your handler must reject -traversal / symlink escapes itself); `PromptMessage` resource links (already in -`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string -cursors (already passed through verbatim); sampling host-requirement docs; elicitation -statefulness wording; cosmetic schema/JSDoc sweeps. - ---- - -## Enhancements - -### Automatic JSON Schema validator selection by runtime - -The SDK auto-selects the validator: Node.js → AJV; Cloudflare Workers (workerd) → -`@cfworker/json-schema`. Cloudflare Workers users can remove explicit -`jsonSchemaValidator` configuration. You don't need to install `ajv`, `ajv-formats`, or -`@cfworker/json-schema` for the default path. To customize the built-in backend, import -the named class from the explicit subpath -(`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing -from a subpath means the corresponding peer dep must be in your `package.json`. - -### `Client.connect(transport, { prior })` — zero-round-trip connect - -Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to -every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported -type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). - -### Serving the 2026-07-28 revision - -`createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests -(`requestState`), client cancellation via stream-close, `subscriptions/listen`, -`Mcp-Param-*` headers, and per-era wire codecs are covered in -**[support-2026-07-28.md](./support-2026-07-28.md)** — they are net-new in v2, not v1→v2 -breaks. - ---- - -## Unchanged APIs - -The following are unchanged between v1 and v2 (only the import path changed): - -- `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, - `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` - signatures changed (schema parameter dropped for spec methods). -- `McpServer` constructor, `server.connect(transport)`, `server.close()`. -- `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options. -- `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the - `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). -- All TypeScript **type** definitions from `types.ts` (except the aliases listed under - [Removed type aliases](#removed-type-aliases)). -- Tool, prompt, and resource callback return types. - -> The `Server` (low-level) constructor and **most** of its methods are unchanged, but -> `setRequestHandler` / `setNotificationHandler` and `request()` signatures changed -> ([Low-level protocol](#low-level-protocol--handler-context-ctx)). The Zod `*Schema` -> constants are **not** part of the unchanged surface — they moved to -> `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). - ---- - -## Need help? - -- The codemod's [`@mcp-codemod-error`](../../packages/codemod/README.md) markers point - at every site it could not safely rewrite. -- The [FAQ](../faq.md) covers common v2 questions. -- Runnable [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) - for every subsystem. -- Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f824e09855..c0efd77ddc 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -24,8 +24,11 @@ export default defineConfig({ title: 'MCP TypeScript SDK', description: 'The TypeScript SDK implementation of the Model Context Protocol specification.', base: '/v2/', - srcExclude: ['v1/**', 'behavior-surface-pins.md'], + srcExclude: ['v1/**', '_meta/**'], sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io/v2/' }, + // Phase-2 preview: most pages are scaffolds with placeholder cross-links; the dead-link gate + // is suspended on this branch only so the structure is browsable. Re-enable before any merge. + ignoreDeadLinks: true, markdown: { config(md) { // Spec-generated JSDoc (packages/core-internal/src/types/spec.types.*.ts) carries @@ -44,26 +47,81 @@ export default defineConfig({ }, themeConfig: { nav: [ - { text: 'Guide', link: '/server-quickstart', activeMatch: '^/(server|client|faq)' }, + { text: 'Get started', link: '/get-started/first-server', activeMatch: '^/get-started/' }, + { text: 'Servers', link: '/servers/tools', activeMatch: '^/(servers|serving)/' }, + { text: 'Clients', link: '/clients/connect', activeMatch: '^/clients/' }, { text: 'Migration', link: '/migration/', activeMatch: '^/migration/' }, { text: 'API Reference', link: '/api/', activeMatch: '^/api/' }, { text: 'V1 Docs', link: 'https://ts.sdk.modelcontextprotocol.io/' } ], sidebar: [ { - text: 'Getting started', + text: 'Get started', items: [ - { text: 'Server Quickstart', link: '/server-quickstart' }, - { text: 'Client Quickstart', link: '/client-quickstart' } + { text: 'Build a server', link: '/get-started/first-server' }, + { text: 'Plug into a real host', link: '/get-started/real-host' }, + { text: 'Build a client', link: '/get-started/first-client' }, + { text: 'Packages', link: '/get-started/packages' } ] }, { - text: 'Guides', + text: 'Servers', items: [ - { text: 'Server', link: '/server' }, - { text: 'Client', link: '/client' } + { text: 'Tools', link: '/servers/tools' }, + { text: 'Resources', link: '/servers/resources' }, + { text: 'Prompts', link: '/servers/prompts' }, + { text: 'Completion', link: '/servers/completion' }, + { text: 'Logging, progress, cancellation', link: '/servers/logging-progress-cancellation' }, + { text: 'Elicitation', link: '/servers/elicitation' }, + { text: 'Sampling (sunset)', link: '/servers/sampling' }, + { text: 'Input required', link: '/servers/input-required' }, + { text: 'Notifications', link: '/servers/notifications' }, + { text: 'Errors', link: '/servers/errors' } ] }, + { + text: 'Serving', + items: [ + { text: 'stdio', link: '/serving/stdio' }, + { text: 'HTTP', link: '/serving/http' }, + { text: 'Express', link: '/serving/express' }, + { text: 'Hono', link: '/serving/hono' }, + { text: 'Fastify', link: '/serving/fastify' }, + { text: 'Web-standard runtimes', link: '/serving/web-standard' }, + { text: 'Sessions, state, scaling', link: '/serving/sessions-state-scaling' }, + { text: 'Authorization', link: '/serving/authorization' }, + { text: 'Legacy clients', link: '/serving/legacy-clients' } + ] + }, + { + text: 'Clients', + items: [ + { text: 'Connect', link: '/clients/connect' }, + { text: 'Calling', link: '/clients/calling' }, + { text: 'Handle server requests', link: '/clients/server-requests' }, + { text: 'Roots (sunset)', link: '/clients/roots' }, + { text: 'Subscriptions', link: '/clients/subscriptions' }, + { text: 'OAuth', link: '/clients/oauth' }, + { text: 'Machine auth', link: '/clients/machine-auth' }, + { text: 'Middleware', link: '/clients/middleware' }, + { text: 'Caching', link: '/clients/caching' } + ] + }, + { text: 'Protocol versions', link: '/protocol-versions' }, + { + text: 'Advanced', + collapsed: true, + items: [ + { text: 'Low-level server', link: '/advanced/low-level-server' }, + { text: 'Custom methods', link: '/advanced/custom-methods' }, + { text: 'Schema libraries', link: '/advanced/schema-libraries' }, + { text: 'Custom transports', link: '/advanced/custom-transports' }, + { text: 'Wire schemas', link: '/advanced/wire-schemas' }, + { text: 'Gateway', link: '/advanced/gateway' } + ] + }, + { text: 'Testing', link: '/testing' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, { text: 'Migration', items: [ @@ -73,8 +131,15 @@ export default defineConfig({ ] }, { - text: 'FAQ', - items: [{ text: 'FAQ', link: '/faq' }] + text: 'Current guides (being replaced)', + collapsed: true, + items: [ + { text: 'Server quickstart', link: '/server-quickstart' }, + { text: 'Client quickstart', link: '/client-quickstart' }, + { text: 'Server guide', link: '/server' }, + { text: 'Client guide', link: '/client' }, + { text: 'FAQ', link: '/faq' } + ] }, { text: 'API Reference', diff --git a/docs-v2/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md similarity index 100% rename from docs-v2/_meta/CONVENTIONS.md rename to docs/_meta/CONVENTIONS.md diff --git a/docs-v2/_TREE.md b/docs/_meta/_TREE.md similarity index 100% rename from docs-v2/_TREE.md rename to docs/_meta/_TREE.md diff --git a/docs-v2/advanced/custom-methods.md b/docs/advanced/custom-methods.md similarity index 100% rename from docs-v2/advanced/custom-methods.md rename to docs/advanced/custom-methods.md diff --git a/docs-v2/advanced/custom-transports.md b/docs/advanced/custom-transports.md similarity index 100% rename from docs-v2/advanced/custom-transports.md rename to docs/advanced/custom-transports.md diff --git a/docs-v2/advanced/gateway.md b/docs/advanced/gateway.md similarity index 100% rename from docs-v2/advanced/gateway.md rename to docs/advanced/gateway.md diff --git a/docs-v2/advanced/low-level-server.md b/docs/advanced/low-level-server.md similarity index 100% rename from docs-v2/advanced/low-level-server.md rename to docs/advanced/low-level-server.md diff --git a/docs-v2/advanced/schema-libraries.md b/docs/advanced/schema-libraries.md similarity index 100% rename from docs-v2/advanced/schema-libraries.md rename to docs/advanced/schema-libraries.md diff --git a/docs-v2/advanced/wire-schemas.md b/docs/advanced/wire-schemas.md similarity index 100% rename from docs-v2/advanced/wire-schemas.md rename to docs/advanced/wire-schemas.md diff --git a/docs-v2/clients/caching.md b/docs/clients/caching.md similarity index 100% rename from docs-v2/clients/caching.md rename to docs/clients/caching.md diff --git a/docs-v2/clients/calling.md b/docs/clients/calling.md similarity index 100% rename from docs-v2/clients/calling.md rename to docs/clients/calling.md diff --git a/docs-v2/clients/connect.md b/docs/clients/connect.md similarity index 100% rename from docs-v2/clients/connect.md rename to docs/clients/connect.md diff --git a/docs-v2/clients/machine-auth.md b/docs/clients/machine-auth.md similarity index 100% rename from docs-v2/clients/machine-auth.md rename to docs/clients/machine-auth.md diff --git a/docs-v2/clients/middleware.md b/docs/clients/middleware.md similarity index 100% rename from docs-v2/clients/middleware.md rename to docs/clients/middleware.md diff --git a/docs-v2/clients/oauth.md b/docs/clients/oauth.md similarity index 100% rename from docs-v2/clients/oauth.md rename to docs/clients/oauth.md diff --git a/docs-v2/clients/roots.md b/docs/clients/roots.md similarity index 100% rename from docs-v2/clients/roots.md rename to docs/clients/roots.md diff --git a/docs-v2/clients/server-requests.md b/docs/clients/server-requests.md similarity index 100% rename from docs-v2/clients/server-requests.md rename to docs/clients/server-requests.md diff --git a/docs-v2/clients/subscriptions.md b/docs/clients/subscriptions.md similarity index 100% rename from docs-v2/clients/subscriptions.md rename to docs/clients/subscriptions.md diff --git a/docs-v2/get-started/first-client.md b/docs/get-started/first-client.md similarity index 100% rename from docs-v2/get-started/first-client.md rename to docs/get-started/first-client.md diff --git a/docs-v2/get-started/first-server.md b/docs/get-started/first-server.md similarity index 100% rename from docs-v2/get-started/first-server.md rename to docs/get-started/first-server.md diff --git a/docs-v2/get-started/packages.md b/docs/get-started/packages.md similarity index 100% rename from docs-v2/get-started/packages.md rename to docs/get-started/packages.md diff --git a/docs-v2/get-started/real-host.md b/docs/get-started/real-host.md similarity index 100% rename from docs-v2/get-started/real-host.md rename to docs/get-started/real-host.md diff --git a/docs-v2/index.md b/docs/index.md similarity index 100% rename from docs-v2/index.md rename to docs/index.md diff --git a/docs-v2/protocol-versions.md b/docs/protocol-versions.md similarity index 100% rename from docs-v2/protocol-versions.md rename to docs/protocol-versions.md diff --git a/docs-v2/servers/completion.md b/docs/servers/completion.md similarity index 100% rename from docs-v2/servers/completion.md rename to docs/servers/completion.md diff --git a/docs-v2/servers/elicitation.md b/docs/servers/elicitation.md similarity index 100% rename from docs-v2/servers/elicitation.md rename to docs/servers/elicitation.md diff --git a/docs-v2/servers/errors.md b/docs/servers/errors.md similarity index 100% rename from docs-v2/servers/errors.md rename to docs/servers/errors.md diff --git a/docs-v2/servers/input-required.md b/docs/servers/input-required.md similarity index 100% rename from docs-v2/servers/input-required.md rename to docs/servers/input-required.md diff --git a/docs-v2/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md similarity index 100% rename from docs-v2/servers/logging-progress-cancellation.md rename to docs/servers/logging-progress-cancellation.md diff --git a/docs-v2/servers/notifications.md b/docs/servers/notifications.md similarity index 100% rename from docs-v2/servers/notifications.md rename to docs/servers/notifications.md diff --git a/docs-v2/servers/prompts.md b/docs/servers/prompts.md similarity index 100% rename from docs-v2/servers/prompts.md rename to docs/servers/prompts.md diff --git a/docs-v2/servers/resources.md b/docs/servers/resources.md similarity index 100% rename from docs-v2/servers/resources.md rename to docs/servers/resources.md diff --git a/docs-v2/servers/sampling.md b/docs/servers/sampling.md similarity index 100% rename from docs-v2/servers/sampling.md rename to docs/servers/sampling.md diff --git a/docs-v2/servers/tools.md b/docs/servers/tools.md similarity index 100% rename from docs-v2/servers/tools.md rename to docs/servers/tools.md diff --git a/docs-v2/serving/authorization.md b/docs/serving/authorization.md similarity index 100% rename from docs-v2/serving/authorization.md rename to docs/serving/authorization.md diff --git a/docs-v2/serving/express.md b/docs/serving/express.md similarity index 100% rename from docs-v2/serving/express.md rename to docs/serving/express.md diff --git a/docs-v2/serving/fastify.md b/docs/serving/fastify.md similarity index 100% rename from docs-v2/serving/fastify.md rename to docs/serving/fastify.md diff --git a/docs-v2/serving/hono.md b/docs/serving/hono.md similarity index 100% rename from docs-v2/serving/hono.md rename to docs/serving/hono.md diff --git a/docs-v2/serving/http.md b/docs/serving/http.md similarity index 100% rename from docs-v2/serving/http.md rename to docs/serving/http.md diff --git a/docs-v2/serving/legacy-clients.md b/docs/serving/legacy-clients.md similarity index 100% rename from docs-v2/serving/legacy-clients.md rename to docs/serving/legacy-clients.md diff --git a/docs-v2/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md similarity index 100% rename from docs-v2/serving/sessions-state-scaling.md rename to docs/serving/sessions-state-scaling.md diff --git a/docs-v2/serving/stdio.md b/docs/serving/stdio.md similarity index 100% rename from docs-v2/serving/stdio.md rename to docs/serving/stdio.md diff --git a/docs-v2/serving/web-standard.md b/docs/serving/web-standard.md similarity index 100% rename from docs-v2/serving/web-standard.md rename to docs/serving/web-standard.md diff --git a/docs-v2/testing.md b/docs/testing.md similarity index 100% rename from docs-v2/testing.md rename to docs/testing.md diff --git a/docs-v2/troubleshooting.md b/docs/troubleshooting.md similarity index 100% rename from docs-v2/troubleshooting.md rename to docs/troubleshooting.md diff --git a/package.json b/package.json index 0fbe0fa6ba..433f8b1132 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "run:examples": "tsx scripts/examples/run-examples.ts", "docs:api": "typedoc", "docs:index": "tsx scripts/build-docs-index.ts", - "docs:dev": "pnpm docs:index && vitepress dev docs", - "docs:build": "pnpm docs:index && pnpm docs:api && vitepress build docs", + "docs:dev": "vitepress dev docs", + "docs:build": "pnpm docs:api && vitepress build docs", "docs:multi": "bash scripts/build-docs-site.sh", "docs:check": "pnpm docs:build", "typecheck:all": "pnpm -r typecheck", From 9bd8b5fb28f0301bfac3699fd4c5df8c7d7971c1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 14:17:59 +0000 Subject: [PATCH 04/27] docs: lead the landing page with what MCP is and link the spec --- docs/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index eb3464d819..2aa7741c4e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,9 @@ shape: landing # MCP TypeScript SDK -This is a complete MCP server: one tool, served over stdio. +The **Model Context Protocol** (MCP) is an open standard that connects AI applications to the systems where your data and tools live. You write a **server** that exposes tools, resources, and prompts; any MCP **host** — Claude Code, VS Code, Cursor, your own application — connects to it and lets a model use them. The protocol is defined by [the MCP specification](https://modelcontextprotocol.io/specification/latest); this SDK is its TypeScript implementation, on Node.js, Bun, and Deno. + +A complete server is one file: ```ts source="../examples/guides/index.examples.ts#serveStdio_minimal" import { McpServer } from '@modelcontextprotocol/server'; @@ -32,8 +34,6 @@ serveStdio(() => { Any MCP host that launches this program lists and calls `get-forecast`; the SDK validates every call against that `z.object(...)` schema before your handler runs. [Build a server](./get-started/first-server.md) installs the packages and runs it end to end. -The **Model Context Protocol** (MCP) is an open standard that connects AI applications to the systems where your data and tools already live. You write a **server** that exposes tools (and resources and prompts — data and templates a host can read); any MCP **host** (Claude Code, VS Code, Cursor, your own application) connects to it and lets a model use them. This SDK is the TypeScript implementation of both sides — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — and runs on Node.js, Bun, and Deno. - ## Pick a path - Expose your API or data to AI applications → **[Build a server](./get-started/first-server.md)** From e9437373b323d7d74a7603b86939fc93ba39032f Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:42:56 +0000 Subject: [PATCH 05/27] docs: write the get-started guide pages --- docs/get-started/first-client.md | 181 ++++++++++++------ docs/get-started/packages.md | 102 +++++----- docs/get-started/real-host.md | 145 +++++++++----- .../get-started/firstClient.examples.ts | 151 +++++++++++++++ .../guides/get-started/packages.examples.ts | 28 +++ .../guides/get-started/realHost.examples.ts | 95 +++++++++ examples/guides/get-started/src/index.ts | 67 +++++++ 7 files changed, 616 insertions(+), 153 deletions(-) create mode 100644 examples/guides/get-started/firstClient.examples.ts create mode 100644 examples/guides/get-started/packages.examples.ts create mode 100644 examples/guides/get-started/realHost.examples.ts create mode 100644 examples/guides/get-started/src/index.ts diff --git a/docs/get-started/first-client.md b/docs/get-started/first-client.md index cf1ed9eef7..7a61c2e0bb 100644 --- a/docs/get-started/first-client.md +++ b/docs/get-started/first-client.md @@ -1,93 +1,162 @@ --- -status: scaffold shape: tutorial --- # Build your first client -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Connect, list, call, read, close — neutral, no vendor SDK -teaches: Client, StdioClientTransport, connect, listTools, callTool, readResource, close -source: mined from docs/client-quickstart.md "Server connection management", "Query processing logic", - "Main entry point"; readResource is net-new on this path (from docs/client.md "Resources"). -vendor-neutral ruling: no Anthropic SDK in the main flow; the tool-use loop is a linked example -(proposal §3 path 1, §7 client-quickstart fate). Joins the e2e runner as a self-verifying story -(proposal §4.2) — every output block on this page is REAL once prose lands. -prereq: the weather server from get-started/first-server.md (or any stdio server script) — ONE -tool, `get-alerts(state)`, no resources, run with `npx tsx src/index.ts` (no build step). -Every command/output on this page must agree with that. ---> +Build an MCP **client** — the program that launches a server, lists its tools, and calls them — against the weather server from [Build your first server](./first-server.md). ## Connect to a server -<!-- teaches: Client, StdioClientTransport, connect | salvage: docs/client-quickstart.md "Server connection management" --> +In the weather project, add the client package — it ships separately from `@modelcontextprotocol/server`. -```ts -// draft - API verified against packages/client/src/client/client.ts and packages/client/src/client/stdio.ts +```sh +npm install @modelcontextprotocol/client +``` + +Create `src/client.ts`. A `Client` plus one transport is a complete MCP client. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_connect" import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; const client = new Client({ name: 'my-first-client', version: '1.0.0' }); + const transport = new StdioClientTransport({ - // the weather server from "Build your first server" — adjust the path to where you put it - command: 'npx', - args: ['tsx', '../weather/src/index.ts'], + command: 'npx', + args: ['tsx', 'src/index.ts'] }); + await client.connect(transport); ``` -<!-- result: the client spawns the server process and completes the initialize handshake; nothing prints yet - (the server's own banner lands on its stderr). --> -<!-- aside (::: info): npm install @modelcontextprotocol/client — project scaffolding is the same - setup-once step as first-server, linked not repeated. - salvage: docs/client-quickstart.md "Set up your environment". --> -<!-- aside (::: tip): the client owns the server's lifetime — never start the script yourself. - salvage: docs/client-quickstart.md "Common Error Messages" (spawn ENOENT). --> +`connect()` spawns `npx tsx src/index.ts` as a child process, speaks JSON-RPC over its stdin and stdout, and completes the **initialize** handshake. The client owns that process from here: it lives exactly as long as the transport. + +::: tip +Never start `src/index.ts` yourself — `connect()` does. A `spawn npx ENOENT` error here means `command` is not an executable on your `PATH`. +::: + +stdio is the transport local hosts use; [Connect to a server](../clients/connect.md) covers HTTP for remote servers. ## List the server's tools -<!-- teaches: listTools | salvage: docs/client-quickstart.md "Server connection management" (listTools call) --> -<!-- code: ts — await client.listTools(); log each tool's name + description --> -<!-- output: REAL — the one weather tool, `get-alerts`, with its description, verbatim from the runner. --> +`listTools` returns every tool the server registered, with the JSON Schema it derived for each one's arguments. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_listTools" +const { tools } = await client.listTools(); +for (const tool of tools) { + console.log(tool.name, '—', tool.description); +} +``` + +Run what you have so far — `npx tsx src/client.ts` from the project root. The first line is the server's banner, forwarded from the child's stderr; the second is your loop. + +```text +weather MCP server running on stdio +get-alerts — Get the active weather alerts for a US state +``` + +The script does not exit on its own — the client still owns a live server process. Stop it with `Ctrl+C` for now; [Close the connection](#close-the-connection) ends it properly. ## Call a tool -<!-- teaches: callTool, CallToolResult.content, isError | salvage: docs/client-quickstart.md "Query processing logic" (callTool + isError) --> -<!-- code: ts — await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } }); print the text content block --> -<!-- output: REAL — the formatted alert text (or "No active alerts for CA."), verbatim. --> -<!-- aside (::: tip): pass arguments that fail the tool's schema and the call returns an error - before the handler runs — the one validation-error output for this page. --> +`callTool` takes the tool's name and an `arguments` object that must satisfy its `inputSchema`. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_callTool" +const result = await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } }); + +for (const block of result.content) { + if (block.type === 'text') console.log(block.text); +} +``` + +A tool result is a list of typed **content** blocks; `get-alerts` returns one `text` block. Its text is the live answer from the National Weather Service — one headline per active California alert, or `No active alerts for CA.` when there are none — so your output differs from anyone else's. + +A handler that throws, and arguments the `inputSchema` rejects, come back in this same shape with `isError: true` set. A tool name the server never registered is a protocol-level failure, and that one does throw out of `await callTool` — [Errors](../servers/errors.md) draws the line. + +::: tip +Change the argument to `{ state: 'California' }` and the SDK rejects it before the handler (and the network request inside it) ever runs: + +```text +Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters +``` + +The rejection is an ordinary `isError: true` result, so a model reads the message and retries with arguments that fit. +::: ## Add a resource and read it -<!-- teaches: listResources, readResource | source: net-new for the tutorial; mined from docs/client.md "Resources" --> -<!-- prereq honesty (MF1): the weather server registers NO resources, so this section first has the - reader add one (a single registerResource line in the weather project's src/index.ts, with a - cross-link to /servers/resources for depth) and then reads it from the client. --> -<!-- code: ts — await client.listResources() then await client.readResource({ uri }) on the first result --> -<!-- output: REAL — the one resource's uri/name from listResources, then its text contents. --> +The weather server registers no **resources** yet — a resource is data a client reads by URI, where a tool is an action it invokes. In `src/index.ts`, register one above the `return server` line. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_registerResource" +server.registerResource( + 'about', + 'weather://about', + { title: 'About this server', mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] + }) +); +``` + +The read handler returns `contents` — a list, because one read can return several text or binary parts. [Resources](../servers/resources.md) covers templates, binary contents, and subscriptions. + +Back in `src/client.ts`, list the resources and read the new one by its `uri`. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_readResource" +const { resources } = await client.listResources(); +console.log(resources); + +const { contents } = await client.readResource({ uri: 'weather://about' }); +console.log(contents); +``` + +Run it again. After the lines you already have, the two new logs are: + +``` +[ + { + name: 'about', + title: 'About this server', + uri: 'weather://about', + mimeType: 'text/plain' + } +] +[ + { + uri: 'weather://about', + text: 'Alert data comes from the US National Weather Service.' + } +] +``` + +`listResources` advertises the metadata you registered; `readResource` returns the handler's `contents` unchanged. ## Close the connection -<!-- teaches: close | salvage: docs/client-quickstart.md "Interactive chat interface" (cleanup) + "Main entry point" (finally) --> -<!-- code: ts — await client.close() in a finally block --> -<!-- result: the spawned server process exits; the script terminates cleanly. --> +End the file with `close`. + +```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_close" +await client.close(); +``` + +`close()` ends the spawned server's stdin and kills the process if it does not exit on its own. Run the finished script once more: it prints everything above and exits without `Ctrl+C`. + +::: tip +In a client that can throw between `connect` and `close`, put `close()` in a `finally` block — otherwise a crash leaves the server process running. +::: ## Hand the tool list to a model -<!-- teaches: where an LLM slots in; this page stays vendor-neutral. - salvage: docs/client-quickstart.md "What's happening under the hood" (the loop, told without vendor code); - the full Anthropic tool-use loop survives as a linked, runner-excluded example (proposal §4.2). --> -<!-- code: none — one short paragraph: listTools() output is exactly what a tool-calling API wants; - link the tool-use-loop example and the host page (real-host.md). --> +Nothing on this page calls a model. The handoff is `listTools()`: each entry's `name`, `description`, and `inputSchema` — plain JSON Schema — map one-to-one onto the tool definition every tool-calling LLM API takes. Send the conversation with that list; when the model returns a tool call, pass its `name` and `arguments` to `callTool` unchanged and append `result.content` as the tool result. + +A host — an application with a model in it — runs that loop for you, through a client of its own. [Plug into a real host](./real-host.md) registers the weather server in VS Code, Claude Code, and Cursor with no client code; `examples/cli-client` in the SDK repository is a complete, provider-neutral host built from the calls on this page. ## Recap -<!-- the 5-6 claims this page will prove: -- A Client plus one transport is a complete MCP client; connect() runs the handshake. -- StdioClientTransport spawns and owns the server process — you never start it by hand. -- listTools, callTool, readResource are the verbs; each returns typed results. -- Tool results arrive as content blocks; isError marks a failed call without throwing. -- close() tears down the transport and the spawned process. -- Nothing here needs a model; an LLM consumes listTools() output unchanged. ---> +- A `Client` plus one transport is a complete MCP client; `connect()` runs the initialize handshake. +- `StdioClientTransport` spawns and owns the server process — never start it yourself. +- `listTools`, `callTool`, `listResources`, and `readResource` are the client verbs; each returns a typed result. +- A failed handler or rejected arguments come back as an ordinary result with `isError: true` set. +- `close()` tears down the transport and the spawned process. +- A model consumes `listTools()` output unchanged: `name`, `description`, `inputSchema`. diff --git a/docs/get-started/packages.md b/docs/get-started/packages.md index b7cc9795be..b4eea130ac 100644 --- a/docs/get-started/packages.md +++ b/docs/get-started/packages.md @@ -1,79 +1,81 @@ --- -status: scaffold shape: explanation --- # Packages and subpath exports -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Which of the 10 packages, why subpaths exist -teaches: @modelcontextprotocol/server, /client, /core, /node, /express, /hono, /fastify, - /server-legacy, /codemod, the ./stdio subpath rule -source: mined from README.md "Packages" + "Installation"; packages/*/README.md; - the runtime-posture invariant (root entry web-standard, node-only code at ./stdio). -table-minimal (proposal §7): one short list, not a feature matrix; the package count (10, -incl. private core-internal) is re-verified at the GA freeze. Sits last in get-started, off -the corridor (crit 92 MF3). ---> +The SDK is published as nine npm packages. Most projects install exactly one of them. ## Start from one package -<!-- teaches: @modelcontextprotocol/server root vs ./stdio subpath | salvage: README.md "Getting Started" imports --> +Everything in [Build your first server](./first-server.md) came from a single install, `@modelcontextprotocol/server` — through two import paths. -```ts -// draft - API verified against packages/server/src/index.ts and packages/server/src/stdio.ts +```ts source="../../examples/guides/get-started/packages.examples.ts#packages_serverEntryPoints" import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; ``` -<!-- result: everything in the server tutorial came from this one package; the second import - line is the only place a subpath appeared, and this page explains why. --> +The first path is the package's root entry. The second is a **subpath export** — a separate entry point inside the same package, declared in its `exports` map. ## Pick the package for your side of the protocol -<!-- teaches: server vs client as the two installable starting points | salvage: README.md "Packages" + "Installation" --> -<!-- code: sh — `npm install @modelcontextprotocol/server` and `npm install @modelcontextprotocol/client`, - the only two install commands most readers ever run --> +Install the package for the side of the protocol you are building. -## Keep node-only code behind the ./stdio subpath +```sh +npm install @modelcontextprotocol/server # expose tools, resources, prompts +npm install @modelcontextprotocol/client # connect to servers and call them +``` + +Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is nine packages: + +- `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — the two starting points, one per side. +- `@modelcontextprotocol/node`, `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify` — optional adapters for serving over HTTP. +- `@modelcontextprotocol/core` — the raw Zod wire schemas. +- `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` — migration surfaces for v1 code. + +A tenth package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. -<!-- teaches: WHY subpath exports exist — the root entry of server and client is runtime-neutral - (browser / Workers safe); anything that spawns processes or imports node: builtins lives at - ./stdio. | salvage: packages/server/src/stdio.ts and packages/client/src/stdio.ts header - comments; report-86 invariant. --> -<!-- code: ts — the failing counter-example as a comment: importing StdioClientTransport from the - root entry is not possible by design; the bundler never sees node:child_process unless you - import the subpath --> +## Keep Node-only code behind the `./stdio` subpath + +`StdioClientTransport` spawns the server as a child process, so it is exported from `./stdio`, never from the root entry. + +```ts source="../../examples/guides/get-started/packages.examples.ts#packages_clientStdioSubpath" +// Runs anywhere: browsers, Workers, Node. +import { Client } from '@modelcontextprotocol/client'; +// Spawns a child process — Node-only, so it lives behind the subpath. +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +``` + +The root entry of every package is **runtime-neutral**: its module graph never reaches `node:child_process` or any other Node builtin a browser or Cloudflare Workers bundler cannot resolve. Importing `./stdio` is the explicit opt-in to a process runtime. `@modelcontextprotocol/server/stdio` is the server-side counterpart and exports `serveStdio` and `StdioServerTransport`. + +::: info Coming from v1? +v1's single `@modelcontextprotocol/sdk` package exposed deep file paths such as `@modelcontextprotocol/sdk/server/mcp.js`. The v2 packages declare an `exports` map, so only the subpaths each package names resolve — the codemod rewrites the imports for you. +::: ## Add a framework adapter when you serve over HTTP -<!-- teaches: @modelcontextprotocol/node, /express, /hono, /fastify are optional thin adapters - around createMcpHandler — install only the one matching your framework. - salvage: README.md "Middleware packages (optional)" + "Optional middleware packages" install block. --> -<!-- code: sh — `npm install @modelcontextprotocol/express express` (one representative line; - the four recipe pages under serving/ carry their own) --> +`createMcpHandler` from `@modelcontextprotocol/server` already serves MCP over web-standard `Request` and `Response` objects. An adapter package wires that handler into a specific runtime or framework; install the adapter next to the framework it adapts. + +```sh +npm install @modelcontextprotocol/express express +``` + +Four adapters exist: `@modelcontextprotocol/node` for Node's built-in `http` server, and one each for Express, Hono, and Fastify. They are thin layers over `createMcpHandler` and add no MCP behavior of their own. + +[Serve over HTTP](../serving/http.md) covers the handler itself; [Express](../serving/express.md), [Hono](../serving/hono.md), and [Fastify](../serving/fastify.md) each have a recipe. -## Reach for core only to validate raw wire JSON +## Reach for `core` only to validate raw wire JSON -<!-- teaches: @modelcontextprotocol/core ships ONLY the Zod schema constants; server and client - stay Zod-free on their public surface. Gateways and proxies are its audience. - salvage: packages/core/README.md opening paragraphs. --> -<!-- code: ts — CallToolResultSchema.safeParse(json) as the one canonical use --> +`@modelcontextprotocol/core` exports the Zod schema constants the SDK validates protocol payloads against, for code that handles raw JSON-RPC payloads itself — gateways, proxies, log pipelines. Neither `server` nor `client` exports a Zod schema, and the matching TypeScript types ship with both, so if you only call `registerTool` and `callTool` you never install it. [Wire schemas](../advanced/wire-schemas.md) is the how-to. -## Leave server-legacy and codemod to the migration guide +## Leave `server-legacy` and `codemod` to the migration guide -<!-- teaches: @modelcontextprotocol/server-legacy (v1-era SSE transport + OAuth Authorization - Server) and @modelcontextprotocol/codemod (the v1 -> v2 CLI) exist to be linked, not taught. - salvage: docs/faq.md "Why did we remove server SSE transport?" + "Where are the server auth - helpers?"; link target is /migration/upgrade-to-v2. --> -<!-- code: none — two sentences and a link. --> +`@modelcontextprotocol/server-legacy` is a frozen copy of v1's server-side SSE transport and OAuth Authorization Server helpers, published so a v1 deployment can move to v2 without replacing everything at once. `@modelcontextprotocol/codemod` is the command-line tool that rewrites v1 imports and call sites to their v2 forms. Neither belongs in a new project — the [upgrade guide](../migration/upgrade-to-v2.md) covers both. ## Recap -<!-- the 5 claims this page will prove: -- Two packages cover almost everyone: server to build servers, client to build clients. -- Package roots are runtime-neutral; node-only code (process spawning, stdio) lives at ./stdio. -- The framework adapters (node, express, hono, fastify) are optional and thin; pick one. -- core exists only for raw Zod schema validation of wire JSON. -- server-legacy and codemod are migration surfaces, reached from the migration guide. ---> +- `@modelcontextprotocol/server` and `@modelcontextprotocol/client` are the two packages you install, one per side of the protocol. +- Package root entries are runtime-neutral; code that spawns processes lives at the `./stdio` subpath and only enters your bundle when you import it. +- The HTTP adapters — `node`, `express`, `hono`, `fastify` — are optional thin layers over `createMcpHandler`; install at most one. +- `@modelcontextprotocol/core` exports only Zod schema constants, for code that validates raw wire JSON itself. +- `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` exist for migration from v1, not for new projects. diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md index bb73c1081f..877a1dc8c8 100644 --- a/docs/get-started/real-host.md +++ b/docs/get-started/real-host.md @@ -1,85 +1,136 @@ --- -status: scaffold shape: tutorial --- # Plug into a real host -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Plug your server into Claude Code / VS Code / Cursor -teaches: the host launch contract (command + args), .vscode/mcp.json, host registration, agent-mode tool call -source: mined from docs/server-quickstart.md "Running the server", "Testing your server in VS Code", "What's happening under the hood", "Troubleshooting" -host order (Felix ruling): VS Code leads the main flow; Claude Code and Cursor are H3 subsections after it. -prereq: the weather server from get-started/first-server.md — one tool (`get-alerts(state)`), -run with `npx tsx src/index.ts` from the project root; there is no build step. -Every host on this page is given that one command. ---> +A **host** is an application with a model in it — VS Code with Copilot, Claude Code, Cursor. Register the weather server from [Build your first server](./first-server.md) in all three and watch the assistant call `get-alerts` on its own. ## Hand the host a launch command -<!-- teaches: the launch contract — a host starts your server as a child process from a command + args; first-server's `src/index.ts` already ends with the serve call, so `npx tsx src/index.ts` IS the entry. No new server code on this page. | salvage: docs/server-quickstart.md "Running the server" --> +To attach your server, a host needs one thing — a command it can launch as a child process and talk to over that process's stdin and stdout — and `src/index.ts` already ends with the entry that speaks to those two pipes. -```ts -// draft - API verified against packages/server/src/server/serveStdio.ts -import { serveStdio } from '@modelcontextprotocol/server/stdio'; - -// the last lines of src/index.ts from "Build your first server" — createServer is the factory you wrote there +```ts source="../../examples/guides/get-started/realHost.examples.ts#realHost_serve" void serveStdio(createServer); console.error('weather MCP server running on stdio'); ``` -<!-- result: `npx tsx src/index.ts` (from the project root) starts it, waits silently on stdin, and logs only to stderr — the command and behavior every host below relies on. --> -<!-- aside (::: warning): console.error, never console.log — stdout is the JSON-RPC channel. - salvage: docs/server-quickstart.md IMPORTANT box (lines 362-363). --> +So the launch command is the one you already use: `npx tsx src/index.ts`, run from the project root. There is no build step, and there is no new server code on this page — every host below gets that one command. + +::: warning +stdout is the protocol channel. One `console.log` and the host drops the connection — log with `console.error`. +::: ## Register the server in VS Code -<!-- teaches: .vscode/mcp.json (type: stdio, command, args) | salvage: docs/server-quickstart.md "Configure the MCP server" --> -<!-- code: json — .vscode/mcp.json with one "weather" stdio entry: command "npx", args ["tsx", "src/index.ts"] (the workspace root is the cwd) --> -<!-- result: VS Code prompts to trust the server; "MCP: List Servers" shows `weather` running. --> -<!-- aside (::: info): VS Code 1.99+, GitHub Copilot extension; Copilot Free is enough. - salvage: docs/server-quickstart.md "Prerequisites" under "Testing your server in VS Code". --> +Create `.vscode/mcp.json` in the `weather` project root, with one stdio entry that holds the launch command. + +```json +{ + "servers": { + "weather": { + "type": "stdio", + "command": "npx", + "args": ["tsx", "src/index.ts"] + } + } +} +``` + +VS Code runs the command from the workspace root — the same directory you run it from by hand — and prompts you to trust the new server. Confirm, then run **MCP: List Servers** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): `weather` shows a running status. + +::: info +You need VS Code 1.99 or later with the **GitHub Copilot** extension installed and signed in. [Copilot Free](https://github.com/features/copilot/plans) is enough. +::: ## Call the tool from Copilot Chat -<!-- teaches: agent mode, tool approval, the conversion moment | salvage: docs/server-quickstart.md "Use the tools" --> -<!-- code: text — the prompt ("Are there any weather alerts in Texas?") and the assistant turn that shows - get-alerts being invoked; REAL transcript captured when prose lands. --> -<!-- result: the assistant calls get-alerts with a two-letter state code and answers from its output. --> +Open **Copilot Chat** (`Ctrl+Alt+I` / `Ctrl+Cmd+I`), switch the mode selector at the top of the panel to **Agent** — the only Copilot mode that calls tools — and ask about the one thing your server knows. + +```text +What are the active weather alerts in Texas? +``` + +Copilot stops to show the call it wants to make — `get-alerts` with a two-letter `state` — and waits for you to approve it. Approve, and the handler you wrote in `src/index.ts` runs; the answer in the chat is written from the text block it returned. + +Nothing in the prompt names the tool. The model picked `get-alerts` from its name, its description, and the JSON Schema the SDK derived from your `inputSchema`. + +::: tip +Click the **Tools** button in the chat panel to see the list the model is choosing from — `get-alerts` is in it, described exactly as you registered it. +::: ## Trace the round trip -<!-- teaches: host -> model -> tools/call -> server -> model loop | salvage: docs/server-quickstart.md "What's happening under the hood" --> -<!-- code: none — six-step numbered sequence (question -> model picks tool -> client sends tools/call -> - server handler runs -> result back to model -> answer). No new API. --> +That one answer is six steps. + +1. VS Code sends your question to the model, along with the name, description, and input schema of every available tool. +2. The model decides `get-alerts` answers the question and emits a call with the arguments it chose. +3. VS Code — the MCP **client** inside the host — sends a `tools/call` request to your server over stdio. +4. The SDK validates the arguments against your `inputSchema` and runs your handler. +5. The handler's `content` goes back over stdout as the `tools/call` result. +6. The model reads the text block and writes the answer you see in the chat. ## Connect other hosts -<!-- teaches: the same stdio command works in any MCP host; only the config file differs. - salvage: net-new (current docs cover VS Code only; modelcontextprotocol.io clients list is the link target). --> +Every MCP host launches a stdio server from the same command and arguments. Only where you put them differs. ### Claude Code -<!-- code: sh — `claude mcp add weather -- npx tsx src/index.ts` (verify exact CLI form in the prose tranche) --> -<!-- result: /mcp lists `weather` as connected; the same prompts work. --> +Register the server from the project root; everything after `--` is the launch command. + +```sh +claude mcp add weather -- npx tsx src/index.ts +``` + +Run `/mcp` inside a Claude Code session in that directory: `weather` is connected, with `get-alerts` listed under it. The same prompt drives the same tool call. ### Cursor -<!-- code: json — .cursor/mcp.json, same { command, args } shape as VS Code --> -<!-- result: the server appears under Cursor's MCP settings with `get-alerts` listed. --> +Create `.cursor/mcp.json` in the project root. + +```json +{ + "mcpServers": { + "weather": { + "command": "npx", + "args": ["tsx", "src/index.ts"] + } + } +} +``` + +The entry is the same `command` plus `args`; only the wrapper key and the file name change. The server appears under Cursor's MCP settings with `get-alerts` listed, and agent chat calls it the same way. ## Fix a host that does not see your tools -<!-- teaches: the three real failure modes | salvage: docs/server-quickstart.md "Troubleshooting" (VS Code <details>) --> -<!-- code: sh — `npx tsx src/index.ts` started by hand: it must sit and wait, not print to stdout and exit. --> -<!-- result: a server that starts, waits, and logs only to stderr is one the host can attach to. --> +Run the launch command by hand, from the project root, before you change any host config. + +```sh +npx tsx src/index.ts +``` + +It prints one line to stderr and then waits. + +```text +weather MCP server running on stdio +``` + +Anything else is the bug, and it has one of three causes. + +- The process exits or crashes: the host has nothing to attach to. Fix the command here, where you can read the error, then re-register it. +- Anything besides JSON-RPC reaches stdout: the host reads it as a corrupt message and drops the connection. Find the `console.log` and make it `console.error`. +- The server runs but Copilot never calls it: confirm the chat is in **Agent** mode, then run **MCP: Reset Cached Tools** from the Command Palette. + +A command that prints that one line to stderr and waits is one every host on this page can attach to. + +## Take the server further + +[Tools](../servers/tools.md) covers structured output, annotations, and everything else a handler can return. [HTTP](../serving/http.md) serves the same `createServer` factory as one endpoint many clients share. [Test a server](../testing.md) drives it from an in-memory client — no host, no approval click. ## Recap -<!-- the 4-5 claims this page will prove: -- A host launches your server from a command + args; `npx tsx src/index.ts` is that command — no build step. -- One .vscode/mcp.json entry registers a stdio server in VS Code. -- In agent mode the model discovers your tools from their schemas and calls them unprompted. -- Claude Code and Cursor take the same command; only where you put it differs. -- stdout belongs to JSON-RPC; log to stderr or the host drops the connection. ---> +- A host launches your server as a child process from a command plus arguments; `npx tsx src/index.ts` is that command, with no build step. +- One `.vscode/mcp.json` entry — `type: stdio`, `command`, `args` — registers the server in VS Code. +- In agent mode the model picks `get-alerts` from its name, description, and input schema; you never name it. +- Claude Code and Cursor take the same launch command; only the config file differs. +- stdout belongs to JSON-RPC — log to stderr or the host drops the connection. diff --git a/examples/guides/get-started/firstClient.examples.ts b/examples/guides/get-started/firstClient.examples.ts new file mode 100644 index 0000000000..801f853113 --- /dev/null +++ b/examples/guides/get-started/firstClient.examples.ts @@ -0,0 +1,151 @@ +/** + * Runnable, type-checked companion for `docs-v2/get-started/first-client.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The + * top-level regions are one linear program — the `src/client.ts` the tutorial + * builds — and the file runs for real: `StdioClientTransport` spawns + * `./src/index.ts` (the tutorial's weather server, checked in next to this + * file) over stdio, prints every output the page quotes verbatim, asserts each + * one in the harness blocks, and exits non-zero on any drift. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx firstClient.examples.ts # from examples/guides/get-started/ + * + * Two regions live in never-invoked wrappers because the program cannot + * execute them hermetically: `firstClient_callTool` reaches the live NWS API + * (the page describes its weather-dependent output in prose instead of + * quoting it), and `firstClient_registerResource` is the server-side line the + * page has the reader add to `src/index.ts` — `./src/index.ts` already carries + * it, and the `listResources` assertion below fails if the two drift apart. + * + * @module + */ +/* eslint-disable no-console */ +import { dirname } from 'node:path'; +import { chdir } from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import type { McpServer } from '@modelcontextprotocol/server'; + +// Harness: anchor the working directory so the transport below resolves +// `src/index.ts` against this directory no matter where the file is launched +// from. The tutorial reader runs `npx tsx src/client.ts` from the project root, +// where the same relative path holds. +chdir(dirname(fileURLToPath(import.meta.url))); + +// --------------------------------------------------------------------------- +// "Connect to a server" +// --------------------------------------------------------------------------- + +//#region firstClient_connect +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const client = new Client({ name: 'my-first-client', version: '1.0.0' }); + +const transport = new StdioClientTransport({ + command: 'npx', + args: ['tsx', 'src/index.ts'] +}); + +await client.connect(transport); +//#endregion firstClient_connect + +// --------------------------------------------------------------------------- +// "List the server's tools" +// --------------------------------------------------------------------------- + +//#region firstClient_listTools +const { tools } = await client.listTools(); +for (const tool of tools) { + console.log(tool.name, '—', tool.description); +} +//#endregion firstClient_listTools + +// Harness: the page quotes the loop's one line verbatim. +if (tools.length !== 1 || tools[0]?.name !== 'get-alerts' || tools[0].description !== 'Get the active weather alerts for a US state') { + throw new Error(`first-client.md listTools output drifted: ${JSON.stringify(tools)}`); +} + +// --------------------------------------------------------------------------- +// "Call a tool" — typecheck-only. The happy path hits the live NWS API, so the +// page describes that output in prose; nothing here is quoted verbatim. +// --------------------------------------------------------------------------- + +async function firstClient_callTool() { + //#region firstClient_callTool + const result = await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } }); + + for (const block of result.content) { + if (block.type === 'text') console.log(block.text); + } + //#endregion firstClient_callTool +} +void firstClient_callTool; + +// Harness: the page's "Call a tool" tip quotes the SDK's rejection of +// `{ state: 'California' }` verbatim. The rejection happens before the handler +// (and therefore before any network call), so it runs here for real. +const rejected = await client.callTool({ name: 'get-alerts', arguments: { state: 'California' } }); +const rejectedBlock = rejected.content[0]; +const quotedRejection = + 'Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters'; +if (rejected.isError !== true || rejectedBlock?.type !== 'text' || rejectedBlock.text !== quotedRejection) { + throw new Error(`first-client.md tip output drifted from the SDK: ${JSON.stringify(rejected)}`); +} +console.log(rejectedBlock.text); + +// --------------------------------------------------------------------------- +// "Add a resource and read it" — the server-side half is the one line the page +// has the reader add to the weather project's `src/index.ts`. It is +// typecheck-only here; `./src/index.ts` (the server this program spawned) +// already registers it, and the assertions below prove the two agree. +// --------------------------------------------------------------------------- + +function firstClient_registerResource(server: McpServer) { + //#region firstClient_registerResource + server.registerResource( + 'about', + 'weather://about', + { title: 'About this server', mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] + }) + ); + //#endregion firstClient_registerResource +} +void firstClient_registerResource; + +//#region firstClient_readResource +const { resources } = await client.listResources(); +console.log(resources); + +const { contents } = await client.readResource({ uri: 'weather://about' }); +console.log(contents); +//#endregion firstClient_readResource + +// Harness: the page quotes both logs above verbatim; pin the values they +// depend on so a drift in `./src/index.ts` fails the run instead of silently +// changing the page. +if (resources.length !== 1 || resources[0]?.uri !== 'weather://about' || resources[0].title !== 'About this server') { + throw new Error(`first-client.md listResources output drifted: ${JSON.stringify(resources)}`); +} +const aboutContents = contents[0]; +if ( + contents.length !== 1 || + aboutContents === undefined || + aboutContents.uri !== 'weather://about' || + !('text' in aboutContents) || + aboutContents.text !== 'Alert data comes from the US National Weather Service.' +) { + throw new Error(`first-client.md readResource output drifted: ${JSON.stringify(contents)}`); +} + +// --------------------------------------------------------------------------- +// "Close the connection" +// --------------------------------------------------------------------------- + +//#region firstClient_close +await client.close(); +//#endregion firstClient_close diff --git a/examples/guides/get-started/packages.examples.ts b/examples/guides/get-started/packages.examples.ts new file mode 100644 index 0000000000..2c4af81c0c --- /dev/null +++ b/examples/guides/get-started/packages.examples.ts @@ -0,0 +1,28 @@ +// docs: typecheck-only +/** + * Type-checked companion for `docs-v2/get-started/packages.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The page is + * an explanation of the published packages and their subpath exports, so the + * regions are import shapes — nothing meaningful to run. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// "Start from one package" — the two import paths the first-server tutorial used. +//#region packages_serverEntryPoints +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +//#endregion packages_serverEntryPoints + +// "Keep Node-only code behind the ./stdio subpath" — the client-side pair. +//#region packages_clientStdioSubpath +// Runs anywhere: browsers, Workers, Node. +import { Client } from '@modelcontextprotocol/client'; +// Spawns a child process — Node-only, so it lives behind the subpath. +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +//#endregion packages_clientStdioSubpath diff --git a/examples/guides/get-started/realHost.examples.ts b/examples/guides/get-started/realHost.examples.ts new file mode 100644 index 0000000000..4e2b220e2d --- /dev/null +++ b/examples/guides/get-started/realHost.examples.ts @@ -0,0 +1,95 @@ +/** + * Runnable, type-checked companion for `docs-v2/get-started/real-host.md`. + * + * The page registers an existing server in MCP hosts; its one `ts` fence is + * the tail of the `src/index.ts` built in `first-server.md` — the entry every + * host on the page launches. The `//#region` block is synced byte-for-byte + * into the page by `pnpm sync:snippets` (`pnpm sync:snippets --check` reports + * drift). Running the file (`npx tsx realHost.examples.ts`) prints the stderr + * banner the page quotes verbatim, proves over an in-memory client that the + * launched server advertises exactly the one `get-alerts` tool the page says a + * host lists, and exits. + * + * @module + */ +/* eslint-disable no-console */ +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// The server from "Build your first server" — one `get-alerts` tool. The page +// adds no server code; this factory is its prerequisite, reproduced here so the +// file is the same `src/index.ts` a host launches with `npx tsx src/index.ts`. +// --------------------------------------------------------------------------- + +const NWS_API = 'https://api.weather.gov'; + +interface AlertsResponse { + features: { properties: { event?: string; headline?: string } }[]; +} + +function createServer(): McpServer { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-alerts', + { + description: 'Get the active weather alerts for a US state', + inputSchema: z.object({ + state: z.string().length(2).describe('Two-letter US state code, e.g. CA') + }) + }, + async ({ state }) => { + const code = state.toUpperCase(); + const url = `${NWS_API}/alerts/active?area=${code}`; + const res = await fetch(url, { headers: { 'User-Agent': 'mcp-weather-tutorial/1.0' } }); + if (!res.ok) { + return { content: [{ type: 'text', text: `NWS API error: HTTP ${res.status}` }], isError: true }; + } + const { features } = (await res.json()) as AlertsResponse; + if (features.length === 0) { + return { content: [{ type: 'text', text: `No active alerts for ${code}.` }] }; + } + const lines = features.map(f => f.properties.headline ?? f.properties.event ?? 'Unnamed alert'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + ); + + return server; +} + +//#region realHost_serve +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); +//#endregion realHost_serve + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A host's first move after launching the +// command above is `tools/list`; the page claims the result is the single +// `get-alerts` tool. An in-memory client connected to the same factory proves +// it — any MCP host sees the same list over stdio. `serveStdio` above is still +// waiting on stdin, so the harness exits explicitly. Imported dynamically so +// the page's region stays exactly the tail of `src/index.ts`. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const harnessServer = createServer(); +const harnessClient = new Client({ name: 'real-host-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await harnessServer.connect(serverTransport); +await harnessClient.connect(clientTransport); + +// The list every host shows after it trusts and starts the server. +const { tools } = await harnessClient.listTools(); +const names = tools.map(tool => tool.name); +if (names.length !== 1 || names[0] !== 'get-alerts') { + throw new Error(`real-host.md expects hosts to list exactly [get-alerts], got: ${JSON.stringify(names)}`); +} + +await harnessClient.close(); +await harnessServer.close(); +// `serveStdio` above is still reading stdin; this file runs as a program, so end it here. +// eslint-disable-next-line unicorn/no-process-exit +process.exit(0); diff --git a/examples/guides/get-started/src/index.ts b/examples/guides/get-started/src/index.ts new file mode 100644 index 0000000000..7fadfacd66 --- /dev/null +++ b/examples/guides/get-started/src/index.ts @@ -0,0 +1,67 @@ +/** + * The `src/index.ts` the get-started tutorials build: the weather server from + * `docs/get-started/first-server.md` plus the `about` resource that + * `docs/get-started/first-client.md` adds. + * + * `firstClient.examples.ts` (one directory up) spawns this file over stdio + * exactly as the tutorial reader's client does — `npx tsx src/index.ts` from + * the project root. Keep `get-alerts` in lockstep with + * `firstServer.examples.ts`, and `registerResource` in lockstep with the + * `firstClient_registerResource` region in `firstClient.examples.ts` (the + * harness there asserts on the values this file advertises). + * + * @module + */ +/* eslint-disable no-console */ +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const NWS_API = 'https://api.weather.gov'; + +interface AlertsResponse { + features: { properties: { event?: string; headline?: string } }[]; +} + +function createServer(): McpServer { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + + server.registerTool( + 'get-alerts', + { + description: 'Get the active weather alerts for a US state', + inputSchema: z.object({ + state: z.string().length(2).describe('Two-letter US state code, e.g. CA') + }) + }, + async ({ state }) => { + const code = state.toUpperCase(); + const url = `${NWS_API}/alerts/active?area=${code}`; + const res = await fetch(url, { headers: { 'User-Agent': 'mcp-weather-tutorial/1.0' } }); + if (!res.ok) { + return { content: [{ type: 'text', text: `NWS API error: HTTP ${res.status}` }], isError: true }; + } + const { features } = (await res.json()) as AlertsResponse; + if (features.length === 0) { + return { content: [{ type: 'text', text: `No active alerts for ${code}.` }] }; + } + const lines = features.map(f => f.properties.headline ?? f.properties.event ?? 'Unnamed alert'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + ); + + // Added by docs/get-started/first-client.md ("Add a resource and read it"). + server.registerResource( + 'about', + 'weather://about', + { title: 'About this server', mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] + }) + ); + + return server; +} + +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); From 788e860c0dc369163843576db07d0b8621bc1a4b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:01 +0000 Subject: [PATCH 06/27] docs: write the servers guide pages --- docs/servers/completion.md | 191 +++++++++--- docs/servers/elicitation.md | 217 ++++++++++---- docs/servers/errors.md | 233 ++++++++++++--- docs/servers/input-required.md | 277 ++++++++++++++---- docs/servers/logging-progress-cancellation.md | 229 ++++++++++++--- docs/servers/notifications.md | 122 ++++++-- docs/servers/prompts.md | 227 +++++++++++--- docs/servers/resources.md | 239 ++++++++++++--- docs/servers/sampling.md | 103 ++++--- .../guides/servers/completion.examples.ts | 183 ++++++++++++ .../guides/servers/elicitation.examples.ts | 176 +++++++++++ examples/guides/servers/errors.examples.ts | 132 +++++++++ .../guides/servers/input-required.examples.ts | 249 ++++++++++++++++ .../logging-progress-cancellation.examples.ts | 168 +++++++++++ .../guides/servers/notifications.examples.ts | 119 ++++++++ examples/guides/servers/prompts.examples.ts | 163 +++++++++++ examples/guides/servers/resources.examples.ts | 157 ++++++++++ examples/guides/servers/sampling.examples.ts | 89 ++++++ 18 files changed, 2875 insertions(+), 399 deletions(-) create mode 100644 examples/guides/servers/completion.examples.ts create mode 100644 examples/guides/servers/elicitation.examples.ts create mode 100644 examples/guides/servers/errors.examples.ts create mode 100644 examples/guides/servers/input-required.examples.ts create mode 100644 examples/guides/servers/logging-progress-cancellation.examples.ts create mode 100644 examples/guides/servers/notifications.examples.ts create mode 100644 examples/guides/servers/prompts.examples.ts create mode 100644 examples/guides/servers/resources.examples.ts create mode 100644 examples/guides/servers/sampling.examples.ts diff --git a/docs/servers/completion.md b/docs/servers/completion.md index c81ce2a88b..b9f0b5222d 100644 --- a/docs/servers/completion.md +++ b/docs/servers/completion.md @@ -1,65 +1,168 @@ --- -status: scaffold shape: how-to --- # Completion -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Autocomplete a schema field. -teaches: completable, CompleteCallback, ResourceTemplate complete callbacks -source: mined from docs/server.md "Completions" ---> +**Completion** is server-side autocomplete for prompt arguments and resource template variables: the client sends the partial value the user has typed so far, your callback returns the matching suggestions. ## Wrap an argument with `completable` -<!-- teaches: completable(schema, complete) on a registerPrompt argsSchema field | salvage: docs/server.md "Completions" (registerPrompt_completion) --> -```ts -// draft - API verified against packages/server/src/server/completable.ts (completable, line 51) +`completable` wraps one field of a [prompt's](./prompts.md) `argsSchema` — the schema validates exactly as before, and the second argument suggests values for the field. + +```ts source="../../examples/guides/servers/completion.examples.ts#completable_language" +import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const languages = ['typescript', 'javascript', 'python', 'rust', 'go']; + +const server = new McpServer({ name: 'review', version: '1.0.0' }); + server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices', - argsSchema: z.object({ - language: completable(z.string().describe('Programming language'), value => - ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) - ), - }), - }, - ({ language }) => ({ - messages: [ - { - role: 'user' as const, - content: { type: 'text' as const, text: `Review this ${language} code for best practices.` }, - }, - ], - }) + 'review-code', + { + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + languages.filter(language => language.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this ${language} code for best practices.` } + } + ] + }) ); ``` -<!-- result: a completion/complete request for `language` with value "ty" returns ["typescript"]. --> + +The first `completable` field also registers the server's `completion/complete` handler and advertises the **completions** capability — nothing to declare. A request for completions of `language` with the partial value `ty` now returns `typescript`. + +Every result quoted on this page comes from `client.complete()` on an in-memory `Client` connected to this server — [Try it from a client](#try-it-from-a-client) shows the call, and [Test a server](../testing.md) shows the wiring. ## Return suggestions from the complete callback -<!-- teaches: CompleteCallback signature - (value, context?) => values[] (sync or async) | source: packages/server/src/server/completable.ts CompleteCallback --> -<!-- code: an async complete callback that queries a list and filters by the typed prefix --> -<!-- result: the completion/complete result the client sees (values array) --> + +The callback receives the value typed so far and returns every match — `string[]`, or a promise of one when the lookup is async. Register a second prompt whose `repo` argument completes from an async list. + +```ts source="../../examples/guides/servers/completion.examples.ts#registerPrompt_async" +const branchesByRepo: Record<string, string[]> = { + 'typescript-sdk': ['main', 'release/1.x', 'release/2.x'], + 'python-sdk': ['main', 'release/1.x'], + inspector: ['main'] +}; + +async function listRepos(): Promise<string[]> { + return Object.keys(branchesByRepo); +} + +server.registerPrompt( + 'review-pr', + { + description: 'Review the open pull requests on one branch', + argsSchema: z.object({ + repo: completable(z.string().describe('Repository name'), async value => { + const repos = await listRepos(); + return repos.filter(repo => repo.startsWith(value)); + }), + branch: completable(z.string().describe('Target branch'), completeBranch) + }) + }, + ({ repo, branch }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review the open pull requests on ${repo}@${branch}.` } + } + ] + }) +); +``` + +Return the full match list; the SDK truncates `values` to 100 entries and fills in `total` and `hasMore`. Completing `repo` with the value `ty` returns: + +``` +{ values: [ 'typescript-sdk' ], total: 1, hasMore: false } +``` + +`branch` points at `completeBranch`, defined in the next section. ## Use the other arguments for context -<!-- teaches: the optional second parameter - context.arguments carries the values already filled in for the other arguments --> -<!-- code: a complete callback that narrows suggestions using context?.arguments?.someOtherField --> + +The callback's optional second parameter carries `arguments`: the values the client has already filled in for the prompt's other arguments. Use it to make one field's suggestions depend on another's. + +```ts source="../../examples/guides/servers/completion.examples.ts#completeCallback_context" +async function completeBranch(value: string, context?: { arguments?: Record<string, string> }): Promise<string[]> { + const repo = context?.arguments?.repo; + if (!repo) return []; + return (branchesByRepo[repo] ?? []).filter(branch => branch.startsWith(value)); +} +``` + +With `repo: 'typescript-sdk'` already filled in, completing `branch` with the value `rel` returns: + +``` +{ values: [ 'release/1.x', 'release/2.x' ], total: 2, hasMore: false } +``` + +Clients are not required to send `context` — return an empty list when it is missing, never throw. ## Complete a resource template variable -<!-- teaches: ResourceTemplate's `complete` callback map keyed by variable name | source: packages/server/src/server/mcp.ts ResourceTemplate constructor --> -<!-- code: new ResourceTemplate('user://{userId}/profile', { list: ..., complete: { userId: async value => [...] } }) --> + +[Resource template](./resources.md) variables complete through the template's `complete` map — one callback per URI variable, with the same `(value, context?)` signature. `completable` is for prompt arguments only. + +```ts source="../../examples/guides/servers/completion.examples.ts#resourceTemplate_complete" +server.registerResource( + 'readme', + new ResourceTemplate('repo://{repo}/readme', { + list: undefined, + complete: { + repo: async value => { + const repos = await listRepos(); + return repos.filter(repo => repo.startsWith(value)); + } + } + }), + { description: 'The README of one repository' }, + async (uri, { repo }) => ({ + contents: [{ uri: uri.href, text: `Repository: ${repo}` }] + }) +); +``` + +The completion request targets the template by its URI pattern: `ref: { type: 'ref/resource', uri: 'repo://{repo}/readme' }` with `argument: { name: 'repo', value: 'py' }` returns `python-sdk`. ## Try it from a client -<!-- teaches: what the host does with completions (Inspector / a client's complete() call); the capability is advertised automatically --> -<!-- code: the completion/complete request and its result, verbatim --> + +`Client.complete()` sends `completion/complete`. Complete `language` on `review-code` with an empty value to get the whole list. + +```ts source="../../examples/guides/servers/completion.examples.ts#complete_client" +const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: '' } +}); +console.log(result.completion); +``` + +The server's callback ran once and produced every value: + +``` +{ + values: [ 'typescript', 'javascript', 'python', 'rust', 'go' ], + total: 5, + hasMore: false +} +``` + +::: tip +A host issues the same request as the end user types: MCP Inspector requests completions while you fill in a prompt's arguments and shows the returned `values` as suggestions. +::: ## Recap -<!-- the claims this page will prove: -- completable(schema, callback) attaches autocompletion to one schema field; the schema still validates as before. -- The callback receives the partial value and returns the suggestion list. -- context.arguments lets one field's suggestions depend on another's value. -- Resource template variables complete through the template's `complete` map, not completable(). -- The server advertises the completions capability for you. ---> + +- `completable(schema, callback)` attaches autocompletion to one prompt argument; the schema validates exactly as before. +- The callback receives the partial value and returns the full match list, synchronously or as a promise; the SDK caps `values` at 100 and sets `total` and `hasMore`. +- `context.arguments` carries the prompt's already-filled arguments, so one field's suggestions can depend on another's. +- Resource template variables complete through the template's `complete` map, not `completable`. +- The first completable registration advertises the `completions` capability; `Client.complete()` sends the request. diff --git a/docs/servers/elicitation.md b/docs/servers/elicitation.md index a117aeef72..cf8fc0694d 100644 --- a/docs/servers/elicitation.md +++ b/docs/servers/elicitation.md @@ -1,73 +1,188 @@ --- -status: scaffold shape: how-to --- # Elicitation -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Ask the user (form mode, URL mode). -teaches: ctx.mcpReq.elicitInput, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult.action -source: mined from docs/server.md "Elicitation" ---> +A tool handler asks the end user a question mid-call with `ctx.mcpReq.elicitInput` — the connected client puts the question in front of them and the promise resolves with their answer. ## Ask for input with a form -<!-- teaches: ctx.mcpReq.elicitInput({ mode: 'form', message, requestedSchema }) | salvage: docs/server.md "Elicitation" (registerTool_elicitation) --> -```ts -// draft - API verified against packages/core-internal/src/shared/protocol.ts (ServerContext.mcpReq.elicitInput, line 470) +**Form mode** carries a `message` and a `requestedSchema`: a flat JSON Schema of primitive fields the client renders as a form. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitForm" +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'feedback', version: '1.0.0' }); + server.registerTool( - 'collect-feedback', - { - description: 'Collect user feedback via a form', - inputSchema: z.object({}), - }, - async (_args, ctx) => { - const result = await ctx.mcpReq.elicitInput({ - mode: 'form', - message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 }, - comment: { type: 'string', title: 'Comment' }, - }, - required: ['rating'], - }, - }); - if (result.action === 'accept') { - return { content: [{ type: 'text', text: `Thanks! ${JSON.stringify(result.content)}` }] }; + 'collect-feedback', + { + description: 'Ask the user how something went', + inputSchema: z.object({ topic: z.string() }) + }, + async ({ topic }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `How was ${topic}?`, + requestedSchema: { + type: 'object', + properties: { + rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 }, + comment: { type: 'string', title: 'Comment' } + }, + required: ['rating'] + } + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] }; } - return { content: [{ type: 'text', text: 'Feedback declined.' }] }; - } ); ``` -<!-- result: the host renders the form; result.action is 'accept' | 'decline' | 'cancel' and result.content holds the fields. --> -<!-- aside (::: info): elicitInput is a push and throws on a 2026-07-28 connection, where a handler - RETURNS the request instead — one line, cross-link servers/input-required.md, which owns that - form. Era detail is one line linking /protocol-versions. --> + +`result.action` records what the end user did — `accept`, `decline`, or `cancel` — and `result.content` carries the submitted fields on accept only. The SDK validates accepted content against `requestedSchema` before `elicitInput` resolves, so the fields you read match the schema you sent. + +::: info +On a 2026-07-28 connection `elicitInput` throws — a handler returns the request instead; see [Input required](./input-required.md) and [Protocol versions](../protocol-versions.md). +::: + +The answer comes from the connected client's `elicitation/create` handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — [Handle requests from the server](../clients/server-requests.md) covers the client side in full. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#Client_elicitationHandler" +const client = new Client( + { name: 'feedback-host', version: '1.0.0' }, + { capabilities: { elicitation: { form: {}, url: {} } } } +); + +client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer when they finish. + return { action: 'accept' }; + } + // Render request.params.requestedSchema as a form; return what the user typed. + return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } }; +}); +``` + +Call `collect-feedback` and the elicitation round-trips through that handler inside the one tool call. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_collectFeedback" +const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } }); +console.log(result.content); +``` + +The handler resumes with the submitted fields and returns: + +``` +[ + { + type: 'text', + text: 'Recorded: {"rating":5,"comment":"Smooth setup"}' + } +] +``` ## Handle every action -<!-- teaches: ElicitResult.action branches (accept / decline / cancel) and treating result.content as untrusted input --> -<!-- code: a switch over result.action returning a distinct CallToolResult per branch --> -<!-- result: the verbatim tool output for a decline --> + +Return a distinct result for each `action` so the model knows whether the end user confirmed, refused, or never answered. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitActions" +server.registerTool( + 'delete-dataset', + { + description: 'Delete a dataset after the user confirms', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete ${name}? This cannot be undone.`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } }, + required: ['confirm'] + } + }); + switch (result.action) { + case 'accept': + if (result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] }; + } + return { content: [{ type: 'text', text: `Deleted ${name}.` }] }; + case 'decline': + return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] }; + case 'cancel': + return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] }; + } + } +); +``` + +`result.content` is end-user input: schema-valid, still untrusted — the `accept` branch checks that the box was actually ticked before acting. Decline the form and the tool answers from the `decline` branch: + +``` +[ { type: 'text', text: 'Declined - nothing deleted.' } ] +``` ## Send the end user to a URL -<!-- teaches: mode: 'url' for secure flows (sign-in, payment, API keys) | salvage: docs/server.md "Elicitation" URL mode --> -<!-- code: ctx.mcpReq.elicitInput({ mode: 'url', message, url, elicitationId }) --> + +**URL mode** replaces the form with a browser flow: pass `url` and a unique `elicitationId` instead of `requestedSchema`. + +```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitUrl" +server.registerTool( + 'link-account', + { + description: 'Link a billing account through a hosted sign-in flow', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'url', + message: `Sign in to ${provider} to link your account`, + url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`, + elicitationId: crypto.randomUUID() + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Linked ${provider}.` }] }; + } +); +``` + +The client opens the URL and answers once the end user finishes there; whatever the page collects — credentials, payment details, API keys — stays in the browser and never crosses the MCP connection. The handler's `url` branch above accepts, so `link-account` returns: + +``` +[ { type: 'text', text: 'Linked github.' } ] +``` ## Keep secrets out of forms -<!-- teaches: the spec rule - never collect sensitive data via form mode; use URL mode or out-of-band | salvage: docs/server.md "Elicitation" IMPORTANT box --> -<!-- code: none --> -<!-- ::: warning placeholder: sensitive information must not be collected via form elicitation --> + +Form answers travel back through the client and land in the model's context like any other tool result. + +::: warning +Never collect sensitive information — passwords, API keys, payment details — through form elicitation. Use URL mode or an out-of-band flow instead. +::: ## Require the elicitation capability -<!-- teaches: the client must declare elicitation; calls against a client without it fail before reaching the wire --> -<!-- code: none; one line on the error the handler observes --> + +Elicitation only works against a client that declared the `elicitation` capability — per mode: `form`, `url` — when it connected. Against a client without it, `elicitInput` throws before anything reaches the wire, and the thrown message comes back as an ordinary `isError` tool result: + +``` +{ + content: [ + { type: 'text', text: 'Client does not support form elicitation.' } + ], + isError: true +} +``` ## Recap -<!-- the claims this page will prove: -- ctx.mcpReq.elicitInput sends an elicitation request mid-handler and resolves with the end user's answer. -- Form mode carries a JSON-Schema requestedSchema; the result's action is accept, decline, or cancel. -- URL mode hands the end user a browser flow; use it for anything sensitive. -- Elicitation only works against clients that declared the capability. ---> + +- `ctx.mcpReq.elicitInput` sends an `elicitation/create` request mid-handler and resolves with the end user's answer. +- Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it. +- `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept. +- URL mode hands the end user a browser flow — use it for anything sensitive. +- Calls against a client that never declared the `elicitation` capability fail before reaching the wire. diff --git a/docs/servers/errors.md b/docs/servers/errors.md index 788a44c5c6..b2201ce05d 100644 --- a/docs/servers/errors.md +++ b/docs/servers/errors.md @@ -1,72 +1,209 @@ --- -status: scaffold shape: how-to --- # Errors -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out). -NOTE for the prose tranche: the v2 export is `ProtocolError` (+ `ProtocolErrorCode`), not v1's `McpError` — teach the real symbol, mention the rename once for v1 readers. -teaches: CallToolResult.isError, ProtocolError, ProtocolErrorCode, ResourceNotFoundError -source: mined from docs/server.md "Error handling" + docs/client.md "Error handling" ---> +A **tool error** is a successful JSON-RPC result with `isError: true` that the model reads and recovers from. A **protocol error** is a JSON-RPC error response the model never sees. ## Return a tool error with `isError` -<!-- teaches: isError: true is a tool-level error the model SEES and can self-correct on | salvage: docs/server.md "Error handling" (registerTool_errorHandling) --> -```ts -// draft - API verified against packages/server/src/server/mcp.ts (registerTool, line 972) and CallToolResult.isError +Return `isError: true` from a tool handler to report a failure the model should see. + +```ts source="../../examples/guides/servers/errors.examples.ts#registerTool_isError" +import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const notes = new Map([['welcome', 'Read tools.md first.']]); + +const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( - 'fetch-data', - { - description: 'Fetch data from a URL', - inputSchema: z.object({ url: z.string() }), - }, - async ({ url }) => { - const res = await fetch(url); - if (!res.ok) { - return { - content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], - isError: true, - }; + 'read-note', + { + description: 'Read a note by its id', + inputSchema: z.object({ id: z.string() }) + }, + async ({ id }) => { + const note = notes.get(id); + if (!note) { + return { + content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }], + isError: true + }; + } + return { content: [{ type: 'text', text: note }] }; } - return { content: [{ type: 'text', text: await res.text() }] }; - } ); ``` -<!-- result: the tools/call response is a normal result with isError: true; the model reads the message and retries. --> + +Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring. Call `read-note` with an id that does not exist. + +```ts source="../../examples/guides/servers/errors.examples.ts#callTool_isError" +const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } }); +console.log(missing); +``` + +The `tools/call` response is an ordinary result: + +``` +{ + content: [ + { + type: 'text', + text: 'No note with id "drafts". Known ids: welcome' + } + ], + isError: true +} +``` + +The model reads the message, sees `welcome` in it, and retries with an id that exists. Put the recovery hint in `text` — it is the only thing the model has to work with. ## Let a thrown exception become a tool error -<!-- teaches: the SDK catches handler throws and converts them to { isError: true }; explicit isError only buys you the message; output-schema validation is skipped on errors | salvage: docs/server.md "Error handling" closing paragraph --> -<!-- code: the same handler throwing; comment shows the converted result --> -<!-- result: the verbatim isError result a throw produces --> + +Throw instead: the SDK catches anything a tool handler throws and converts it to the same `isError: true` shape. + +```ts source="../../examples/guides/servers/errors.examples.ts#registerTool_throw" +server.registerTool( + 'delete-note', + { + description: 'Delete a note by its id', + inputSchema: z.object({ id: z.string() }) + }, + async ({ id }) => { + if (!notes.delete(id)) { + throw new Error(`Cannot delete "${id}": no such note`); + } + return { content: [{ type: 'text', text: `Deleted "${id}"` }] }; + } +); +``` + +Call `delete-note` with the same missing id. + +```ts source="../../examples/guides/servers/errors.examples.ts#callTool_throw" +const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } }); +console.log(thrown); +``` + +The exception's `message` becomes the result's `content` text: + +``` +{ + content: [ { type: 'text', text: 'Cannot delete "drafts": no such note' } ], + isError: true +} +``` + +A throw and an explicit `isError: true` produce the same shape; returning explicitly gives you control over `content`. The SDK skips `outputSchema` validation on any `isError` result. ## Throw a protocol error -<!-- teaches: ProtocolError(code, message, data?) for failures the MODEL must not see (bad params, unknown resource); JSON-RPC error response, not a result | source: packages/core-internal/src/types/errors.ts ProtocolError --> -<!-- code: throw new ProtocolError(ProtocolErrorCode.InvalidParams, '...') from a resource read callback --> -<!-- result: the verbatim JSON-RPC error object on the wire --> + +Resource, prompt, and completion callbacks have no `isError` channel. Throw `ProtocolError(code, message, data?)` when the request itself is wrong. + +```ts source="../../examples/guides/servers/errors.examples.ts#registerResource_protocolError" +server.registerResource( + 'note', + new ResourceTemplate('note://{id}', { list: undefined }), + { description: 'A note by its id' }, + async (uri, { id }) => { + const noteId = String(id); + if (!/^[a-z]+$/.test(noteId)) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`); + } + const note = notes.get(noteId); + if (!note) throw new ResourceNotFoundError(uri.href); + return { contents: [{ uri: uri.href, text: note }] }; + } +); +``` + +::: info Coming from v1? +`ProtocolError` and `ProtocolErrorCode` replace v1's `McpError` and `ErrorCode` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: + +Read the resource with an id the callback rejects. + +```ts source="../../examples/guides/servers/errors.examples.ts#readResource_protocolError" +try { + await client.readResource({ uri: 'note://42' }); +} catch (error) { + const { code, message } = error as ProtocolError; + console.log({ code, message }); +} +``` + +`readResource` rejects with a `ProtocolError` carrying the wire fields: + +``` +{ code: -32602, message: 'Note ids are lowercase letters, got "42"' } +``` + +On the wire this is a JSON-RPC error response — `{ code, message, data? }` instead of a `result` — and the host's MCP client handles it; the model never sees it. A non-`ProtocolError` exception thrown from one of these callbacks surfaces as `-32603` Internal Error with the exception's message. ## Choose between tool error and protocol error -<!-- teaches: the rule - recoverable, model-visible failures -> isError; malformed requests / missing things / infrastructure -> protocol error (hidden from the model) | salvage: docs/server.md "Error handling" + docs/client.md "Error handling" framing --> -<!-- code: none --> + +Pick by audience. The model drives `tools/call`, so a failure it can recover from — a missing record, a bad argument, a transient upstream fault — belongs in `isError: true` with a message that names the fix. The host application drives `resources/read`, `prompts/get`, and `completion/complete`, so failures there are protocol errors addressed to the caller's code. + +The handler decides which channel exists: + +- A tool handler produces only tool errors. The SDK converts every exception it throws — including a thrown `ProtocolError` — into an `isError: true` result. `UrlElicitationRequiredError` is the one exception; it propagates as a JSON-RPC error so the host can open the URL — see [Elicitation](./elicitation.md). +- A resource, prompt, or completion callback produces only protocol errors. Throw a `ProtocolError`. ## Use the typed error subclasses -<!-- teaches: ResourceNotFoundError, UrlElicitationRequiredError, UnsupportedProtocolVersionError carry structured data and the right code | source: packages/core-internal/src/types/errors.ts --> -<!-- code: throw new ResourceNotFoundError(uri) from a read callback --> + +Each subclass picks the right `ProtocolErrorCode` and packs structured `data` for you. `ResourceNotFoundError` takes the missing URI — the read callback above already throws it for a well-formed id with no note. + +```ts source="../../examples/guides/servers/errors.examples.ts#readResource_notFound" +try { + await client.readResource({ uri: 'note://archived' }); +} catch (error) { + const { code, message, data } = error as ResourceNotFoundError; + console.log({ code, message, data }); +} +``` + +The error carries the requested URI in `data` and the code the spec mandates for a `resources/read` miss: + +``` +{ + code: -32602, + message: 'Resource not found: note://archived', + data: { uri: 'note://archived' } +} +``` + +Three more subclasses cover the other structured protocol errors: + +- `UrlElicitationRequiredError(elicitations)` — `-32042`; the only error a tool handler can propagate. See [Elicitation](./elicitation.md). +- `UnsupportedProtocolVersionError({ supported, requested })` — `-32022`; `data.supported` lets the peer pick a version and retry. +- `MissingRequiredClientCapabilityError({ requiredCapabilities })` — `-32021`; `data.requiredCapabilities` names exactly what the client must declare. + +Match these by `code` and `data` shape, not by `instanceof` — `instanceof` fails across separately bundled copies of the SDK. ## Look up a protocol error code -<!-- teaches: ProtocolErrorCode enum; the table carve-out (the ONE table allowed on a narrative page) | source: packages/core-internal/src/types/enums.ts ProtocolErrorCode --> -<!-- table placeholder (bottom of page), values verified against ProtocolErrorCode: -ParseError -32700 · InvalidRequest -32600 · MethodNotFound -32601 · InvalidParams -32602 · InternalError -32603 · -ResourceNotFound -32002 (receive-tolerated only; the SDK answers -32602 and never emits -32002) · -MissingRequiredClientCapability -32021 · UnsupportedProtocolVersion -32022 · UrlElicitationRequired -32042 ---> + +`ProtocolErrorCode` is the complete vocabulary of wire codes the SDK sends and recognizes. + +| Member | Code | Meaning | +| --- | --- | --- | +| `ParseError` | `-32700` | The message was not valid JSON. | +| `InvalidRequest` | `-32600` | The message was not a valid JSON-RPC request. | +| `MethodNotFound` | `-32601` | No handler is registered for the method. | +| `InvalidParams` | `-32602` | The params are wrong — also the code for a `resources/read` miss. | +| `InternalError` | `-32603` | The handler threw something other than a `ProtocolError`. | +| `ResourceNotFound` | `-32002` | Receive-tolerated only: the SDK answers a `resources/read` miss with `-32602` and never emits `-32002`. Throw `ResourceNotFoundError` instead. | +| `MissingRequiredClientCapability` | `-32021` | The request needs a capability the client did not declare. | +| `UnsupportedProtocolVersion` | `-32022` | The requested protocol version is unknown to the receiver or unsupported by it. | +| `UrlElicitationRequired` | `-32042` | The tool needs the user to visit a URL before it can complete. | + +`-32021` and `-32022` are new in protocol revision 2026-07-28 — see [Protocol versions](../protocol-versions.md). ## Recap -<!-- the claims this page will prove: -- isError: true is a successful JSON-RPC response carrying a tool failure the model can act on. -- A thrown exception in a tool handler becomes isError: true automatically. -- ProtocolError / its subclasses produce JSON-RPC error responses the model never sees. -- Pick by audience: model-recoverable -> isError; caller/infrastructure -> protocol error. -- The full code list lives in the table at the bottom of this page. ---> + +- `isError: true` is a successful JSON-RPC result carrying a tool failure the model reads and acts on. +- A tool handler that throws produces the same `isError: true` result; the exception's `message` becomes the `content` text. +- A tool handler cannot produce a protocol error — only `UrlElicitationRequiredError` escapes. +- `ProtocolError` and its subclasses, thrown from resource, prompt, and completion callbacks, become JSON-RPC error responses the model never sees. +- `ResourceNotFoundError` and the other subclasses pick the code and pack structured `data`; match them by `code` and `data`, not `instanceof`. +- The table above lists every `ProtocolErrorCode` member. diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index afcc085d90..700acfecbf 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -1,75 +1,254 @@ --- -status: scaffold shape: how-to --- # input_required -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Handle input_required (multi-round-trip requests). -teaches: inputRequired, inputRequired.elicit/elicitUrl/createMessage/listRoots, acceptedContent, ctx.mcpReq.inputResponses, ctx.mcpReq.requestState, createRequestStateCodec -source: mined from docs/server.md "Requesting input on 2026-07-28: input_required" + "Carrying state across rounds: requestState" ---> +An **`input_required`** result is how a `tools/call`, `prompts/get`, or `resources/read` handler asks the connected client for input mid-call: the handler returns the embedded requests, the client answers them and retries the call, and the handler runs again with the responses. ## Return `input_required` instead of pushing a request -<!-- teaches: the inversion - the handler RETURNS the embedded request and the client retries the call with the responses | salvage: docs/server.md "Server-initiated requests" intro + "Requesting input on 2026-07-28" (registerTool_inputRequired) --> -```ts -// draft - API verified against packages/core-internal/src/shared/inputRequired.ts (inputRequired/acceptedContent, lines 120/147) +The handler reads what already arrived with `acceptedContent`; while the answer is missing it returns `inputRequired(...)` instead of a tool result. + +```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_inputRequired" server.registerTool( - 'deploy', - { - description: 'Deploy after user confirmation', - inputSchema: z.object({ env: z.string() }), - }, - async ({ env }, ctx) => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (confirmed?.confirm !== true) { - return inputRequired({ - inputRequests: { - confirm: inputRequired.elicit({ - message: `Deploy to ${env}?`, - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }, - }), - }, - }); + 'deploy', + { + description: 'Deploy after the operator confirms', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; } - return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; - } ); ``` -<!-- result: round 1 returns resultType 'input_required'; the client answers and retries; round 2 returns the tool result. --> + +The first round returns `resultType: 'input_required'` carrying the `confirm` request. The client fulfils it and retries `deploy` with the answer in `inputResponses`; on re-entry `acceptedContent` finds it and the handler finishes. + +Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](../testing.md) shows that wiring. Calling `deploy` once produces both rounds: + +``` +[client] elicitation/create → Deploy to prod? +{ content: [ { type: 'text', text: 'Deployed to prod' } ] } +``` + +`inputRequired(spec)` throws a `TypeError` unless `spec` carries at least one of `inputRequests` or `requestState`. Each embedded request is checked against the capabilities the client declared; a missing capability rejects the call with `-32021` before anything reaches the wire. + +::: info Coming from v1? +`ctx.mcpReq.elicitInput` and `ctx.mcpReq.requestSampling` are the 2025-era push channels — they throw on a 2026-07-28 request. See [Elicitation](./elicitation.md) and the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Read the responses on re-entry -<!-- teaches: ctx.mcpReq.inputResponses + acceptedContent(key) (typed, schema-or-cast); responses are untrusted input | source: packages/core-internal/src/shared/inputRequired.ts acceptedContent --> -<!-- code: acceptedContent with a Zod schema overload, plus the rejected/declined branch --> + +`ctx.mcpReq.inputResponses` comes from the client — treat it as untrusted. Pass a Zod schema as `acceptedContent`'s third argument and the value reaches your handler already validated and typed. + +```ts source="../../examples/guides/servers/input-required.examples.ts#acceptedContent_schema" +server.registerTool( + 'tag-release', + { + description: 'Tag a release after the operator confirms', + inputSchema: z.object({ tag: z.string() }) + }, + async ({ tag }, ctx): Promise<CallToolResult | InputRequiredResult> => { + const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm'); + if (view.kind === 'elicit' && view.action !== 'accept') { + return { content: [{ type: 'text', text: 'Tagging cancelled by the operator' }], isError: true }; + } + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() })); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Tag ${tag}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: `Tagged ${tag}` }] }; + } +); +``` + +`acceptedContent` returns `undefined` for a missing, declined, or cancelled answer alike — re-issuing the request is the right move for all three only when the request is idempotent. `inputResponse` returns a discriminated view (`missing` / `elicit` / `sampling` / `roots`) when you need to tell a refusal from a first entry. A client that declines: + +``` +[client] elicitation/create → Tag v2.1.0? +{ + content: [ { type: 'text', text: 'Tagging cancelled by the operator' } ], + isError: true +} +``` ## Write the handler write-once -<!-- teaches: the pattern - on every entry read what already arrived, ask only for what is still missing; never branch on era | salvage: docs/server.md "Requesting input on 2026-07-28" --> -<!-- code: the same handler asking for two inputs across two rounds, each guarded by acceptedContent --> + +Write one handler that runs on every round: read each answer first, then request only the keys still missing. `inputRequests` is a map, so one round carries every outstanding request. + +```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_writeOnce" +server.registerTool( + 'provision', + { description: 'Provision a database', inputSchema: z.object({}) }, + async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { + const name = acceptedContent(ctx.mcpReq.inputResponses, 'name', z.object({ name: z.string() })); + const region = acceptedContent(ctx.mcpReq.inputResponses, 'region', z.object({ region: z.string() })); + if (name === undefined || region === undefined) { + return inputRequired({ + inputRequests: { + ...(name === undefined && { + name: inputRequired.elicit({ + message: 'Database name?', + requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } + }) + }), + ...(region === undefined && { + region: inputRequired.elicit({ + message: 'Which region?', + requestedSchema: { type: 'object', properties: { region: { type: 'string' } }, required: ['region'] } + }) + }) + } + }); + } + return { content: [{ type: 'text', text: `Provisioned ${name.name} in ${region.region}` }] }; + } +); +``` + +Round one finds neither key, so both requests go out together; round two finds both and the handler returns. + +``` +[client] elicitation/create → Database name? +[client] elicitation/create → Which region? +{ + content: [ { type: 'text', text: 'Provisioned analytics in eu-west-1' } ] +} +``` + +`inputResponses` holds only the latest round's answers, and nothing else on the server survives between rounds. A flow whose rounds must run in **sequence** carries what it has learned in `requestState`, below. ## Pick the embedded request kind -<!-- teaches: inputRequired.elicit (form), inputRequired.elicitUrl (URL), inputRequired.createMessage (sampling), inputRequired.listRoots() | source: packages/core-internal/src/shared/inputRequired.ts InputRequiredBuilder --> -<!-- code: one inputRequests map naming all four builders --> + +Each value in `inputRequests` is one embedded request, named by the builder that constructs it: `inputRequired.elicit` (form), `inputRequired.elicitUrl` (out-of-band URL), `inputRequired.createMessage` (sampling), and `inputRequired.listRoots()`. + +```ts source="../../examples/guides/servers/input-required.examples.ts#inputRequired_kinds" +const next = inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Continue?', + requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } + }), + signin: inputRequired.elicitUrl({ message: 'Sign in to continue', url: 'https://example.com/auth' }), + summary: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the diff' } }], + maxTokens: 200 + }), + roots: inputRequired.listRoots() + } +}); +``` + +`acceptedContent` only reads accepted form elicitations; read the sampling and roots responses through `inputResponse`, which discriminates all four kinds. [Elicitation](./elicitation.md) covers `requestedSchema` and URL mode in full. + +::: warning +Sampling and roots are deprecated as of protocol revision 2026-07-28 (SEP-2577) — see [Sampling](./sampling.md). Reach for the elicitation builders first. +::: ## Carry state across rounds with `requestState` -<!-- teaches: nothing survives between rounds on the server; mint an opaque requestState, read it back with ctx.mcpReq.requestState<State>() | salvage: docs/server.md "Carrying state across rounds: requestState" (requestState_mintDecode) --> -<!-- code: mint requestState alongside the second-round request; read it on re-entry --> + +To run rounds in sequence, return an opaque `requestState` string alongside the requests. The client echoes it back byte-for-byte on the retry, and `ctx.mcpReq.requestState<State>()` reads its decoded payload on re-entry. Mint it with the codec from the next section. + +```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_mint" +server.registerTool( + 'wipe-cache', + { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, + async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { + const state = ctx.mcpReq.requestState<{ step: string }>(); + + if (state?.step !== 'confirmed') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Really wipe the cache?', + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + // Mint only what the response above already proved: the operator confirmed. + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ + message: 'Which scope?', + requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + + const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); + return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + } +); +``` + +Mint only what earlier rounds already proved. The token is bearer proof of whatever it claims: state minted as `{ step: 'confirmed' }` before the confirmation arrives grants that step to anyone who echoes it. One call drives all three entries: + +``` +[client] elicitation/create → Really wipe the cache? +[client] elicitation/create → Which scope? +{ content: [ { type: 'text', text: 'Wiped sessions' } ] } +``` ## Protect `requestState` with the codec -<!-- teaches: requestState round-trips through the client and is attacker-controlled; createRequestStateCodec (HMAC-SHA256) + the ServerOptions.requestState.verify hook; mint only what earlier rounds proved | salvage: docs/server.md requestState IMPORTANT box (requestState_codec) --> -<!-- code: createRequestStateCodec({ key, ttlSeconds }) wired into ServerOptions.requestState.verify --> -<!-- ::: warning placeholder: signed, not encrypted; tampered/expired state answers -32602 --> + +`requestState` round-trips through the client and comes back as attacker-controlled input; the SDK applies no protection of its own. `createRequestStateCodec` returns an HMAC-SHA256 `{ mint, verify }` pair — pass `verify` as `ServerOptions.requestState.verify` and it runs before every handler entry that carries state. + +```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_codec" +const stateCodec = createRequestStateCodec<{ step: string }>({ + key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share it across instances in a fleet + ttlSeconds: 600 +}); + +const server = new McpServer( + { name: 'releases', version: '1.0.0' }, + { requestState: { verify: stateCodec.verify } } +); +``` + +With the hook in place, the accessor hands the handler `verify`'s decoded payload, and tampered or expired state never reaches the handler at all. Retrying `wipe-cache` with `requestState: 'tampered'` answers a wire-level protocol error: + +``` +-32602 Invalid or expired requestState +``` + +::: warning +The codec is signed, not encrypted — the client can base64url-decode the payload. Keep secrets out of it. +::: ## Let the shim serve older clients -<!-- teaches: the on-by-default legacy shim fulfils input_required returns over the older push channels, so write-once handlers serve every connection | salvage: docs/server.md "Requesting input on 2026-07-28" closing paragraph --> -<!-- code: none; the era detail is ONE line linking /protocol-versions and the support guide --> + +The handlers above already serve every connection. On a connection that predates 2026-07-28, the SDK's legacy shim — on by default — fulfils an `input_required` return by pushing real `elicitation/create`, `sampling/createMessage`, and `roots/list` requests over the session, then re-enters the handler with the collected responses and the byte-exact `requestState` echo. Every result quoted on this page came from such a connection. + +Set `ServerOptions.inputRequired.legacyShim: false` to fail loudly instead. Which revision a connection negotiates is covered in [Protocol versions](../protocol-versions.md). ## Recap -<!-- the claims this page will prove: -- On 2026-07-28 a handler asks for input by RETURNING inputRequired(...); the client retries with the responses. -- inputRequired carries inputRequests and/or requestState; it throws if it has neither. -- acceptedContent(ctx.mcpReq.inputResponses, key) reads what a previous round produced; treat it as untrusted. -- Write-once handlers re-derive their position on every entry instead of remembering it. -- requestState is the only cross-round memory; sign it with createRequestStateCodec and mint only what was proved. -- The legacy shim makes the same handler work for older clients. ---> + +- A handler asks for input by returning `inputRequired(...)`; the client answers the embedded requests and retries the call. +- `inputRequired(spec)` needs at least one of `inputRequests` or `requestState`, and throws a `TypeError` without one. +- `acceptedContent(ctx.mcpReq.inputResponses, key, schema)` validates the untrusted client answer before it reaches your code; `inputResponse` discriminates declines and the non-elicitation kinds. +- A write-once handler re-derives its position on every entry and requests only what is still missing. +- `requestState` is the only cross-round memory; protect it with `createRequestStateCodec` and mint only what earlier rounds proved. +- The legacy shim serves the same handlers to pre-2026-07-28 clients. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index 77fc65d8b0..bfc61db1bf 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -1,72 +1,205 @@ --- -status: scaffold shape: how-to --- # Logging, progress, and cancellation -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: The ctx every handler receives: logging, progress, cancellation. -teaches: ServerContext, ctx.mcpReq.notify, ctx.mcpReq.log, ctx.mcpReq.signal, ctx.mcpReq._meta -source: mined from docs/server.md "Logging", "Progress" + protocol cancellation behavior ---> +Every handler receives a **context** as its second argument; the request-scoped helpers — progress, logging, and the cancellation signal — live on `ctx.mcpReq`. ## Report progress from a handler -<!-- teaches: ctx.mcpReq._meta.progressToken, ctx.mcpReq.notify('notifications/progress') | salvage: docs/server.md "Progress" (registerTool_progress) --> -```ts -// draft - API verified against packages/core-internal/src/shared/protocol.ts (BaseContext.mcpReq._meta/notify, lines 375-433) +A client that wants progress puts a `progressToken` in the request's `_meta`. Read it from `ctx.mcpReq._meta` and send each update with `ctx.mcpReq.notify`. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_progress" server.registerTool( - 'process-files', - { - description: 'Process files with progress updates', - inputSchema: z.object({ files: z.array(z.string()) }), - }, - async ({ files }, ctx) => { - const progressToken = ctx.mcpReq._meta?.progressToken; - - for (let i = 0; i < files.length; i++) { - // ... process files[i] ... - if (progressToken !== undefined) { - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` }, - }); - } + 'process-files', + { + description: 'Process files with progress updates', + inputSchema: z.object({ files: z.array(z.string()) }) + }, + async ({ files }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + for (let i = 0; i < files.length; i++) { + // ... process files[i] ... + + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` } + }); + } + } + + return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; } +); +``` - return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; - } +Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring. Pass an `onprogress` callback and the SDK puts the `progressToken` in `_meta` for you. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_onprogress" +const result = await client.callTool( + { name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } }, + { onprogress: update => console.log(update) } ); +console.log(result.content); +``` + +The callback fires once per file, then the call resolves: + +``` +{ progress: 1, total: 3, message: 'Processed a.csv' } +{ progress: 2, total: 3, message: 'Processed b.csv' } +{ progress: 3, total: 3, message: 'Processed c.csv' } +[ { type: 'text', text: 'Processed 3 files' } ] ``` -<!-- result: the client's progress callback fires once per file with progress/total/message. --> ## Skip progress when the client did not ask -<!-- teaches: progressToken is opt-in; progress must increase; total and message are optional | salvage: docs/server.md "Progress" closing rules --> -<!-- code: the same loop guarded on progressToken === undefined, one comment per rule --> + +Drop `onprogress` and the same request arrives with no `progressToken`, so the guard in the handler sends nothing. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_noProgress" +const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } }); +console.log(quiet.content); +``` + +Only the result comes back: + +``` +[ { type: 'text', text: 'Processed 2 files' } ] +``` + +`progress` must increase on every notification for the same token; `total` and `message` are optional. ## Log to the client -<!-- teaches: capabilities: { logging: {} } + ctx.mcpReq.log(level, data) | salvage: docs/server.md "Logging" (logging_capability, registerTool_logging) --> -<!-- code: declare the logging capability at construction, then ctx.mcpReq.log('info', ...) inside the handler --> -<!-- ::: warning placeholder: MCP logging is deprecated (SEP-2577); migrate to stderr (stdio) or OpenTelemetry --> -## Respect the client's log level -<!-- teaches: per-request logLevel _meta key (2026-07-28) vs logging/setLevel (2025-era); silent no-op when unset | salvage: docs/server.md "Logging" closing paragraph --> -<!-- code: none; one-line era cross-link to /protocol-versions --> +::: warning Deprecated — SEP-2577 +Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). +::: + +Declare the `logging` capability when you construct the server. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#logging_capability" +const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } }); +``` + +`ctx.mcpReq.log(level, data)` then sends a `notifications/message` from inside any handler — `data` is any JSON value. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_logging" +server.registerTool( + 'validate-records', + { + description: 'Validate records before import', + inputSchema: z.object({ records: z.array(z.string()) }) + }, + async ({ records }, ctx) => { + await ctx.mcpReq.log('info', `Validating ${records.length} records`); + const invalid = records.filter(record => !record.endsWith('.csv')); + if (invalid.length > 0) { + await ctx.mcpReq.log('warning', { invalid }); + } + return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] }; + } +); +``` + +The connected client surfaces each one through its `notifications/message` handler. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#setNotificationHandler_message" +client.setNotificationHandler('notifications/message', notification => { + console.log(notification.params.level, notification.params.data); +}); +``` + +Calling `validate-records` with one bad record delivers both log notifications before the result: + +``` +info Validating 2 records +warning { invalid: [ 'b.txt' ] } +[ { type: 'text', text: '1 of 2 records are valid' } ] +``` + +How the client's log level reaches `ctx.mcpReq.log` differs by protocol era — see [Protocol versions](../protocol-versions.md). ## Stop work when the request is cancelled -<!-- teaches: ctx.mcpReq.signal is an AbortSignal aborted by notifications/cancelled and client disconnects | source: packages/core-internal/src/shared/protocol.ts (signal, line 406) --> -<!-- code: a long-running loop that checks ctx.mcpReq.signal.aborted and returns early --> -<!-- result: the client sees no response for the cancelled request; the handler stops burning work --> + +`ctx.mcpReq.signal` is an `AbortSignal`. The SDK aborts it when the client sends `notifications/cancelled` for the request, and when the connection closes — check it before each unit of work. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_abort" +server.registerTool( + 'scan-archive', + { + description: 'Scan every page of the archive', + inputSchema: z.object({ pages: z.number().int() }) + }, + async ({ pages }, ctx) => { + let scanned = 0; + for (let page = 0; page < pages; page++) { + if (ctx.mcpReq.signal.aborted) { + console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`); + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ... + scanned++; + } + return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] }; + } +); +``` + +Cancel from the client by aborting the signal you pass to the call. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_abort" +const controller = new AbortController(); +const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal }); + +// the end user clicks Stop while the scan runs +setTimeout(() => controller.abort('the end user clicked Stop'), 5); + +await scan.catch(error => console.log(String(error))); +``` + +The call rejects on the client and the handler stops at its next check; the abort reason travels in the notification and comes out as `ctx.mcpReq.signal.reason`: + +``` +SdkError: the end user clicked Stop +Stopped after 1 of 40 pages: the end user clicked Stop +``` + +The first line is the client's rejection, the second is the handler's `console.error` on the server. The SDK never sends a response for a cancelled request and discards whatever the handler still returns. ## Pass the signal to your own I/O -<!-- teaches: forwarding ctx.mcpReq.signal into fetch / db calls so cancellation propagates --> -<!-- code: fetch(url, { signal: ctx.mcpReq.signal }) inside the handler --> + +Hand the same signal to `fetch` — or any API that accepts an `AbortSignal` — and cancellation propagates into the work the handler started. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_forwardSignal" +const SOURCE_URLS = { + readme: 'https://example.com/sources/readme.md', + changelog: 'https://example.com/sources/changelog.md' +}; + +server.registerTool( + 'fetch-source', + { + description: 'Download one of the known source files', + inputSchema: z.object({ source: z.enum(['readme', 'changelog']) }) + }, + async ({ source }, ctx) => { + const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal }); + return { content: [{ type: 'text', text: await response.text() }] }; + } +); +``` + +On cancellation `fetch` rejects mid-download and the handler unwinds with it, so no work outlives the request. + +::: warning +Resolve an identifier against a fixed list, as `fetch-source` does. A tool that fetches a caller-supplied URL lets any connected client drive requests from your server's network position (server-side request forgery). +::: ## Recap -<!-- the claims this page will prove: -- Every handler receives a context as its second argument; request-scoped helpers live on ctx.mcpReq. -- notify() sends notifications/progress when the client supplied a progressToken; progress must increase. -- log(level, data) sends structured log notifications once the logging capability is declared; logging is sunset (SEP-2577). -- The client's level filter is per-request on 2026-07-28 and per-session on 2025-era connections. -- ctx.mcpReq.signal aborts on cancellation; check it and forward it to your own I/O. ---> + +- Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`. +- `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one. +- `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577). +- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O. diff --git a/docs/servers/notifications.md b/docs/servers/notifications.md index 2ad761474f..42bc96415c 100644 --- a/docs/servers/notifications.md +++ b/docs/servers/notifications.md @@ -1,51 +1,111 @@ --- -status: scaffold shape: how-to --- + # Notifications -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Notify clients of changes. -teaches: sendToolListChanged, sendPromptListChanged, sendResourceListChanged, sendResourceUpdated, handler.notify, ServerEventBus -source: mined from docs/server.md "Change notifications" -era note (R8): the main column tells the handler.notify story once; the 2025-era hand-wired -subscribe path is a labeled aside, not a peer H2. The page's one era line links /protocol-versions. ---> +A **notification** is a one-way message your server pushes to a connected client; change notifications tell clients that a list or a resource they cached is stale. ## Send a list-changed notification -<!-- teaches: McpServer.sendToolListChanged() (and the prompt/resource siblings) | salvage: docs/server.md "Change notifications" --> -```ts -// draft - API verified against packages/server/src/server/mcp.ts (sendToolListChanged, line 1129) +Start from a server with one tool. + +```ts source="../../examples/guides/servers/notifications.examples.ts#notifications_server" +import { McpServer } from '@modelcontextprotocol/server'; + +const jobs = ['nightly-backup']; + +const server = new McpServer({ name: 'jobs', version: '1.0.0' }); + +server.registerTool('list-jobs', { description: 'List the configured jobs' }, async () => ({ + content: [{ type: 'text', text: jobs.join('\n') }] +})); +``` + +Every notification on this page is observed by an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — which logs each notification method it receives. Push a tool-list change yourself when the tool set changes for a reason the registration API cannot see. + +```ts source="../../examples/guides/servers/notifications.examples.ts#sendToolListChanged_basic" server.sendToolListChanged(); ``` -<!-- result: connected clients that declared the capability receive notifications/tools/list_changed and re-list. --> + +The client receives one `notifications/tools/list_changed` and re-fetches `tools/list`: + +``` +notifications/tools/list_changed +``` + +`sendPromptListChanged()` and `sendResourceListChanged()` are the prompt and resource siblings. ## Let registration changes notify for you -<!-- teaches: registering, enabling, disabling, updating, or removing a tool/prompt/resource emits the matching list_changed automatically | salvage: docs/server.md "Change notifications" (List changes) --> -<!-- code: registeredTool.update(...) / .disable() with a comment on the notification each emits --> + +Hold on to the handle `registerTool` returns — every mutation through it sends the matching list-changed on its own. + +```ts source="../../examples/guides/servers/notifications.examples.ts#registeredTool_update" +const report = server.registerTool('run-report', { description: 'Run the weekly report' }, async () => ({ + content: [{ type: 'text', text: 'report queued' }] +})); + +report.update({ description: 'Run the weekly report and email it' }); +report.disable(); +``` + +Registering, updating, and disabling each sent a notification of its own — three more, none explicit: + +``` +notifications/tools/list_changed +notifications/tools/list_changed +notifications/tools/list_changed +``` + +`enable()` and `remove()` notify the same way, and the handles returned by `registerResource` and `registerPrompt` send `notifications/resources/list_changed` and `notifications/prompts/list_changed`. Most servers never call a `send*ListChanged()` method directly. ## Advertise the `listChanged` capability -<!-- teaches: McpServer advertises listChanged on registration; declare it up front only on the low-level Server | salvage: docs/server.md "Change notifications" --> -<!-- code: capabilities: { tools: { listChanged: true } } on a low-level Server --> + +`McpServer` advertised `tools: { listChanged: true }` the moment you registered a tool, and does the same for prompts and resources. Only the [low-level `Server`](../advanced/low-level-server.md) needs the capability declared up front. + +```ts source="../../examples/guides/servers/notifications.examples.ts#Server_listChanged" +const lowLevel = new Server({ name: 'jobs', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); +``` + +The low-level `Server` refuses to send a notification its capabilities do not cover — `sendToolListChanged()` throws without a `tools` capability — and clients use the `listChanged` flag to decide which notification types to ask for. ## Publish a resource update through the handler -<!-- teaches: clients subscribe via the serving entries (subscriptions/listen); you publish through the handler.notify.resourceUpdated / toolsChanged facade | salvage: docs/server.md "Change notifications" (subscriptions_notify) --> -<!-- code: const handler = createMcpHandler(() => buildServer()); handler.notify.resourceUpdated('config://app') --> -<!-- result: every client subscribed to that URI receives notifications/resources/updated --> -<!-- aside (::: info Coming from 2025-era subscriptions, labeled): resources: { subscribe: true } plus - hand-wired resources/subscribe/unsubscribe handlers and sendResourceUpdated({ uri }) still work - on older connections — compressed to this aside; salvage docs/server.md (subscriptions_legacy). - The era detail is ONE line linking /protocol-versions. --> + +On a 2026-07-28 connection, change notifications reach a client only on a [`subscriptions/listen`](../clients/subscriptions.md) stream the client opens — see [Protocol versions](../protocol-versions.md). Behind [`createMcpHandler`](../serving/http.md) the `McpServer` instance is per-request, so publish through the handler, not the instance: `notify` is a typed facade over the handler's open subscription streams. + +```ts source="../../examples/guides/servers/notifications.examples.ts#handler_notifyResourceUpdated" +const handler = createMcpHandler(() => buildJobsServer()); + +// After config://app changes: +handler.notify.resourceUpdated('config://app'); +``` + +Every client whose stream listed `config://app` receives `notifications/resources/updated` carrying that URI; `notify.toolsChanged()`, `notify.promptsChanged()`, and `notify.resourcesChanged()` publish the three list-changed types the same way. Per-resource updates have one extra gate: the server your factory builds must advertise `resources: { subscribe: true }`. + +::: tip +On stdio, [`serveStdio`](../serving/stdio.md) routes the instance's own `send*ListChanged()` and `sendResourceUpdated()` calls onto its open subscription stream — no `notify` facade needed. +::: + +::: info Coming from 2025-era subscriptions +A 2025-era connection delivers per-resource updates without a listen stream — [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) covers that path, and [Protocol versions](../protocol-versions.md) the era split. +::: ## Pick an event bus for multi-process deployments -<!-- teaches: InMemoryServerEventBus default; supply a ServerEventBus via the `bus` option when you run more than one process | salvage: docs/server.md "Change notifications" closing paragraph --> -<!-- code: createMcpHandler(factory, { bus: myBus }) placeholder; cross-link serving/sessions-state-scaling.md --> + +The `bus` option accepts any `ServerEventBus`; `InMemoryServerEventBus` is what `createMcpHandler` builds when you omit it. + +```ts source="../../examples/guides/servers/notifications.examples.ts#createMcpHandler_bus" +const bus = new InMemoryServerEventBus(); + +const shared = createMcpHandler(() => buildJobsServer(), { bus }); +``` + +The in-memory bus never leaves the process, so one process needs nothing more. Run more than one and you implement the two-method `ServerEventBus` interface — `publish` and `subscribe` — over your own pub/sub backend, then pass the same instance to every handler; see [Sessions, state, and scaling](../serving/sessions-state-scaling.md). ## Recap -<!-- the claims this page will prove: -- send*ListChanged() pushes a list_changed notification; registration changes already send it for you. -- Delivery is capability-gated: only clients (and servers) that declared listChanged participate. -- Clients subscribe to per-resource updates through the serving entry; you publish through the handler's notify facade. -- One process needs nothing; multiple processes share a ServerEventBus. ---> + +- `sendToolListChanged()`, `sendPromptListChanged()`, and `sendResourceListChanged()` push a list-changed notification to connected clients. +- Registering, updating, enabling, disabling, or removing through a registration handle sends the matching list-changed for you. +- `McpServer` advertises `listChanged` as you register; only the low-level `Server` declares it up front. +- Behind `createMcpHandler`, publish through `handler.notify`; delivery reaches every open subscription stream that opted in. +- One process needs no `bus`; more than one shares a `ServerEventBus`. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index 1363c5ae3c..141b1b2f09 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -1,64 +1,205 @@ --- -status: scaffold shape: how-to --- # Prompts -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Register prompts, message construction. -teaches: McpServer.registerPrompt, PromptCallback, argsSchema -source: mined from docs/server.md "Prompts" ---> +A **prompt** is a message template a connected client invokes by name. Clients surface prompts directly to people — slash commands, menu entries — where [tools](./tools.md) are picked by the model. ## Register a prompt -<!-- teaches: registerPrompt(name, config, cb) | salvage: docs/server.md "Prompts" (registerPrompt_basic) --> -```ts -// draft - API verified against packages/server/src/server/mcp.ts (registerPrompt, line 1031) +`registerPrompt` takes a name, a config, and a callback that returns the messages. `argsSchema` is a Zod object schema describing the arguments. + +```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_review" +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'review', version: '1.0.0' }); + server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices and potential issues', - argsSchema: z.object({ - code: z.string(), - }), - }, - ({ code }) => ({ - messages: [ - { - role: 'user' as const, - content: { type: 'text' as const, text: `Please review this code:\n\n${code}` }, - }, - ], - }) + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string().describe('The code to review') + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this code:\n\n${code}` } + } + ] + }) ); ``` -<!-- result: the prompt appears in prompts/list; prompts/get returns the messages with the argument filled in. --> + +`prompts/list` now advertises `review-code` with one required argument, `code`. + +::: tip +`.describe()` survives the conversion: `prompts/list` carries `The code to review` as the `code` argument's `description` — what a client shows next to the input field. +::: + +::: info Coming from v1? +`registerPrompt` replaces `prompt()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: + +Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same when someone picks the prompt. Fetch it with `getPrompt`. + +```ts source="../../examples/guides/servers/prompts.examples.ts#getPrompt_review" +const result = await client.getPrompt({ name: 'review-code', arguments: { code: 'let x = 1' } }); +console.log(result.messages); +``` + +The callback's messages come back with the argument filled in: + +``` +[ + { + role: 'user', + content: { type: 'text', text: 'Review this code:\n\nlet x = 1' } + } +] +``` ## Validate the arguments with the schema -<!-- teaches: argsSchema is a Zod object; the SDK validates prompts/get arguments before the callback and infers the callback's argument types --> -<!-- code: prompts/get with a missing `code` argument --> -<!-- result: the verbatim -32602 Invalid Params error the client receives --> -<!-- the schema-payoff sentence lands here, once --> + +Drop the required argument. + +```ts source="../../examples/guides/servers/prompts.examples.ts#getPrompt_invalid" +import type { ProtocolError } from '@modelcontextprotocol/client'; + +try { + await client.getPrompt({ name: 'review-code', arguments: {} }); +} catch (error) { + const { code, message } = error as ProtocolError; + console.log(code, message); +} +``` + +The SDK rejects the request before your callback runs: + +``` +-32602 Invalid arguments for prompt review-code: code: Invalid input: expected string, received undefined +``` + +A failed prompt validation is a protocol error — `getPrompt` rejects with a `ProtocolError` carrying code `-32602` (Invalid params). A [tool](./tools.md) argument rejection comes back as an `isError: true` result instead. + +From that one schema the SDK derives the argument list `prompts/list` advertises, validates `prompts/get` arguments before your callback runs, and infers the callback's argument types. ## Build the messages -<!-- teaches: PromptMessage shape - role ('user' | 'assistant') and content item types | salvage: docs/server.md "Prompts" --> -<!-- code: a two-message prompt (user + assistant) showing the role/content structure --> + +The callback returns `{ messages }`. Each message names a `role` — `'user'` or `'assistant'` — and one `content` block; add an `assistant` message after the `user` message to seed how the reply starts. + +```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_messages" +server.registerPrompt( + 'explain-error', + { + description: 'Explain a compiler error and suggest the smallest fix', + argsSchema: z.object({ error: z.string() }) + }, + ({ error }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Explain this compiler error:\n\n${error}` } + }, + { + role: 'assistant' as const, + content: { type: 'text' as const, text: 'The one-line cause:' } + } + ] + }) +); +``` + +The host hands the messages to the model in order, so the trailing `assistant` message becomes the start of its reply. `content` accepts the same union a tool result does: `text`, `image`, `audio`, `resource_link`, and `resource`. ## Embed a resource in a message -<!-- teaches: content: { type: 'resource', resource: { uri, text, mimeType } } inside a prompt message --> -<!-- code: a prompt message whose content embeds a resource the server also registers --> + +`type: 'resource'` puts a resource's contents inside a message. Register the resource as usual — see [Resources](./resources.md) — and embed the same `uri`, `mimeType`, and `text` in the prompt. + +```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_embedResource" +const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.'; + +server.registerResource('style-guide', 'doc://style-guide', { mimeType: 'text/markdown' }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/markdown', text: styleGuide }] +})); + +server.registerPrompt( + 'review-against-style', + { + description: 'Review code against the team style guide', + argsSchema: z.object({ code: z.string() }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'resource' as const, + resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: styleGuide } + } + }, + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this code against the style guide:\n\n${code}` } + } + ] + }) +); +``` + +`prompts/get` returns the style guide inline as the first message, so the client never makes a second `resources/read` round trip: + +``` +{ + role: 'user', + content: { + type: 'resource', + resource: { + uri: 'doc://style-guide', + mimeType: 'text/markdown', + text: '- Prefer const over let.\n- No single-letter identifiers.' + } + } +} +``` + +The `uri` tells the client which registered resource the embedded copy came from. ## Offer argument autocompletion -<!-- teaches: hand-off - wrap an argsSchema field with completable(); full treatment on servers/completion.md --> -<!-- code: one line - completable(z.string(), value => [...]) inside argsSchema; cross-link servers/completion.md --> + +Wrap an argument with `completable()` to suggest values while a client fills in the form. + +```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_completable" +import { completable } from '@modelcontextprotocol/server'; + +server.registerPrompt( + 'translate', + { + description: 'Translate a snippet into another language', + argsSchema: z.object({ + language: completable(z.string(), value => + ['typescript', 'python', 'rust', 'go'].filter(language => language.startsWith(value)) + ), + code: z.string() + }) + }, + ({ language, code }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Translate to ${language}:\n\n${code}` } }] + }) +); +``` + +The client sends `completion/complete` with the characters typed so far; the SDK runs your function and returns the matching values. [Completion](./completion.md) covers the request flow and context-aware suggestions. ## Recap -<!-- the claims this page will prove: -- registerPrompt(name, config, callback) registers a prompt; clients discover it via prompts/list. -- argsSchema is one Zod object: validated arguments, inferred callback types, the argument list clients see. -- The callback returns { messages: [...] }; each message names a role and one content item. -- Messages can embed resources, not only text. -- completable() adds per-argument autocompletion. ---> + +- `registerPrompt(name, config, callback)` registers a prompt; clients discover it through `prompts/list`. +- `argsSchema` is one Zod object: the advertised argument list, argument validation, and the callback's argument types. +- Arguments that fail the schema reject `prompts/get` with a `-32602` protocol error; the callback never runs. +- The callback returns `{ messages }`; each message names a `role` and one `content` block. +- A message can embed a registered resource's contents with `type: 'resource'`. +- `completable()` adds per-argument autocompletion. diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 5fa0460b95..7082723d22 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -1,63 +1,222 @@ --- -status: scaffold shape: how-to --- + # Resources -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Static + templated resources, list callbacks. -teaches: McpServer.registerResource, ResourceTemplate, ReadResourceCallback, ListResourcesCallback -source: mined from docs/server.md "Resources" ---> +A **resource** is read-only data — a file, a database row, a rendered report — that a connected client lists, reads, and attaches as context for the model. The client decides what to read: resources are application-controlled, where [tools](./tools.md) are model-controlled. ## Register a static resource -<!-- teaches: registerResource (string URI overload) | salvage: docs/server.md "Resources" --> -```ts -// draft - API verified against packages/server/src/server/mcp.ts (registerResource, line 580) +`registerResource` takes a name, a fixed URI, metadata, and a read callback. + +```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_static" +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'workspace', version: '1.0.0' }); + server.registerResource( - 'config', - 'config://app', - { - title: 'Application Config', - description: 'Application configuration data', - mimeType: 'text/plain', - }, - async uri => ({ - contents: [{ uri: uri.href, text: 'App configuration here' }], - }) + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }] + }) ); ``` -<!-- result: resources/list now returns config://app; resources/read on it returns the contents array. --> + +`resources/list` now advertises `config://app` with that metadata, and `resources/read` on `config://app` runs the callback. + +::: info Coming from v1? +`registerResource` replaces `resource()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Return the contents from the read callback -<!-- teaches: ReadResourceCallback, ReadResourceResult.contents (text vs blob) | salvage: docs/server.md "Resources" --> -<!-- code: the same callback returning a text item and a base64 blob item; uri.href echoed back --> + +The callback returns `{ contents: [...] }`. Add a resource whose contents hold two items: each one echoes the `uri` it answers for and carries either `text` or a base64 `blob`. + +```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_report" +// A 1x1 PNG; a production server reads these bytes from disk or object storage. +const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='; + +server.registerResource( + 'report', + 'report://latest', + { + title: 'Latest usage report', + description: 'Weekly usage summary with a rendered chart', + mimeType: 'text/markdown' + }, + async uri => ({ + contents: [ + { uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' }, + { uri: uri.href, mimeType: 'image/png', blob: chartPng } + ] + }) +); +``` + +Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Read the resource. + +```ts source="../../examples/guides/servers/resources.examples.ts#readResource_report" +const { contents } = await client.readResource({ uri: 'report://latest' }); +console.log(contents); +``` + +The callback's array comes back unchanged, one entry per item: + +``` +[ + { + uri: 'report://latest', + mimeType: 'text/markdown', + text: 'Active installs grew 12% week over week.' + }, + { + uri: 'report://latest', + mimeType: 'image/png', + blob: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=' + } +] +``` + +The `mimeType` on each item describes that item; the `mimeType` in the registration config describes the resource as a whole in `resources/list`. ## Add a resource template -<!-- teaches: ResourceTemplate (uriTemplate, registerResource template overload), template variables in the read callback | salvage: docs/server.md "Resources" (registerResource_template) --> -<!-- code: new ResourceTemplate('user://{userId}/profile', { list: undefined }) passed to registerResource; handler receives (uri, { userId }) --> + +A `ResourceTemplate` registers a whole URI pattern instead of one URI. `list` is a required key — pass `undefined` when the instances are unbounded. + +```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_template" +server.registerResource( + 'user-profile', + new ResourceTemplate('users://{userId}/profile', { list: undefined }), + { + title: 'User Profile', + description: 'Profile data for one user', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }] + }) +); +``` + +The matched variables arrive parsed as the read callback's second argument. Read any URI the pattern matches. + +```ts source="../../examples/guides/servers/resources.examples.ts#readResource_template" +const profile = await client.readResource({ uri: 'users://7/profile' }); +console.log(profile.contents); +``` + +The callback ran with `userId` bound to `'7'`: + +``` +[ + { + uri: 'users://7/profile', + mimeType: 'application/json', + text: '{"userId":"7","plan":"pro"}' + } +] +``` ## List the template's instances -<!-- teaches: ListResourcesCallback (the required `list` option) | salvage: docs/server.md "Resources" (list callback) --> -<!-- code: same template with list: async () => ({ resources: [{ uri, name }, ...] }) --> -<!-- result: resources/list output showing the two concrete user:// URIs --> + +`users://{userId}/profile` is readable but never appears in `resources/list` — with `list: undefined` there is nothing to enumerate. Register a template over an enumerable set and give it a `list` callback. + +```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_list" +server.registerResource( + 'team-roster', + new ResourceTemplate('teams://{teamId}/roster', { + list: async () => ({ + resources: [ + { uri: 'teams://core/roster', name: 'Core team roster' }, + { uri: 'teams://growth/roster', name: 'Growth team roster' } + ] + }) + }), + { + description: 'Members of one team', + mimeType: 'text/plain' + }, + async (uri, { teamId }) => ({ + contents: [{ uri: uri.href, text: `Members of team ${teamId}` }] + }) +); +``` + +`resources/list` merges the static resources with every template's `list` results: + +```ts source="../../examples/guides/servers/resources.examples.ts#listResources" +const { resources } = await client.listResources(); +console.log(resources.map(resource => resource.uri)); +``` + +Both `teams://` rosters are discoverable; the `users://` template contributes nothing: + +``` +[ + 'config://app', + 'report://latest', + 'teams://core/roster', + 'teams://growth/roster' +] +``` + +`resources/templates/list` still advertises both URI patterns (`client.listResourceTemplates()`), so a client that already knows a `userId` builds the concrete URI itself. ## Sanitize file-backed paths -<!-- teaches: path-traversal guard for file:// resources | salvage: docs/server.md "Resources" IMPORTANT security note --> -<!-- code: resolve the requested path and reject anything that escapes the root (.. and symlinks) --> -<!-- ::: warning placeholder: never pass template variables or client URIs to filesystem APIs unchecked --> + +A template variable that becomes a filesystem path is client-controlled input. Resolve it to a real path and reject anything outside the root before you read. + +```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_file" +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +const DOCS_ROOT = path.resolve('./docs'); + +server.registerResource( + 'doc', + new ResourceTemplate('docs://{file}', { list: undefined }), + { + description: 'A markdown page from the docs directory', + mimeType: 'text/markdown' + }, + async (uri, { file }) => { + const requested = await realpath(path.join(DOCS_ROOT, String(file))); + if (!requested.startsWith(DOCS_ROOT + path.sep)) { + throw new Error(`${uri.href} resolves outside the docs root`); + } + return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] }; + } +); +``` + +`realpath` collapses `..` segments and symlinks to the path that is actually on disk; the `startsWith` check then rejects anything that escaped `DOCS_ROOT`. Throw on rejection — [Errors](./errors.md) covers how a thrown error reaches the client. + +::: warning +Never pass a template variable or a client-supplied URI to a filesystem API unchecked. `..` arrives raw and percent-encoded, and a symlink inside the root can point outside it — compare resolved real paths, never the strings the client sent. +::: ## Tell clients when a resource changes -<!-- teaches: list_changed is automatic on (de)registration; per-resource updates live on the notifications page | salvage: docs/server.md "Change notifications" --> -<!-- code: one line - server.sendResourceListChanged(); cross-link servers/notifications.md --> + +Registering, enabling, disabling, or removing a resource already sends `notifications/resources/list_changed`. Send it yourself when the set changes for a reason the SDK cannot see. + +```ts source="../../examples/guides/servers/resources.examples.ts#sendResourceListChanged" +server.sendResourceListChanged(); +``` + +The notification tells connected clients to call `resources/list` again. A change to one resource's content is a different signal, `notifications/resources/updated` — [Notifications](./notifications.md) covers both. ## Recap -<!-- the claims this page will prove: -- registerResource(name, uri, config, readCallback) registers a fixed-URI resource. -- The read callback returns { contents: [...] }; each item carries uri plus text or blob. -- A ResourceTemplate registers a whole URI pattern; variables arrive parsed in the callback. -- The template's list callback is what makes instances discoverable via resources/list. -- File-backed resources must reject paths that escape the root. -- Registration changes emit notifications/resources/list_changed automatically. ---> + +- `registerResource(name, uri, config, readCallback)` registers a resource at a fixed URI. +- The read callback returns `{ contents: [...] }`; each item echoes the `uri` and carries `text` or a base64 `blob`. +- A `ResourceTemplate` registers a URI pattern; the matched variables arrive parsed as the read callback's second argument. +- A template's `list` callback is what makes its instances appear in `resources/list`. +- Resolve file-backed paths to their real location and reject anything outside the root before reading. +- Registration changes emit `notifications/resources/list_changed` automatically. diff --git a/docs/servers/sampling.md b/docs/servers/sampling.md index d30ae1517e..86f97ae569 100644 --- a/docs/servers/sampling.md +++ b/docs/servers/sampling.md @@ -1,67 +1,80 @@ --- -status: scaffold shape: how-to --- # Sampling ::: warning Deprecated — SEP-2577 -<!-- SUNSET BANNER placeholder. Sampling is deprecated as of protocol version 2026-07-28 -(SEP-2577) and remains functional on 2025-era connections for at least twelve months. -Migration target named FIRST: call your LLM provider's API directly from your server. -Link the deprecated-features registry. This banner is the first thing on the page. --> +Call your LLM provider's API directly from your server instead. **Sampling** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). ::: -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first. -teaches: ctx.mcpReq.requestSampling, CreateMessageRequestParams -source: mined from docs/server.md "Sampling" -mrtr note: inputRequired.createMessage is owned by servers/input-required.md (proposal §1 -taxonomy delta); this page carries one cross-link aside, never a second code block. ---> - ## Replace sampling with a direct provider call -<!-- teaches: the migration target, not the feature - call your LLM provider's SDK/API from the tool handler with your own key | source: SEP-2577 framing in docs/server.md sampling WARNING; net-new framing mirroring clients/roots.md "Migrate away first" --> -<!-- code: none — this section is the off-ramp; one link to the deprecated-features registry --> +Sampling routes an LLM call through the connected client: a tool handler sends a prompt, the host runs it through a model it controls, and the handler resumes with the completion. The 2026-07-28 revision removes the server-to-client request channel that carries it. + +Migrate by importing your LLM provider's SDK into the server and calling it from the tool handler with your own API key. The handler keeps its shape; the `requestSampling` call is the only line that changes, and you stop depending on what the client supports. ## Request a completion from the client -<!-- teaches: ctx.mcpReq.requestSampling({ messages, maxTokens }) | salvage: docs/server.md "Sampling" (registerTool_sampling) --> -```ts -// draft - API verified against packages/core-internal/src/shared/protocol.ts (ServerContext.mcpReq.requestSampling, line 481) +`ctx.mcpReq.requestSampling` sends a `sampling/createMessage` request to the connected client from inside a tool handler. The client runs the messages through its model and resolves the call with the completion. + +```ts source="../../examples/guides/servers/sampling.examples.ts#registerTool_sampling" server.registerTool( - 'summarize', - { - description: 'Summarize text using the client LLM', - inputSchema: z.object({ text: z.string() }), - }, - async ({ text }, ctx) => { - const response = await ctx.mcpReq.requestSampling({ - messages: [{ role: 'user', content: { type: 'text', text: `Please summarize:\n\n${text}` } }], - maxTokens: 500, - }); - return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; - } + 'summarize', + { + description: 'Summarize text using the client LLM', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }, ctx) => { + const response = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: `Summarize in one sentence:\n\n${text}` } }], + maxTokens: 500 + }); + return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; + } ); ``` -<!-- result: the client runs the prompt through its model and the handler gets back { model, role, content }. --> -<!-- aside (::: info): requestSampling is a push and throws on a 2026-07-28 connection, where a - handler RETURNS the embedded request instead — one line, cross-link servers/input-required.md, - which owns that form. Era detail is one line linking /protocol-versions. --> + +The handler blocks until the client answers, so your server never holds the key for the model that does the work — the host does. + +::: info +On a 2026-07-28 connection `requestSampling` throws. The replacement on that revision is returning an embedded `createMessage` request from the handler — [input_required](./input-required.md) owns that form. Era differences are listed in [Protocol versions](../protocol-versions.md). +::: ## Read the model's reply -<!-- teaches: CreateMessageResult shape - model, role, content; the client picks the model --> -<!-- code: none beyond the lead; the verbatim result object --> -<!-- result: the JSON the handler receives, verbatim --> + +The response is a `CreateMessageResult`: the client decides which model fulfils the request and returns its name as `model`, plus the assistant `role` and one `content` block. The handler above folds it into its tool result, so calling `summarize` from a client whose model is named `host-model` returns: + +``` +[ + { + type: 'text', + text: 'Model (host-model): {"type":"text","text":"Sampling lets a tool ask the client for a completion."}' + } +] +``` ## Require the sampling capability -<!-- teaches: the client must declare sampling; the SDK rejects the request before the wire when it did not --> -<!-- code: none; one line on the error surfaced to the handler --> + +`requestSampling` only works against a client that declared the `sampling` capability and registered a `sampling/createMessage` handler — [Handle requests from the server](../clients/server-requests.md) covers that side. + +Pass `enforceStrictCapabilities: true` to the `McpServer` constructor and the SDK checks the client's declared capabilities before it sends any server-initiated request. Against a client that never declared `sampling`, `requestSampling` then throws inside your handler, and the call comes back as an ordinary `isError` tool result: + +``` +{ + content: [ + { + type: 'text', + text: 'Client does not support sampling (required for sampling/createMessage)' + } + ], + isError: true +} +``` ## Recap -<!-- the claims this page will prove: -- Sampling is sunset (SEP-2577); the migration target is a direct LLM provider call from your server. -- ctx.mcpReq.requestSampling asks the connected client's model for a completion mid-handler. -- The client owns model choice; the result carries model, role, and content. -- It only works when the client declared the sampling capability. ---> + +- Sampling is deprecated (SEP-2577); the migration target is a direct LLM provider call from your server. +- `ctx.mcpReq.requestSampling({ messages, maxTokens })` asks the connected client's model for a completion mid-handler. +- The client picks the model; the result carries `model`, `role`, and `content`. +- On a 2026-07-28 connection `requestSampling` throws; the embedded-request form lives on the input_required page. +- The client must declare the `sampling` capability; `enforceStrictCapabilities: true` rejects the request before the wire when it did not. diff --git a/examples/guides/servers/completion.examples.ts b/examples/guides/servers/completion.examples.ts new file mode 100644 index 0000000000..a15904c4c2 --- /dev/null +++ b/examples/guides/servers/completion.examples.ts @@ -0,0 +1,183 @@ +/** + * Companion example for `docs/servers/completion.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/completion.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region completable_language +import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const languages = ['typescript', 'javascript', 'python', 'rust', 'go']; + +const server = new McpServer({ name: 'review', version: '1.0.0' }); + +server.registerPrompt( + 'review-code', + { + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + languages.filter(language => language.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this ${language} code for best practices.` } + } + ] + }) +); +//#endregion completable_language + +// "Return suggestions from the complete callback" — `repo` completes from an +// async lookup; `branch` points at `completeBranch`, defined in the next region. +//#region registerPrompt_async +const branchesByRepo: Record<string, string[]> = { + 'typescript-sdk': ['main', 'release/1.x', 'release/2.x'], + 'python-sdk': ['main', 'release/1.x'], + inspector: ['main'] +}; + +async function listRepos(): Promise<string[]> { + return Object.keys(branchesByRepo); +} + +server.registerPrompt( + 'review-pr', + { + description: 'Review the open pull requests on one branch', + argsSchema: z.object({ + repo: completable(z.string().describe('Repository name'), async value => { + const repos = await listRepos(); + return repos.filter(repo => repo.startsWith(value)); + }), + branch: completable(z.string().describe('Target branch'), completeBranch) + }) + }, + ({ repo, branch }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review the open pull requests on ${repo}@${branch}.` } + } + ] + }) +); +//#endregion registerPrompt_async + +// "Use the other arguments for context" — `branch` suggestions depend on the +// `repo` the client has already filled in. Function declarations hoist, so the +// registration above can reference this by name. +//#region completeCallback_context +async function completeBranch(value: string, context?: { arguments?: Record<string, string> }): Promise<string[]> { + const repo = context?.arguments?.repo; + if (!repo) return []; + return (branchesByRepo[repo] ?? []).filter(branch => branch.startsWith(value)); +} +//#endregion completeCallback_context + +// "Complete a resource template variable" — the template's `complete` map. +//#region resourceTemplate_complete +server.registerResource( + 'readme', + new ResourceTemplate('repo://{repo}/readme', { + list: undefined, + complete: { + repo: async value => { + const repos = await listRepos(); + return repos.filter(repo => repo.startsWith(value)); + } + } + }), + { description: 'The README of one repository' }, + async (uri, { repo }) => ({ + contents: [{ uri: uri.href, text: `Repository: ${repo}` }] + }) +); +//#endregion resourceTemplate_complete + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the requests +// whose output servers/completion.md quotes verbatim. Any MCP client behaves +// the same. Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'completion-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// Proof for "Wrap an argument with `completable`": the first completable +// registration advertised the `completions` capability without any declaration. +if (!client.getServerCapabilities()?.completions) { + throw new Error('completion.md claim failed: completions capability was not advertised'); +} + +// Proof for "Wrap an argument with `completable`": `language` typed as `ty`. +const language = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: 'ty' } +}); +if (language.completion.values.join(',') !== 'typescript') { + throw new Error(`completion.md claim failed: language 'ty' -> ${JSON.stringify(language.completion.values)}`); +} + +// "Return suggestions from the complete callback" — the async `repo` result the page quotes. +const repo = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-pr' }, + argument: { name: 'repo', value: 'ty' } +}); +console.log(repo.completion); + +// "Use the other arguments for context" — the context-narrowed `branch` result the page quotes. +const branch = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-pr' }, + argument: { name: 'branch', value: 'rel' }, + context: { arguments: { repo: 'typescript-sdk' } } +}); +console.log(branch.completion); + +// "Use the other arguments for context" — without context the callback returns nothing. +const branchNoContext = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-pr' }, + argument: { name: 'branch', value: 'rel' } +}); +if (branchNoContext.completion.values.length !== 0) { + throw new Error(`completion.md claim failed: branch without context -> ${JSON.stringify(branchNoContext.completion.values)}`); +} + +// Proof for "Complete a resource template variable": the request targets the +// template by its URI pattern. +const templateRepo = await client.complete({ + ref: { type: 'ref/resource', uri: 'repo://{repo}/readme' }, + argument: { name: 'repo', value: 'py' } +}); +if (templateRepo.completion.values.join(',') !== 'python-sdk') { + throw new Error(`completion.md claim failed: template repo 'py' -> ${JSON.stringify(templateRepo.completion.values)}`); +} + +// "Try it from a client" — the call and output the page quotes. +//#region complete_client +const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: '' } +}); +console.log(result.completion); +//#endregion complete_client + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts new file mode 100644 index 0000000000..a37be55ccb --- /dev/null +++ b/examples/guides/servers/elicitation.examples.ts @@ -0,0 +1,176 @@ +/** + * Companion example for `docs-v2/servers/elicitation.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions drives every elicitation round over an in-memory transport pair and + * prints the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/elicitation.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerTool_elicitForm +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'feedback', version: '1.0.0' }); + +server.registerTool( + 'collect-feedback', + { + description: 'Ask the user how something went', + inputSchema: z.object({ topic: z.string() }) + }, + async ({ topic }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `How was ${topic}?`, + requestedSchema: { + type: 'object', + properties: { + rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 }, + comment: { type: 'string', title: 'Comment' } + }, + required: ['rating'] + } + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] }; + } +); +//#endregion registerTool_elicitForm + +// "Handle every action" — a confirmation form whose handler answers all three actions. +//#region registerTool_elicitActions +server.registerTool( + 'delete-dataset', + { + description: 'Delete a dataset after the user confirms', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete ${name}? This cannot be undone.`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } }, + required: ['confirm'] + } + }); + switch (result.action) { + case 'accept': + if (result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] }; + } + return { content: [{ type: 'text', text: `Deleted ${name}.` }] }; + case 'decline': + return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] }; + case 'cancel': + return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] }; + } + } +); +//#endregion registerTool_elicitActions + +// "Send the end user to a URL" — url mode hands the browser flow to the client. +//#region registerTool_elicitUrl +server.registerTool( + 'link-account', + { + description: 'Link a billing account through a hosted sign-in flow', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'url', + message: `Sign in to ${provider} to link your account`, + url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`, + elicitationId: crypto.randomUUID() + }); + if (result.action !== 'accept') { + return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] }; + } + return { content: [{ type: 'text', text: `Linked ${provider}.` }] }; + } +); +//#endregion registerTool_elicitUrl + +// --------------------------------------------------------------------------- +// Harness (not shown on the page beyond the two regions below). An in-memory +// client plays the end user; a real host renders UI instead. Imported +// dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +// The client-side handler the page shows once (the full client story lives in +// docs-v2/clients/server-requests.md). +//#region Client_elicitationHandler +const client = new Client( + { name: 'feedback-host', version: '1.0.0' }, + { capabilities: { elicitation: { form: {}, url: {} } } } +); + +client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer when they finish. + return { action: 'accept' }; + } + // Render request.params.requestedSchema as a form; return what the user typed. + return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } }; +}); +//#endregion Client_elicitationHandler + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Ask for input with a form" — the accept round trip the page quotes. +//#region callTool_collectFeedback +const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } }); +console.log(result.content); +//#endregion callTool_collectFeedback + +// "Send the end user to a URL" — the handler's url branch accepts. +const linked = await client.callTool({ name: 'link-account', arguments: { provider: 'github' } }); +console.log(linked.content); + +// "Handle every action" — the end user clicks Decline; the harness simulates +// that by swapping in a handler that declines every request. +client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' })); +const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } }); +console.log(declined.content); + +// "Require the elicitation capability" — the same form tool served to a client +// that never declared the elicitation capability. elicitInput throws before +// anything reaches the wire and the message becomes the tool result. +const plainServer = new McpServer({ name: 'feedback', version: '1.0.0' }); +plainServer.registerTool( + 'collect-feedback', + { description: 'Ask the user how something went', inputSchema: z.object({ topic: z.string() }) }, + async ({ topic }, ctx) => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `How was ${topic}?`, + requestedSchema: { type: 'object', properties: { rating: { type: 'number' } }, required: ['rating'] } + }); + return { content: [{ type: 'text', text: result.action }] }; + } +); +const plainClient = new Client({ name: 'no-elicitation-host', version: '1.0.0' }); +const [plainClientTransport, plainServerTransport] = InMemoryTransport.createLinkedPair(); +await plainServer.connect(plainServerTransport); +await plainClient.connect(plainClientTransport); +const failed = await plainClient.callTool({ name: 'collect-feedback', arguments: { topic: 'anything' } }); +console.log(failed); + +await plainClient.close(); +await plainServer.close(); +await client.close(); +await server.close(); diff --git a/examples/guides/servers/errors.examples.ts b/examples/guides/servers/errors.examples.ts new file mode 100644 index 0000000000..639286e2b3 --- /dev/null +++ b/examples/guides/servers/errors.examples.ts @@ -0,0 +1,132 @@ +/** + * Companion example for `docs-v2/servers/errors.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/errors.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerTool_isError +import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const notes = new Map([['welcome', 'Read tools.md first.']]); + +const server = new McpServer({ name: 'notes', version: '1.0.0' }); + +server.registerTool( + 'read-note', + { + description: 'Read a note by its id', + inputSchema: z.object({ id: z.string() }) + }, + async ({ id }) => { + const note = notes.get(id); + if (!note) { + return { + content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }], + isError: true + }; + } + return { content: [{ type: 'text', text: note }] }; + } +); +//#endregion registerTool_isError + +//#region registerTool_throw +server.registerTool( + 'delete-note', + { + description: 'Delete a note by its id', + inputSchema: z.object({ id: z.string() }) + }, + async ({ id }) => { + if (!notes.delete(id)) { + throw new Error(`Cannot delete "${id}": no such note`); + } + return { content: [{ type: 'text', text: `Deleted "${id}"` }] }; + } +); +//#endregion registerTool_throw + +//#region registerResource_protocolError +server.registerResource( + 'note', + new ResourceTemplate('note://{id}', { list: undefined }), + { description: 'A note by its id' }, + async (uri, { id }) => { + const noteId = String(id); + if (!/^[a-z]+$/.test(noteId)) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`); + } + const note = notes.get(noteId); + if (!note) throw new ResourceNotFoundError(uri.href); + return { contents: [{ uri: uri.href, text: note }] }; + } +); +//#endregion registerResource_protocolError + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output servers/errors.md quotes verbatim. Any MCP client behaves the same. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'errors-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Return a tool error with isError" — the result the page quotes. +//#region callTool_isError +const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } }); +console.log(missing); +//#endregion callTool_isError + +// "Let a thrown exception become a tool error" — the converted result the page quotes. +//#region callTool_throw +const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } }); +console.log(thrown); +//#endregion callTool_throw + +// "Throw a protocol error" — the JSON-RPC error the page quotes. +//#region readResource_protocolError +try { + await client.readResource({ uri: 'note://42' }); +} catch (error) { + const { code, message } = error as ProtocolError; + console.log({ code, message }); +} +//#endregion readResource_protocolError + +// "Use the typed error subclasses" — the structured data the page quotes. +//#region readResource_notFound +try { + await client.readResource({ uri: 'note://archived' }); +} catch (error) { + const { code, message, data } = error as ResourceNotFoundError; + console.log({ code, message, data }); +} +//#endregion readResource_notFound + +// Proof for the page's claim that a thrown ProtocolError inside a TOOL handler +// is still converted to an `isError: true` result, never a JSON-RPC error. +// Throws (non-zero exit) if the claim is false. +server.registerTool('always-protocol-error', { description: 'Throws a ProtocolError' }, async () => { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'unreachable as a protocol error'); +}); +const converted = await client.callTool({ name: 'always-protocol-error', arguments: {} }); +if (converted.isError !== true) { + throw new Error(`errors.md claim failed: a ProtocolError thrown from a tool handler was not converted: ${JSON.stringify(converted)}`); +} + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts new file mode 100644 index 0000000000..117181438e --- /dev/null +++ b/examples/guides/servers/input-required.examples.ts @@ -0,0 +1,249 @@ +/** + * Companion example for `docs/servers/input-required.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client with an elicitation handler and + * produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/input-required.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { CallToolResult, ElicitResult, InputRequiredResult, ProtocolError } from '@modelcontextprotocol/server'; +import { + acceptedContent, + createRequestStateCodec, + inputRequired, + inputResponse, + McpServer, + ProtocolErrorCode +} from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// "Protect `requestState` with the codec" — the page shows this block AFTER the +// handlers that use `stateCodec`, but module evaluation needs it first. +//#region requestState_codec +const stateCodec = createRequestStateCodec<{ step: string }>({ + key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share it across instances in a fleet + ttlSeconds: 600 +}); + +const server = new McpServer( + { name: 'releases', version: '1.0.0' }, + { requestState: { verify: stateCodec.verify } } +); +//#endregion requestState_codec + +// "Return `input_required` instead of pushing a request" +//#region registerTool_inputRequired +server.registerTool( + 'deploy', + { + description: 'Deploy after the operator confirms', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; + } +); +//#endregion registerTool_inputRequired + +// "Read the responses on re-entry" — the schema-aware overload + the declined branch. +//#region acceptedContent_schema +server.registerTool( + 'tag-release', + { + description: 'Tag a release after the operator confirms', + inputSchema: z.object({ tag: z.string() }) + }, + async ({ tag }, ctx): Promise<CallToolResult | InputRequiredResult> => { + const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm'); + if (view.kind === 'elicit' && view.action !== 'accept') { + return { content: [{ type: 'text', text: 'Tagging cancelled by the operator' }], isError: true }; + } + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() })); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Tag ${tag}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + return { content: [{ type: 'text', text: `Tagged ${tag}` }] }; + } +); +//#endregion acceptedContent_schema + +// "Write the handler write-once" — two missing inputs requested in one round. +//#region registerTool_writeOnce +server.registerTool( + 'provision', + { description: 'Provision a database', inputSchema: z.object({}) }, + async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { + const name = acceptedContent(ctx.mcpReq.inputResponses, 'name', z.object({ name: z.string() })); + const region = acceptedContent(ctx.mcpReq.inputResponses, 'region', z.object({ region: z.string() })); + if (name === undefined || region === undefined) { + return inputRequired({ + inputRequests: { + ...(name === undefined && { + name: inputRequired.elicit({ + message: 'Database name?', + requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } + }) + }), + ...(region === undefined && { + region: inputRequired.elicit({ + message: 'Which region?', + requestedSchema: { type: 'object', properties: { region: { type: 'string' } }, required: ['region'] } + }) + }) + } + }); + } + return { content: [{ type: 'text', text: `Provisioned ${name.name} in ${region.region}` }] }; + } +); +//#endregion registerTool_writeOnce + +// "Carry state across rounds with `requestState`" — two sequential rounds. +//#region requestState_mint +server.registerTool( + 'wipe-cache', + { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, + async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { + const state = ctx.mcpReq.requestState<{ step: string }>(); + + if (state?.step !== 'confirmed') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmed?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Really wipe the cache?', + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + }) + } + }); + } + // Mint only what the response above already proved: the operator confirmed. + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ + message: 'Which scope?', + requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + + const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); + return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + } +); +//#endregion requestState_mint + +/** "Pick the embedded request kind" — all four builders in one map (typecheck-only). */ +export function inputRequired_kinds(): InputRequiredResult { + //#region inputRequired_kinds + const next = inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Continue?', + requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } + }), + signin: inputRequired.elicitUrl({ message: 'Sign in to continue', url: 'https://example.com/auth' }), + summary: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the diff' } }], + maxTokens: 200 + }), + roots: inputRequired.listRoots() + } + }); + //#endregion inputRequired_kinds + return next; +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client with an elicitation +// handler drives the calls whose output servers/input-required.md quotes +// verbatim. Any MCP client behaves the same; the SDK's legacy shim fulfils +// these `input_required` returns over the 2025-era linked pair. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client( + { name: 'input-required-docs-harness', version: '1.0.0' }, + { capabilities: { elicitation: { form: {} } } } +); + +const answers: Record<string, Record<string, string | boolean>> = { + 'Deploy to prod?': { confirm: true }, + 'Database name?': { name: 'analytics' }, + 'Which region?': { region: 'eu-west-1' }, + 'Really wipe the cache?': { confirm: true }, + 'Which scope?': { scope: 'sessions' } +}; + +const acceptHandler = async (request: { params: { message: string } }): Promise<ElicitResult> => { + console.log('[client] elicitation/create →', request.params.message); + const content = answers[request.params.message]; + return content === undefined ? { action: 'decline' } : { action: 'accept', content }; +}; +client.setRequestHandler('elicitation/create', acceptHandler); + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Return `input_required` instead of pushing a request" — the round trip the page quotes. +console.log(await client.callTool({ name: 'deploy', arguments: { env: 'prod' } })); + +// "Read the responses on re-entry" — the declined branch the page quotes. +client.setRequestHandler('elicitation/create', async (request): Promise<ElicitResult> => { + console.log('[client] elicitation/create →', request.params.message); + return { action: 'decline' }; +}); +console.log(await client.callTool({ name: 'tag-release', arguments: { tag: 'v2.1.0' } })); +client.setRequestHandler('elicitation/create', acceptHandler); + +// "Write the handler write-once" — both missing inputs requested in one round. +console.log(await client.callTool({ name: 'provision', arguments: {} })); + +// "Carry state across rounds with `requestState`" — two sequential rounds. +console.log(await client.callTool({ name: 'wipe-cache', arguments: {} })); + +// "Protect `requestState` with the codec" — tampered state answers -32602. +// Matched by `code`, not `instanceof` (see docs/servers/errors.md): `instanceof` +// fails across separately bundled copies of the SDK. +try { + await client.request({ + method: 'tools/call', + params: { name: 'wipe-cache', arguments: {}, requestState: 'tampered' } + }); +} catch (error) { + const { code, message } = error as ProtocolError; + if (code !== ProtocolErrorCode.InvalidParams) throw error; + console.log(`${code} ${message}`); +} + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/logging-progress-cancellation.examples.ts b/examples/guides/servers/logging-progress-cancellation.examples.ts new file mode 100644 index 0000000000..d0750e51a1 --- /dev/null +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -0,0 +1,168 @@ +/** + * Companion example for `docs/servers/logging-progress-cancellation.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * server regions connects an in-memory client and produces the output the + * page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/logging-progress-cancellation.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// "Log to the client" — the logging capability is declared at construction. +//#region logging_capability +const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } }); +//#endregion logging_capability + +// "Report progress from a handler" — the page's lead block. +//#region registerTool_progress +server.registerTool( + 'process-files', + { + description: 'Process files with progress updates', + inputSchema: z.object({ files: z.array(z.string()) }) + }, + async ({ files }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + for (let i = 0; i < files.length; i++) { + // ... process files[i] ... + + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` } + }); + } + } + + return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; + } +); +//#endregion registerTool_progress + +// "Log to the client" — `ctx.mcpReq.log(level, data)` inside a handler. +//#region registerTool_logging +server.registerTool( + 'validate-records', + { + description: 'Validate records before import', + inputSchema: z.object({ records: z.array(z.string()) }) + }, + async ({ records }, ctx) => { + await ctx.mcpReq.log('info', `Validating ${records.length} records`); + const invalid = records.filter(record => !record.endsWith('.csv')); + if (invalid.length > 0) { + await ctx.mcpReq.log('warning', { invalid }); + } + return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] }; + } +); +//#endregion registerTool_logging + +// "Stop work when the request is cancelled" — check `ctx.mcpReq.signal`. +//#region registerTool_abort +server.registerTool( + 'scan-archive', + { + description: 'Scan every page of the archive', + inputSchema: z.object({ pages: z.number().int() }) + }, + async ({ pages }, ctx) => { + let scanned = 0; + for (let page = 0; page < pages; page++) { + if (ctx.mcpReq.signal.aborted) { + console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`); + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ... + scanned++; + } + return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] }; + } +); +//#endregion registerTool_abort + +// "Pass the signal to your own I/O" — registered for the page, never called by +// the harness (it would hit the network). +//#region registerTool_forwardSignal +const SOURCE_URLS = { + readme: 'https://example.com/sources/readme.md', + changelog: 'https://example.com/sources/changelog.md' +}; + +server.registerTool( + 'fetch-source', + { + description: 'Download one of the known source files', + inputSchema: z.object({ source: z.enum(['readme', 'changelog']) }) + }, + async ({ source }, ctx) => { + const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal }); + return { content: [{ type: 'text', text: await response.text() }] }; + } +); +//#endregion registerTool_forwardSignal + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output the page quotes verbatim. Any MCP client behaves the same. +// Imported dynamically so the page's server regions stay self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'lpc-docs-harness', version: '1.0.0' }); + +// "Log to the client" — the client surfaces `notifications/message`. +//#region setNotificationHandler_message +client.setNotificationHandler('notifications/message', notification => { + console.log(notification.params.level, notification.params.data); +}); +//#endregion setNotificationHandler_message + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Report progress from a handler" — `onprogress` opts the call in. +//#region callTool_onprogress +const result = await client.callTool( + { name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } }, + { onprogress: update => console.log(update) } +); +console.log(result.content); +//#endregion callTool_onprogress + +// "Skip progress when the client did not ask" — same call, no `onprogress`. +//#region callTool_noProgress +const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } }); +console.log(quiet.content); +//#endregion callTool_noProgress + +// "Log to the client" — both `log` calls land before the result. +const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } }); +console.log(validated.content); + +// "Stop work when the request is cancelled". +//#region callTool_abort +const controller = new AbortController(); +const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal }); + +// the end user clicks Stop while the scan runs +setTimeout(() => controller.abort('the end user clicked Stop'), 5); + +await scan.catch(error => console.log(String(error))); +//#endregion callTool_abort + +// Give the cancelled handler time to observe the abort and stop. +await new Promise(resolve => setTimeout(resolve, 250)); + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/notifications.examples.ts b/examples/guides/servers/notifications.examples.ts new file mode 100644 index 0000000000..765d36c8c5 --- /dev/null +++ b/examples/guides/servers/notifications.examples.ts @@ -0,0 +1,119 @@ +/** + * Companion example for `docs/servers/notifications.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * lead region connects an in-memory client whose notification log the page + * quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/notifications.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region notifications_server +import { McpServer } from '@modelcontextprotocol/server'; + +const jobs = ['nightly-backup']; + +const server = new McpServer({ name: 'jobs', version: '1.0.0' }); + +server.registerTool('list-jobs', { description: 'List the configured jobs' }, async () => ({ + content: [{ type: 'text', text: jobs.join('\n') }] +})); +//#endregion notifications_server + +// Symbols the later sections use, imported here so the page's lead block shows +// only what it teaches. A real server imports everything in one statement. +import { createMcpHandler, InMemoryServerEventBus, Server } from '@modelcontextprotocol/server'; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client connected to the server +// above logs every list-changed notification it receives — the output the +// page quotes verbatim. Any MCP client behaves the same. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'notifications-docs-harness', version: '1.0.0' }); +for (const method of [ + 'notifications/tools/list_changed', + 'notifications/prompts/list_changed', + 'notifications/resources/list_changed' +] as const) { + client.setNotificationHandler(method, async () => console.log(method)); +} + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Send a list-changed notification" — the explicit push the page quotes. +//#region sendToolListChanged_basic +server.sendToolListChanged(); +//#endregion sendToolListChanged_basic + +// A round-trip so the notification above flushes before the next region runs; +// the page quotes its single output line on its own. +await client.listTools(); + +// "Let registration changes notify for you" — three more sends, none explicit. +//#region registeredTool_update +const report = server.registerTool('run-report', { description: 'Run the weekly report' }, async () => ({ + content: [{ type: 'text', text: 'report queued' }] +})); + +report.update({ description: 'Run the weekly report and email it' }); +report.disable(); +//#endregion registeredTool_update + +await client.listTools(); +await client.close(); +await server.close(); + +// --------------------------------------------------------------------------- +// "Advertise the listChanged capability" — the low-level Server needs it +// declared up front. Constructed only; nothing on the page quotes output here. +// --------------------------------------------------------------------------- + +//#region Server_listChanged +const lowLevel = new Server({ name: 'jobs', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); +//#endregion Server_listChanged +void lowLevel; + +// --------------------------------------------------------------------------- +// "Publish a resource update through the handler". The factory the page refers +// to: a fresh McpServer per request, advertising `resources.subscribe` so the +// entry honors per-resource subscriptions. +// --------------------------------------------------------------------------- + +function buildJobsServer(): McpServer { + const app = new McpServer({ name: 'jobs', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + app.registerResource('config', 'config://app', { mimeType: 'application/json' }, async uri => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ jobs }) }] + })); + return app; +} + +// Runs as written: with no subscription stream open the publish is a no-op, +// and `createMcpHandler` binds no port. +//#region handler_notifyResourceUpdated +const handler = createMcpHandler(() => buildJobsServer()); + +// After config://app changes: +handler.notify.resourceUpdated('config://app'); +//#endregion handler_notifyResourceUpdated + +await handler.close(); + +// "Pick an event bus for multi-process deployments" — typecheck-only wrapper. +function createMcpHandler_bus() { + //#region createMcpHandler_bus + const bus = new InMemoryServerEventBus(); + + const shared = createMcpHandler(() => buildJobsServer(), { bus }); + //#endregion createMcpHandler_bus + return shared; +} +void createMcpHandler_bus; diff --git a/examples/guides/servers/prompts.examples.ts b/examples/guides/servers/prompts.examples.ts new file mode 100644 index 0000000000..118aeba8b8 --- /dev/null +++ b/examples/guides/servers/prompts.examples.ts @@ -0,0 +1,163 @@ +/** + * Companion example for `docs-v2/servers/prompts.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/prompts.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerPrompt_review +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'review', version: '1.0.0' }); + +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string().describe('The code to review') + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this code:\n\n${code}` } + } + ] + }) +); +//#endregion registerPrompt_review + +//#region registerPrompt_messages +server.registerPrompt( + 'explain-error', + { + description: 'Explain a compiler error and suggest the smallest fix', + argsSchema: z.object({ error: z.string() }) + }, + ({ error }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Explain this compiler error:\n\n${error}` } + }, + { + role: 'assistant' as const, + content: { type: 'text' as const, text: 'The one-line cause:' } + } + ] + }) +); +//#endregion registerPrompt_messages + +//#region registerPrompt_embedResource +const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.'; + +server.registerResource('style-guide', 'doc://style-guide', { mimeType: 'text/markdown' }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/markdown', text: styleGuide }] +})); + +server.registerPrompt( + 'review-against-style', + { + description: 'Review code against the team style guide', + argsSchema: z.object({ code: z.string() }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'resource' as const, + resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: styleGuide } + } + }, + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this code against the style guide:\n\n${code}` } + } + ] + }) +); +//#endregion registerPrompt_embedResource + +//#region registerPrompt_completable +import { completable } from '@modelcontextprotocol/server'; + +server.registerPrompt( + 'translate', + { + description: 'Translate a snippet into another language', + argsSchema: z.object({ + language: completable(z.string(), value => + ['typescript', 'python', 'rust', 'go'].filter(language => language.startsWith(value)) + ), + code: z.string() + }) + }, + ({ language, code }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Translate to ${language}:\n\n${code}` } }] + }) +); +//#endregion registerPrompt_completable + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output servers/prompts.md quotes verbatim. Any MCP client behaves the same. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'prompts-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Register a prompt" — the prose claims `prompts/list` advertises `review-code` +// with one required `code` argument carrying the `.describe()` string. Throws +// (non-zero exit) if the claim is false. +const { prompts } = await client.listPrompts(); +const reviewPrompt = prompts.find(prompt => prompt.name === 'review-code'); +const codeArgument = reviewPrompt?.arguments?.find(argument => argument.name === 'code'); +if (codeArgument?.required !== true || codeArgument.description !== 'The code to review') { + throw new Error(`prompts.md claim failed: review-code argument is ${JSON.stringify(reviewPrompt?.arguments)}`); +} + +// "Register a prompt" — the filled-in messages the page quotes. +//#region getPrompt_review +const result = await client.getPrompt({ name: 'review-code', arguments: { code: 'let x = 1' } }); +console.log(result.messages); +//#endregion getPrompt_review + +// "Validate the arguments with the schema" — the rejection the page quotes. +//#region getPrompt_invalid +import type { ProtocolError } from '@modelcontextprotocol/client'; + +try { + await client.getPrompt({ name: 'review-code', arguments: {} }); +} catch (error) { + const { code, message } = error as ProtocolError; + console.log(code, message); +} +//#endregion getPrompt_invalid + +// "Embed a resource in a message" — the embedded-resource message the page quotes. +const review = await client.getPrompt({ + name: 'review-against-style', + arguments: { code: 'let n = 1' } +}); +console.log(review.messages[0]); + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/resources.examples.ts b/examples/guides/servers/resources.examples.ts new file mode 100644 index 0000000000..57d5cbe3b7 --- /dev/null +++ b/examples/guides/servers/resources.examples.ts @@ -0,0 +1,157 @@ +/** + * Companion example for `docs/servers/resources.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/resources.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerResource_static +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'workspace', version: '1.0.0' }); + +server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }] + }) +); +//#endregion registerResource_static + +//#region registerResource_report +// A 1x1 PNG; a production server reads these bytes from disk or object storage. +const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='; + +server.registerResource( + 'report', + 'report://latest', + { + title: 'Latest usage report', + description: 'Weekly usage summary with a rendered chart', + mimeType: 'text/markdown' + }, + async uri => ({ + contents: [ + { uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' }, + { uri: uri.href, mimeType: 'image/png', blob: chartPng } + ] + }) +); +//#endregion registerResource_report + +//#region registerResource_template +server.registerResource( + 'user-profile', + new ResourceTemplate('users://{userId}/profile', { list: undefined }), + { + title: 'User Profile', + description: 'Profile data for one user', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }] + }) +); +//#endregion registerResource_template + +//#region registerResource_list +server.registerResource( + 'team-roster', + new ResourceTemplate('teams://{teamId}/roster', { + list: async () => ({ + resources: [ + { uri: 'teams://core/roster', name: 'Core team roster' }, + { uri: 'teams://growth/roster', name: 'Growth team roster' } + ] + }) + }), + { + description: 'Members of one team', + mimeType: 'text/plain' + }, + async (uri, { teamId }) => ({ + contents: [{ uri: uri.href, text: `Members of team ${teamId}` }] + }) +); +//#endregion registerResource_list + +//#region registerResource_file +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +const DOCS_ROOT = path.resolve('./docs'); + +server.registerResource( + 'doc', + new ResourceTemplate('docs://{file}', { list: undefined }), + { + description: 'A markdown page from the docs directory', + mimeType: 'text/markdown' + }, + async (uri, { file }) => { + const requested = await realpath(path.join(DOCS_ROOT, String(file))); + if (!requested.startsWith(DOCS_ROOT + path.sep)) { + throw new Error(`${uri.href} resolves outside the docs root`); + } + return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] }; + } +); +//#endregion registerResource_file + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output servers/resources.md quotes verbatim. Any MCP client behaves the same. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'resources-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Return the contents from the read callback" — the two-item result the page quotes. +//#region readResource_report +const { contents } = await client.readResource({ uri: 'report://latest' }); +console.log(contents); +//#endregion readResource_report + +// "Add a resource template" — the parameterized read the page quotes. +//#region readResource_template +const profile = await client.readResource({ uri: 'users://7/profile' }); +console.log(profile.contents); +//#endregion readResource_template + +// "List the template's instances" — the merged list the page quotes. Must contain +// the static URIs and the team:// instances, and no users:// URI. +//#region listResources +const { resources } = await client.listResources(); +console.log(resources.map(resource => resource.uri)); +//#endregion listResources + +const uris = resources.map(resource => resource.uri); +if (uris.some(uri => uri.startsWith('users://')) || !uris.includes('teams://core/roster')) { + throw new Error(`resources.md list claim failed: ${JSON.stringify(uris)}`); +} + +// "Tell clients when a resource changes" — explicit list_changed. +//#region sendResourceListChanged +server.sendResourceListChanged(); +//#endregion sendResourceListChanged + +await client.close(); +await server.close(); diff --git a/examples/guides/servers/sampling.examples.ts b/examples/guides/servers/sampling.examples.ts new file mode 100644 index 0000000000..2d54c2d975 --- /dev/null +++ b/examples/guides/servers/sampling.examples.ts @@ -0,0 +1,89 @@ +/** + * Companion example for `docs-v2/servers/sampling.md`. + * + * The `ts` fence on that page is synced from the `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * region connects in-memory clients whose sampling handlers stand in for a + * host LLM, and produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/servers/sampling.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// The page's one code block. Wrapped in a function so the harness can stand up +// two independent server instances (one per client) without duplicating it. +function registerTool_sampling(server: McpServer) { + //#region registerTool_sampling + server.registerTool( + 'summarize', + { + description: 'Summarize text using the client LLM', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }, ctx) => { + const response = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: `Summarize in one sentence:\n\n${text}` } }], + maxTokens: 500 + }); + return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; + } + ); + //#endregion registerTool_sampling +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client declares the sampling +// capability and answers `sampling/createMessage` the way a host would — by +// running the prompt through its model. Here the "model" is canned so the run +// is deterministic; any MCP client behaves the same over stdio or HTTP. +// --------------------------------------------------------------------------- + +const server = new McpServer({ name: 'summarizer', version: '1.0.0' }); +registerTool_sampling(server); + +const client = new Client({ name: 'sampling-docs-harness', version: '1.0.0' }, { capabilities: { sampling: {} } }); + +client.setRequestHandler('sampling/createMessage', async () => { + return { + model: 'host-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Sampling lets a tool ask the client for a completion.' } + }; +}); + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Read the model's reply" — the result the page quotes verbatim. +const result = await client.callTool({ + name: 'summarize', + arguments: { text: 'Sampling is a server-to-client request for an LLM completion...' } +}); +console.log(result.content); + +await client.close(); +await server.close(); + +// "Require the sampling capability" — a strict server and a client that never +// declared `sampling`. The SDK rejects the request before it reaches the wire; +// the page quotes the resulting tool error verbatim. +const bareServer = new McpServer({ name: 'summarizer', version: '1.0.0' }, { enforceStrictCapabilities: true }); +registerTool_sampling(bareServer); + +const bare = new Client({ name: 'no-sampling-harness', version: '1.0.0' }); +const [bareClientTransport, bareServerTransport] = InMemoryTransport.createLinkedPair(); +await bareServer.connect(bareServerTransport); +await bare.connect(bareClientTransport); + +const rejected = await bare.callTool({ name: 'summarize', arguments: { text: 'anything' } }); +console.log(rejected); + +await bare.close(); +await bareServer.close(); From 460f16e31bca37abe835f6679dafdbe73e717284 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:01 +0000 Subject: [PATCH 07/27] docs: write the clients guide pages --- docs/clients/caching.md | 102 ++++++--- docs/clients/calling.md | 174 ++++++++++++--- docs/clients/connect.md | 105 ++++++--- docs/clients/machine-auth.md | 104 ++++++--- docs/clients/middleware.md | 131 +++++++++--- docs/clients/oauth.md | 172 ++++++++++++--- docs/clients/roots.md | 94 +++++--- docs/clients/server-requests.md | 118 +++++++--- docs/clients/subscriptions.md | 135 +++++++++--- examples/guides/clients/caching.examples.ts | 148 +++++++++++++ examples/guides/clients/calling.examples.ts | 202 ++++++++++++++++++ examples/guides/clients/connect.examples.ts | 116 ++++++++++ .../guides/clients/machine-auth.examples.ts | 89 ++++++++ .../guides/clients/middleware.examples.ts | 138 ++++++++++++ examples/guides/clients/oauth.examples.ts | 181 ++++++++++++++++ examples/guides/clients/roots.examples.ts | 70 ++++++ .../clients/server-requests.examples.ts | 176 +++++++++++++++ .../guides/clients/subscriptions.examples.ts | 199 +++++++++++++++++ 18 files changed, 2169 insertions(+), 285 deletions(-) create mode 100644 examples/guides/clients/caching.examples.ts create mode 100644 examples/guides/clients/calling.examples.ts create mode 100644 examples/guides/clients/connect.examples.ts create mode 100644 examples/guides/clients/machine-auth.examples.ts create mode 100644 examples/guides/clients/middleware.examples.ts create mode 100644 examples/guides/clients/oauth.examples.ts create mode 100644 examples/guides/clients/roots.examples.ts create mode 100644 examples/guides/clients/server-requests.examples.ts create mode 100644 examples/guides/clients/subscriptions.examples.ts diff --git a/docs/clients/caching.md b/docs/clients/caching.md index bd83727c6a..d15871bdb8 100644 --- a/docs/clients/caching.md +++ b/docs/clients/caching.md @@ -1,21 +1,15 @@ --- -status: scaffold shape: how-to --- # Cache responses -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Client store + server cache hints, presented as one feature. -teaches: CacheableRequestOptions.cacheMode, ClientOptions.responseCacheStore, ClientOptions.cachePartition, ClientOptions.defaultCacheTtlMs, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS, server-side ttlMs/cacheScope hints (SEP-2549) -source: mined from docs/client.md "Response caching (2026-07-28 draft)"; server hint side mined from docs/server.md / packages/server/src — ONE feature, both halves on this page ---> +Caching is one feature with two halves: the server marks a result with a freshness hint, and the client's **response cache** serves it locally while it stays fresh. ## Let the cache work -<!-- teaches: the zero-config path — cacheable verbs honour the server's ttlMs automatically; cacheMode overrides per call | salvage: docs/client.md "Response caching (2026-07-28 draft)" --> +The cacheable verbs check the cache before they send. A still-fresh entry comes back without a round trip; `cacheMode` overrides the disposition per call. -```ts -// draft - API verified against packages/client/src/client/client.ts (listTools(params?, options?: CacheableRequestOptions), readResource) and packages/client/src/client/responseCache.ts (InMemoryResponseCacheStore, MAX_CACHE_TTL_MS) +```ts source="../../examples/guides/clients/caching.examples.ts#responseCache_use" const tools = await client.listTools(); // network, then cached for the server's ttlMs const again = await client.listTools(); // served from cache while still fresh @@ -23,41 +17,91 @@ await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch a await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write ``` -<!-- result: the second listTools() makes no network round trip; quote the companion example's timing/log output. --> +`client` is connected to the server in the next section — served in-process by `createMcpHandler`, the wiring [Test a server](../testing.md) shows — and the harness counts every request that reaches it. After all four calls, only the first `listTools()` and the `'refresh'` crossed the wire: + +``` +tools/list requests that reached the server: 2 +resources/read requests that reached the server: 1 +``` + +Nothing on the client opts in: every `Client` holds a response cache, and the server's hint decides what it may serve. ## Have the server send the hint -<!-- teaches: the other half of the feature — the server attaches ttlMs / cacheScope to cacheable results (SEP-2549); without a hint nothing is served from cache | salvage: net-new (server cache-hint config in packages/server/src); cross-reference, not duplicated prose --> -<!-- code: the server-side registration option that sets ttlMs / cacheScope on a list result --> +`ServerOptions.cacheHints` attaches a `ttlMs` and a `cacheScope` to each cacheable result it names (SEP-2549) — without one, the SDK emits `ttlMs: 0` and no client ever serves that result from cache. + +```ts source="../../examples/guides/clients/caching.examples.ts#cacheHints_server" +const server = new McpServer( + { name: 'catalog', version: '1.0.0' }, + { + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 5_000, cacheScope: 'private' } + } + } +); +``` + +`registerResource` also takes a per-resource `cacheHint`; it wins, field by field, over the `resources/read` entry here for that resource's read results. Mark a result `cacheScope: 'public'` only when it is identical for every caller — anything derived from the caller's authorization context stays `'private'`, the default. + +::: tip +A server cannot pin an entry forever: the client caps any `ttlMs` at 24 hours (`MAX_CACHE_TTL_MS`). +::: ## Choose a cache mode per call -<!-- teaches: cacheMode 'refresh' vs 'bypass' vs default; which verbs are cacheable (tools/list, prompts/list, resources/list, resources/templates/list, resources/read, server/discover) | salvage: docs/client.md "Response caching" --> -<!-- code: none — placeholder comment naming the three modes; 'bypass' leaves the cache byte-untouched --> +`cacheMode` on `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` — the cacheable verbs — takes one of three values. `'use'`, the default, serves a still-fresh entry and otherwise fetches and stores. `'refresh'` always fetches and stores the fresh result. + +`'bypass'` fetches without reading or writing: it leaves the cache byte-untouched, including the `tools/list` entry the SDK itself reads for output validation when you [call tools](./calling.md). ## Bring your own store -<!-- teaches: ClientOptions.responseCacheStore, the ResponseCacheStore interface, InMemoryResponseCacheStore default | salvage: docs/client.md "Response caching" (ClientOptions bullets) --> -<!-- code: new Client(info, { responseCacheStore: myStore }) --> +`responseCacheStore` swaps the backing store; the default is a fresh `InMemoryResponseCacheStore` per client, holding at most 512 `resources/read` entries. + +```ts source="../../examples/guides/clients/caching.examples.ts#responseCacheStore_shared" +const store = new InMemoryResponseCacheStore({ maxEntries: 2048 }); + +const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store }); +``` + +Every method on the `ResponseCacheStore` interface may return a promise, so a Redis-style store implements the same five methods. Entries are keyed by connected-server identity, so one store can back many clients: connections to different servers never collide. ## Partition the store per user -<!-- teaches: ClientOptions.cachePartition isolating 'private'-scoped entries when one store serves several principals | salvage: docs/client.md "Response caching" (IMPORTANT callout) --> -<!-- code: new Client(info, { responseCacheStore: shared, cachePartition: userId }) --> -<!-- aside: ::: warning — a shared store without cachePartition can serve one user's private resource bodies to another --> +When one shared store serves several principals, set `cachePartition` to a stable identity of the authorization context — the auth subject, for example. + +```ts source="../../examples/guides/clients/caching.examples.ts#cachePartition_perUser" +const client = new Client( + { name: 'gateway', version: '1.0.0' }, + { responseCacheStore: sharedStore, cachePartition: userId } +); +``` + +`'private'`-scoped entries are stored under that partition and never read across it; `'public'`-scoped entries stay shared within the server's namespace. + +::: warning +A shared store without `cachePartition` can serve one user's `'private'`-scoped resource bodies to another. Set it whenever the store outlives a single principal. +::: ## Cache against servers that send no hints -<!-- teaches: ClientOptions.defaultCacheTtlMs; eviction on list_changed / resources/updated notifications | salvage: docs/client.md "Response caching" (defaultCacheTtlMs bullet + eviction paragraph) --> -<!-- code: new Client(info, { defaultCacheTtlMs: 60_000 }) --> -<!-- aside: ::: info — one-line era cross-link to /protocol-versions: cache hints are a 2026-07-28 surface; against 2025-era servers defaultCacheTtlMs is the only lever --> +`defaultCacheTtlMs` is the TTL applied when a cacheable result arrives without a `ttlMs`. The default is `0`: a result with no hint is never served from cache. + +```ts source="../../examples/guides/clients/caching.examples.ts#defaultCacheTtlMs_optIn" +const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 }); +``` + +Fresh or not, the cache also evicts itself when the server signals a change: a `list_changed` notification drops the matching list entries, and `notifications/resources/updated` drops the cached body for that URI — see [Subscriptions](./subscriptions.md). + +::: info +Cache hints are a 2026-07-28 surface — see [Protocol versions](../protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever. +::: ## Recap -<!-- the claims this page will prove: -- Caching is one feature with two halves: the server sends ttlMs/cacheScope, the client honours it — neither half does anything alone (by default). -- The cacheable verbs serve a still-fresh result without a round trip; cacheMode overrides per call. -- responseCacheStore swaps the backing store; cachePartition is mandatory when that store is shared across principals. -- defaultCacheTtlMs is the opt-in for servers that send no hints. -- list_changed and resources/updated notifications evict automatically. ---> +- Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone. +- `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` serve a still-fresh result without a round trip; `cacheMode` overrides per call. +- A result without a hint carries `ttlMs: 0` and is never served from cache; the client caps every `ttlMs` at 24 hours. +- `responseCacheStore` swaps the backing store; `cachePartition` is mandatory when that store serves several principals. +- `defaultCacheTtlMs` opts in to caching against servers that send no hints. +- `list_changed` and `notifications/resources/updated` evict matching entries automatically. diff --git a/docs/clients/calling.md b/docs/clients/calling.md index 41602cbe7b..c4ed292912 100644 --- a/docs/clients/calling.md +++ b/docs/clients/calling.md @@ -1,69 +1,175 @@ --- -status: scaffold shape: how-to --- # Call tools, read resources, get prompts -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: The verbs; auto-aggregating pagination. -teaches: Client.listTools, Client.callTool, Client.listResources, Client.readResource, Client.listResourceTemplates, Client.listPrompts, Client.getPrompt, Client.complete, ClientOptions.listMaxPages, CallToolRequestOptions.onprogress -source: mined from docs/client.md "Tools", "Resources", "Prompts", "Completions", "Tracking progress" ---> +Every block on this page runs on a connected `Client` — [Connect to a server](./connect.md) shows the wiring — here paired in memory with an `orders` server that registers three tools, a resource, and a prompt. ## List the tools and call one -<!-- teaches: Client.listTools, Client.callTool | salvage: docs/client.md "Tools" --> +`listTools` returns the tools the server advertises; `callTool` invokes one by name with a plain `arguments` object. -```ts -// draft - API verified against packages/client/src/client/client.ts (listTools: ListToolsResult, callTool: CallToolResult) +```ts source="../../examples/guides/clients/calling.examples.ts#listTools_callTool" const { tools } = await client.listTools(); +console.log(tools.map(tool => tool.name)); -const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 }, -}); +const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } }); console.log(result.content); ``` -<!-- result: result.content is the model-facing content array; quote the real printed output from the companion example. --> +`result.content` is the content array the tool handler returned, unchanged: + +``` +[ 'lookup-order', 'order-total', 'export-orders' ] +[ { type: 'text', text: 'A-1041: 3 items, shipped' } ] +``` + +::: tip +A failed tool call is still a result: check `isError` on it before trusting `content`. Arguments the input schema rejects come back the same way. Only protocol-level failures — unknown tool, timeout — throw. +::: ## Let the SDK walk the pages -<!-- teaches: auto-aggregating pagination, ClientOptions.listMaxPages, LIST_PAGINATION_EXCEEDED | salvage: docs/client.md "Tools" (aggregate-walk paragraph) --> -<!-- code: listTools() with no cursor returns the COMPLETE list; { cursor } opts into per-page control; listMaxPages caps the walk --> -<!-- aside: ::: warning — a server whose pagination never terminates rejects with SdkError LIST_PAGINATION_EXCEEDED --> +That `listTools()` already walked every page: when a server splits its list, the SDK follows `nextCursor` page by page and returns one aggregated list with `nextCursor: undefined`. `listPrompts()`, `listResources()`, and `listResourceTemplates()` aggregate the same way. + +Pass a `cursor` — a page's `nextCursor` your application held on to — and `listTools` returns exactly that page, raw. + +```ts source="../../examples/guides/clients/calling.examples.ts#listTools_onePage" +const page = await client.listTools({ cursor: heldCursor }); +console.log(page.tools.map(tool => tool.name), page.nextCursor); +``` + +The `orders` server hands out its three tools two per page, and `heldCursor` names the second page — one tool, nothing left to follow: + +``` +[ 'export-orders' ] undefined +``` + +::: warning +`ClientOptions.listMaxPages` (default 64) caps the aggregate walk; a server whose pagination never terminates rejects the call with an `SdkError` whose code is `LIST_PAGINATION_EXCEEDED`. `listMaxPages: 0` removes the cap. Explicit-`cursor` calls are never capped. +::: ## Read structured output -<!-- teaches: CallToolResult.structuredContent | salvage: docs/client.md "Tools" (structuredContent block) --> -<!-- code: check result.structuredContent !== undefined and narrow the unknown before use --> +A tool that declares an `outputSchema` returns `structuredContent` next to `content`. It is typed `unknown` — check that it is present and narrow it before use. + +```ts source="../../examples/guides/clients/calling.examples.ts#callTool_structured" +const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } }); + +const total: unknown = details.structuredContent; +if (typeof total === 'object' && total !== null && 'currency' in total) { + console.log(total); +} +``` + +`order-total` declares `{ id, total, currency }`, and that is what comes back: + +``` +{ id: 'A-1041', total: 61.5, currency: 'EUR' } +``` + +When an earlier `listTools()` gave the client the tool's `outputSchema`, `callTool` validates `structuredContent` against it and rejects a result that does not match. + +The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md). ## Read a resource -<!-- teaches: Client.listResources, Client.readResource, Client.listResourceTemplates | salvage: docs/client.md "Resources" --> -<!-- code: listResources() then readResource({ uri }) iterating contents --> +`listResources` names what the server exposes; `readResource` fetches one URI. + +```ts source="../../examples/guides/clients/calling.examples.ts#readResource_basic" +const { resources } = await client.listResources(); +console.log(resources.map(resource => resource.uri)); + +const { contents } = await client.readResource({ uri: 'orders://recent' }); +console.log(contents[0]); +``` + +Each item in `contents` carries the `uri`, a `mimeType`, and either `text` or a base64 `blob`: + +``` +[ 'orders://recent' ] +{ + uri: 'orders://recent', + mimeType: 'application/json', + text: '["A-1041","A-1042"]' +} +``` + +For parameterized URIs, `listResourceTemplates()` returns the server's URI templates — expand one and pass the resulting URI to `readResource`. To react when a resource changes instead of re-reading it on a timer, see [Subscriptions](./subscriptions.md). ## Get a prompt -<!-- teaches: Client.listPrompts, Client.getPrompt | salvage: docs/client.md "Prompts" --> -<!-- code: listPrompts() then getPrompt({ name, arguments }) returning messages --> +`listPrompts` advertises each prompt with its arguments; `getPrompt` fills them in and returns `messages` ready to send to a model. + +```ts source="../../examples/guides/clients/calling.examples.ts#getPrompt_basic" +const { prompts } = await client.listPrompts(); +console.log(prompts.map(prompt => prompt.name)); + +const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } }); +console.log(prompt.messages); +``` + +The server's template comes back with both arguments substituted: + +``` +[ 'summarize-order' ] +[ + { + role: 'user', + content: { + type: 'text', + text: 'Write a terse status update for order A-1041.' + } + } +] +``` ## Autocomplete an argument -<!-- teaches: Client.complete | salvage: docs/client.md "Completions" --> -<!-- code: client.complete({ ref, argument }) returning completion.values --> +`complete` asks the server for suggestions while the user types an argument: `ref` names the prompt (or resource template) and `argument` carries the partial value. + +```ts source="../../examples/guides/clients/calling.examples.ts#complete_tone" +const { completion } = await client.complete({ + ref: { type: 'ref/prompt', name: 'summarize-order' }, + argument: { name: 'tone', value: 'f' } +}); +console.log(completion.values); +``` + +The server matches `f` against the values it accepts for `tone`: + +``` +[ 'formal', 'friendly' ] +``` ## Track progress on a long call -<!-- teaches: CallToolRequestOptions.onprogress, resetTimeoutOnProgress, maxTotalTimeout | salvage: docs/client.md "Tracking progress" --> -<!-- code: callTool(params, { onprogress, resetTimeoutOnProgress: true, maxTotalTimeout }) --> +Every verb takes request options as a second argument. `onprogress` receives each `notifications/progress` the server emits for this call; `resetTimeoutOnProgress` restarts the request timeout on every update and `maxTotalTimeout` is the absolute cap. + +```ts source="../../examples/guides/clients/calling.examples.ts#callTool_progress" +const exported = await client.callTool( + { name: 'export-orders', arguments: { format: 'csv' } }, + { + onprogress: update => console.log(update), + resetTimeoutOnProgress: true, + maxTotalTimeout: 600_000 + } +); +console.log(exported.content); +``` + +The updates stream in while the call is still pending; the return type does not change: + +``` +{ progress: 1, total: 2, message: 'exported A-1041' } +{ progress: 2, total: 2, message: 'exported A-1042' } +[ { type: 'text', text: '2 orders exported as csv' } ] +``` ## Recap -<!-- the claims this page will prove: -- listTools/listResources/listResourceTemplates/listPrompts auto-aggregate every page; pass { cursor } only when you want per-page control. -- callTool returns content for the model and optionally structuredContent for your application. -- readResource and getPrompt mirror the same list-then-fetch shape. -- complete() autocompletes a prompt or resource-template argument. -- onprogress on the call options streams progress without changing the return type. ---> +- `listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` aggregate every page; `{ cursor }` fetches a single raw page and `listMaxPages` caps the walk. +- `callTool` returns `content` for the model and, when the tool declares an `outputSchema`, `structuredContent` for your application. +- `readResource({ uri })` and `getPrompt({ name, arguments })` follow the same list-then-fetch shape as tools. +- `complete()` returns the server's suggestions for a prompt or resource-template argument. +- `onprogress` in the request options streams progress updates without changing the call's return type. diff --git a/docs/clients/connect.md b/docs/clients/connect.md index 4bf0e82ba2..4fb6b72f33 100644 --- a/docs/clients/connect.md +++ b/docs/clients/connect.md @@ -1,21 +1,15 @@ --- -status: scaffold shape: how-to --- # Connect to a server -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Client + transports, what you can ask after connect. -teaches: Client, Client.connect, StreamableHTTPClientTransport, StdioClientTransport, SSEClientTransport, Client.close, Client.getInstructions, ConnectOptions -source: mined from docs/client.md "Connecting to a server", "Disconnecting", "Server instructions", "Protocol version negotiation" ---> +A **client** holds one connection to one server: construct a `Client`, pick a **transport**, and `connect()`. ## Create a client and connect over HTTP -<!-- teaches: Client, StreamableHTTPClientTransport, Client.connect | salvage: docs/client.md "Streamable HTTP" --> +`Client` takes a name and a version; `StreamableHTTPClientTransport` takes the server's MCP endpoint URL. -```ts -// draft - API verified against packages/client/src/client/client.ts and packages/client/src/client/streamableHttp.ts +```ts source="../../examples/guides/clients/connect.examples.ts#connect_streamableHttp" import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const client = new Client({ name: 'my-client', version: '1.0.0' }); @@ -25,39 +19,88 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 await client.connect(transport); ``` -<!-- result: connect() resolves once the initialize handshake completes; the client now holds the negotiated protocol version and the server's capabilities. --> -<!-- aside (::: info Coming from v1?): Client and the transport classes keep their names; the import - paths moved to @modelcontextprotocol/client (and its /stdio subpath) — run the codemod, then see - /migration/upgrade-to-v2. (proposal §3 path 3: the standard aside, mandatory on this page) --> +`connect()` runs the `initialize` handshake and resolves once it completes. The client now holds the negotiated protocol version, the server's capabilities, and its instructions. + +::: info Coming from v1? +`Client` and the transport classes keep their names — only the import paths moved, to `@modelcontextprotocol/client` and its `/stdio` subpath. Run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Connect to a local process over stdio -<!-- teaches: StdioClientTransport (@modelcontextprotocol/client/stdio) | salvage: docs/client.md "stdio" --> -<!-- code: same Client, StdioClientTransport({ command, args }) spawning the server process; note the /stdio subpath import --> +For a server you run as a child process, change only the transport: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and speaks JSON-RPC over its stdin and stdout. + +```ts source="../../examples/guides/clients/connect.examples.ts#connect_stdio" +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] }); + +await client.connect(transport); +``` + +`server.js` runs as a child of your process. `close()` shuts it down in order: close stdin, then `SIGTERM`, then `SIGKILL`. + +::: tip +`InMemoryTransport.createLinkedPair()` is the third transport: it links a `Client` and an `McpServer` inside one process, no network and no child process. [Test a server](../testing.md) builds on it. +::: -## Fall back to SSE for legacy servers +## Fall back to SSE for servers that predate Streamable HTTP + +An SSE-only server speaks the older HTTP+SSE transport instead of Streamable HTTP. Try `StreamableHTTPClientTransport` first; when it fails, retry with `SSEClientTransport` on a fresh `Client`. + +```ts source="../../examples/guides/clients/connect.examples.ts#connect_sseFallback" +try { + const client = new Client({ name: 'my-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; +} catch { + const client = new Client({ name: 'my-client', version: '1.0.0' }); + await client.connect(new SSEClientTransport(new URL(url))); + return client; +} +``` -<!-- teaches: SSEClientTransport | salvage: docs/client.md "SSE fallback for legacy servers" --> -<!-- code: try StreamableHTTPClientTransport, catch, retry with SSEClientTransport on a fresh Client --> -<!-- aside: ::: info — one-line era cross-link to /protocol-versions; version negotiation (ConnectOptions / setVersionNegotiation) is a labeled aside, not main flow --> +Whichever branch returns, the `Client` behaves the same from here on — nothing downstream depends on the transport. + +::: info +`versionNegotiation` in `ClientOptions` controls which protocol revision `connect()` negotiates — see [Protocol versions](../protocol-versions.md). +::: ## Read what the server told you at connect time -<!-- teaches: Client.getServerVersion, Client.getServerCapabilities, Client.getInstructions | salvage: docs/client.md "Server instructions", "Extension capabilities" --> -<!-- code: log getServerVersion(), getServerCapabilities(), getInstructions() after connect --> -<!-- result: the capability object is what gates every verb on the next page --> +Three accessors return what the server declared during the handshake; all of them return `undefined` until `connect()` resolves. + +```ts source="../../examples/guides/clients/connect.examples.ts#connect_introspect" +console.log(client.getServerVersion()); +console.log(client.getServerCapabilities()); +console.log(client.getInstructions()); +``` + +Connected to a server named `travel` that registered one tool and set `instructions`, that prints: + +``` +{ name: 'travel', version: '2.1.0' } +{ tools: { listChanged: true } } +Call list-trips before book-trip. Dates are ISO 8601. +``` + +The capability object gates every verb on [the next page](./calling.md): only ask for what the server advertised. `getInstructions()` is the server's usage guide for the model — put it in the system prompt. ## Disconnect cleanly -<!-- teaches: Client.close | salvage: docs/client.md "Disconnecting" --> -<!-- code: await client.close() --> +Over Streamable HTTP, terminate the server-side session, then close the client. + +```ts source="../../examples/guides/clients/connect.examples.ts#connect_close" +await transport.terminateSession(); +await client.close(); +``` + +`close()` tears down the transport and rejects every request still in flight with a `CONNECTION_CLOSED` error. `terminateSession()` returns without sending anything when the server never issued a session ID. On the other transports, `close()` alone is the whole teardown. ## Recap -<!-- the claims this page will prove: -- new Client({ name, version }) plus a transport plus connect() is the whole setup. -- StreamableHTTPClientTransport is the default for remote servers; StdioClientTransport (from /stdio) for local processes; SSEClientTransport only as a legacy fallback. -- connect() performs initialization; afterwards getServerCapabilities()/getInstructions() are populated. -- close() tears down the transport. -- Era differences live on /protocol-versions, not here. ---> +- `new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake. +- `StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers. +- `InMemoryTransport.createLinkedPair()` links a client and a server in one process. +- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared. +- `close()` tears down the transport and rejects in-flight requests. +- Protocol-revision differences live on the protocol versions page, not here. diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 957aa41d15..34d24f8450 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -1,66 +1,106 @@ --- -status: scaffold shape: how-to --- # Authenticate without a user -<!-- ROUTER (one line, first body line of the page — proposal §3 path 4): -"Protecting a server you run → serving/authorization. Authenticating a user → clients/oauth. -No user (service-to-service) → this page." --> - -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Client credentials, private-key JWT, cross-app access. -teaches: AuthProvider, ClientCredentialsProvider, PrivateKeyJwtProvider, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant -source: mined from docs/client.md "Bearer tokens", "Client credentials", "Private key JWT", "Cross-App Access (Enterprise Managed Authorization)" ---> +Protecting a server you run → [Require authorization](../serving/authorization.md). Authenticating an end user → [OAuth](./oauth.md). No user — a job, a backend, a service account → this page. ## Authenticate with client credentials -<!-- teaches: ClientCredentialsProvider; the authProvider option is the same one OAuth uses | salvage: docs/client.md "Client credentials" --> +`ClientCredentialsProvider` runs the OAuth `client_credentials` grant from a `client_id` and `client_secret`. Pass it as the transport's `authProvider` — every flow on this page plugs into that same option. -```ts -// draft - API verified against packages/client/src/client/authExtensions.ts (ClientCredentialsProvider implements OAuthClientProvider) and packages/client/src/client/streamableHttp.ts (authProvider) +```ts source="../../examples/guides/clients/machine-auth.examples.ts#clientCredentials_connect" import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const authProvider = new ClientCredentialsProvider({ - clientId: 'my-service', - clientSecret: 'my-secret', + clientId: 'reporting-job', + clientSecret: 'reporting-job-secret' }); -const client = new Client({ name: 'my-service', version: '1.0.0' }); +const client = new Client({ name: 'reporting-job', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); await client.connect(transport); ``` -<!-- result: the provider discovers the authorization server, runs the client_credentials grant, and refreshes the token on 401 — no browser, no user. --> +`connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user. + +::: tip +Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. +::: ## Bring your own bearer token -<!-- teaches: the minimal AuthProvider interface (token(), optional onUnauthorized()) for tokens managed outside the SDK | salvage: docs/client.md "Bearer tokens" --> -<!-- code: const authProvider: AuthProvider = { token: async () => getStoredToken() } --> +When something outside the SDK already owns the token — an API key, a gateway, a platform secret store — implement `AuthProvider` with only `token()`. + +```ts source="../../examples/guides/clients/machine-auth.examples.ts#bearerToken_provider" +const authProvider: AuthProvider = { token: async () => getStoredToken() }; + +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); +``` + +The transport calls `token()` before every request and sets the `Authorization` header from whatever it returns. Without `onUnauthorized`, a 401 throws `UnauthorizedError`. Add `onUnauthorized(ctx)` to refresh the credential and the transport retries the request once. ## Sign with a private key instead of a secret -<!-- teaches: PrivateKeyJwtProvider (private_key_jwt token-endpoint auth) | salvage: docs/client.md "Private key JWT" --> -<!-- code: new PrivateKeyJwtProvider({ clientId, privateKey, algorithm: 'RS256' }) --> +`PrivateKeyJwtProvider` runs the same `client_credentials` grant, but authenticates the token request with a signed JWT assertion (`private_key_jwt`, RFC 7523) in place of a shared secret. + +```ts source="../../examples/guides/clients/machine-auth.examples.ts#privateKeyJwt_provider" +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'reporting-job', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); + +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); +``` + +`privateKey` accepts a PEM string, a `Uint8Array`, or a JWK object. The provider signs a fresh assertion for every token request; `jwtLifetimeSeconds` overrides the 300-second default, and `claims` merges extra claims into the assertion. ## Act for an enterprise user with cross-app access -<!-- teaches: CrossAppAccessProvider (SEP-990), discoverAndRequestJwtAuthGrant; the IdP-token -> JAG -> access-token chain | salvage: docs/client.md "Cross-App Access (Enterprise Managed Authorization)" --> -<!-- code: new CrossAppAccessProvider({ assertion: async ctx => (await discoverAndRequestJwtAuthGrant({...})).jwtAuthGrant, clientId, clientSecret }) --> +**Cross-app access** (Enterprise Managed Authorization, SEP-990) lets a service reach an MCP server for a user who already authenticated with the enterprise IdP, with no second consent screen. Two exchanges get it there: the IdP ID Token becomes a **JWT Authorization Grant** (RFC 8693), and that grant becomes an MCP access token (RFC 7523). + +`CrossAppAccessProvider` runs the second exchange. Your `assertion` callback supplies the grant — here `discoverAndRequestJwtAuthGrant` performs the first exchange against the IdP. + +```ts source="../../examples/guides/clients/machine-auth.examples.ts#crossAppAccess_provider" +const authProvider = new CrossAppAccessProvider({ + assertion: async ctx => { + const grant = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: ctx.authorizationServerUrl, + resource: ctx.resourceUrl, + idToken: await getIdToken(), + clientId: 'idp-exchange-client', + clientSecret: 'idp-exchange-secret', + scope: ctx.scope, + fetchFn: ctx.fetchFn + }); + return grant.jwtAuthGrant; + }, + clientId: 'reporting-job', + clientSecret: 'reporting-job-secret' +}); + +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); +``` + +The SDK discovers the MCP server's authorization server and resource URL (RFC 9728) before it calls `assertion`, then hands them in on `ctx` together with the negotiated `scope` and the transport's `fetchFn`. Pass them through so the IdP issues a grant bound to the right audience and resource. ## Drop to the token-exchange utilities -<!-- teaches: requestJwtAuthorizationGrant, discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant as standalone functions | salvage: docs/client.md "Cross-App Access" (Layer 2 list) --> -<!-- code: none — link the API reference for the three functions --> +Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store. + +- `requestJwtAuthorizationGrant` exchanges an ID Token for a JWT Authorization Grant at a known IdP token endpoint (RFC 8693). +- `discoverAndRequestJwtAuthGrant` performs the same exchange, discovering the IdP's token endpoint from `idpUrl` first. +- `exchangeJwtAuthGrant` exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server (RFC 7523). + +All three live in [`client/crossAppAccess`](../api/@modelcontextprotocol/client/client/crossAppAccess.md) in the API reference. ## Recap -<!-- the claims this page will prove: -- Every flow on this page plugs in through the same authProvider transport option. -- ClientCredentialsProvider covers plain service-to-service; PrivateKeyJwtProvider replaces the shared secret with a signed assertion. -- A bare AuthProvider with only token() is enough when something else owns the token. -- CrossAppAccessProvider chains the enterprise IdP token through a JAG to an MCP access token (SEP-990). -- User-facing flows belong on clients/oauth. ---> +- Every flow on this page plugs in through the same `authProvider` option on `StreamableHTTPClientTransport`. +- `ClientCredentialsProvider` runs the `client_credentials` grant with a shared secret; `PrivateKeyJwtProvider` runs the same grant with a signed JWT assertion in its place. +- An `AuthProvider` with only `token()` is enough when something outside the SDK owns the token; without `onUnauthorized`, a 401 throws `UnauthorizedError`. +- `CrossAppAccessProvider` chains an enterprise IdP token through a JWT Authorization Grant to an MCP access token (SEP-990), and both exchanges are exported standalone. +- Authenticating an end user belongs on [OAuth](./oauth.md). diff --git a/docs/clients/middleware.md b/docs/clients/middleware.md index d979f69b8e..2904850fea 100644 --- a/docs/clients/middleware.md +++ b/docs/clients/middleware.md @@ -1,62 +1,133 @@ --- -status: scaffold shape: how-to --- # Compose client middleware -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Compose request/response middleware. -teaches: createMiddleware, applyMiddlewares, Middleware, withLogging, withOAuth, the transport fetch option -source: mined from docs/client.md "Client middleware", "Trace context propagation" (middleware block); packages/client/src/client/middleware.ts ---> +A **middleware** wraps the `fetch` a client transport uses, so it sees every HTTP request on the way out and every `Response` on the way back. ## Write a middleware -<!-- teaches: createMiddleware((next, input, init) => ...) wrapping fetch | salvage: docs/client.md "Client middleware" --> +`createMiddleware` builds one from a function that receives the next handler plus the request. Compose it onto `fetch` with `applyMiddlewares` and hand the result to the transport's `fetch` option. -```ts -// draft - API verified against packages/client/src/client/middleware.ts (createMiddleware, applyMiddlewares) and packages/client/src/client/streamableHttp.ts (fetch option) +```ts source="../../examples/guides/clients/middleware.examples.ts#middleware_create" import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const authMiddleware = createMiddleware(async (next, input, init) => { - const headers = new Headers(init?.headers); - headers.set('X-Custom-Header', 'my-value'); - return next(input, { ...init, headers }); +const tagRequests = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Request-Source', 'reports-cli'); + return next(input, { ...init, headers }); }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - fetch: applyMiddlewares(authMiddleware)(fetch), + fetch: applyMiddlewares(tagRequests)(fetch) }); ``` -<!-- result: every HTTP request the transport makes now carries the header; the middleware sees the raw Response on the way back. --> +Every request this transport sends now carries the header — including the requests the SDK sends that you never wrote, like `initialize`. + +::: info Not the framework middleware packages +This page is about client request middleware: functions that wrap the `fetch` inside `@modelcontextprotocol/client`. The `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, and `@modelcontextprotocol/node` packages also carry the word "middleware" — those are server-side framework adapters for mounting a handler. See [Express](../serving/express.md) and [Hono](../serving/hono.md). +::: ## Compose several middlewares -<!-- teaches: applyMiddlewares(...mws) ordering — first argument is outermost | salvage: docs/client.md "Client middleware"; net-new ordering note --> -<!-- code: applyMiddlewares(retry, auth, logging)(fetch) with a one-line comment per layer --> +`applyMiddlewares` takes any number of middlewares; each one in the list wraps everything before it. Stub out the network and stamp each layer's name on both sides of `next` to watch the order. + +```ts source="../../examples/guides/clients/middleware.examples.ts#middleware_order" +const stamp = (name: string) => + createMiddleware(async (next, input, init) => { + console.log(`-> ${name}`); + const response = await next(input, init); + console.log(`<- ${name}`); + return response; + }); + +const base = async () => new Response('ok'); +await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp'); +``` + +The last middleware you pass is outermost — it sees the request first and the response last: + +``` +-> trace +-> auth +-> retry +<- retry +<- auth +<- trace +``` + +The first middleware you pass sits closest to the network. Put a retry there so every layer above it sees one settled `Response`. ## Use the built-in logging middleware -<!-- teaches: withLogging(options) | salvage: net-new from packages/client/src/client/middleware.ts (withLogging) --> -<!-- code: applyMiddlewares(withLogging({ ... }))(fetch) --> +`withLogging` ships in `@modelcontextprotocol/client`; called with no options it logs every request the wrapped `fetch` makes. + +```ts source="../../examples/guides/clients/middleware.examples.ts#middleware_logging" +const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch); +``` + +Connect through `loggedFetch` and call one tool. Four requests reach the wire, and you wrote one of them: + +``` +HTTP POST http://localhost:3000/mcp 200 (0ms) +HTTP POST http://localhost:3000/mcp 202 (0ms) +HTTP GET http://localhost:3000/mcp 405 (0ms) +HTTP POST http://localhost:3000/mcp 200 (0ms) +``` + +The `POST`s are `initialize`, the `notifications/initialized` notification, and your `tools/call`; the `GET` opens the server-to-client stream, which this server declines. Pass `statusLevel: 400` to log only failures, `includeRequestHeaders` / `includeResponseHeaders` to add headers to each line, and `logger` to replace the formatter entirely. + +::: warning +The default logger writes to `console.log` and `console.error`. In a process whose stdout carries an MCP stdio transport, pass your own `logger` so these lines stay off that stream. +::: ## Combine middleware with an auth provider -<!-- teaches: withOAuth — the auth provider expressed AS a middleware, for stacks that already own fetch | salvage: net-new from packages/client/src/client/middleware.ts (withOAuth) --> -<!-- code: applyMiddlewares(withOAuth(provider, serverUrl))(fetch) --> -<!-- aside: ::: tip — for the common case just pass authProvider to the transport (clients/oauth); withOAuth is for composing it with other middleware --> +`withOAuth(provider, serverUrl)` is the OAuth flow expressed as one middleware layer: it adds the `Authorization` header, and on a `401` it re-authenticates against `serverUrl` and retries the request once. + +```ts source="../../examples/guides/clients/middleware.examples.ts#middleware_withOAuth" +const serverUrl = new URL('http://localhost:3000/mcp'); +const authed = new StreamableHTTPClientTransport(serverUrl, { + fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch) +}); +``` + +`provider` is the same `OAuthClientProvider` you would hand to the transport directly. With `statusLevel: 400`, `withLogging` stays silent until a request fails. + +::: tip +For the common case, pass `authProvider` to the transport instead — see [OAuth](./oauth.md). `withOAuth` is for stacks that already own `fetch` and need auth composed with other layers. +::: ## Inspect the response -<!-- teaches: middleware sees both directions — read response status/headers after awaiting next() | salvage: net-new; docs/client.md "Trace context propagation" (traceContext_middleware) as the worked case --> -<!-- code: const response = await next(input, init); read response.status; return response --> +A middleware runs on both sides of `next`: read the request body before the call and the `Response` after it. Map each JSON-RPC method to the HTTP status it came back with. + +```ts source="../../examples/guides/clients/middleware.examples.ts#middleware_inspect" +const observeStatus = createMiddleware(async (next, input, init) => { + const response = await next(input, init); + if (typeof init?.body === 'string') { + const { method } = JSON.parse(init.body) as { method?: string }; + console.log(`${method ?? 'response'} -> HTTP ${response.status}`); + } + return response; +}); +``` + +Connecting through `observeStatus` and calling one tool prints one line per request that carried a body: + +``` +initialize -> HTTP 200 +notifications/initialized -> HTTP 202 +tools/call -> HTTP 200 +``` + +Always return the `Response`; the transport consumes its body after you. To read the body too, read a `response.clone()`. ## Recap -<!-- the claims this page will prove: -- Middleware wraps the transport's fetch; createMiddleware builds one, applyMiddlewares composes many. -- Pass the composed fetch to the transport's fetch option. -- A middleware sees the request before next() and the Response after it. -- withLogging and withOAuth ship in the box. ---> +- A middleware wraps the transport's `fetch`: `createMiddleware` builds one, `applyMiddlewares` composes many, and the transport's `fetch` option takes the result. +- The last middleware passed to `applyMiddlewares` is outermost; the first sits closest to the network. +- A middleware sees every HTTP request the transport sends, including the ones the SDK sends on its own. +- `withLogging` and `withOAuth` ship in `@modelcontextprotocol/client`. +- A middleware sees both directions: the request before `next`, the `Response` after it. diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md index 7935e602f8..ed972919e1 100644 --- a/docs/clients/oauth.md +++ b/docs/clients/oauth.md @@ -1,66 +1,170 @@ --- -status: scaffold shape: how-to --- # Authenticate a user with OAuth -<!-- ROUTER (one line, first body line of the page — Felix ruling, proposal §3 path 4): -"Protecting a server you run → serving/authorization. Authenticating a user → this page. -No user → clients/machine-auth." --> - -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: User-facing authorization-code flow. Opens with the one-line auth router. -teaches: OAuthClientProvider, StreamableHTTPClientTransport authProvider option, UnauthorizedError, StreamableHTTPClientTransport.finishAuth, IssuerMismatchError, OAuthClientProvider.validateResourceURL -source: mined from docs/client.md "Full OAuth with user authorization", "Resource indicators (RFC 8707)" ---> +Protecting a server you run → [Require authorization](../serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](./machine-auth.md). ## Hand the transport an OAuth provider -<!-- teaches: authProvider option on StreamableHTTPClientTransport; connect() throws UnauthorizedError when authorization is needed | salvage: docs/client.md "Full OAuth with user authorization" --> +Pass an **`OAuthClientProvider`** as the transport's `authProvider` — it, and every other symbol on this page, comes from `@modelcontextprotocol/client`. -```ts -// draft - API verified against packages/client/src/client/streamableHttp.ts (authProvider option, finishAuth) and packages/client/src/client/auth.ts (OAuthClientProvider, UnauthorizedError) +```ts source="../../examples/guides/clients/oauth.examples.ts#authProvider_connect" +const provider = new MyOAuthProvider(); +const client = new Client({ name: 'my-app', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { - authProvider: provider, // your OAuthClientProvider + authProvider: provider }); try { - await client.connect(transport); + await client.connect(transport); } catch (error) { - if (!(error instanceof UnauthorizedError)) throw error; - // The provider's redirectToAuthorization() already sent the end user to the browser. + if (!(error instanceof UnauthorizedError)) throw error; + // The transport already called provider.redirectToAuthorization(url): + // the end user is in the browser, at the authorization server. } ``` -<!-- result: on a 401 the SDK runs discovery, registers (or looks up) the client, and calls your provider's redirectToAuthorization(url). --> +When the server requires authorization and the provider has no token, the SDK runs discovery against the server, registers (or looks up) your OAuth client, calls the provider's `redirectToAuthorization(url)`, and `connect()` throws `UnauthorizedError`. The end user finishes signing in out of band; your callback endpoint picks the flow back up below. + +::: info +With protocol-version negotiation in play, the connect-time 401 can also surface as an `SdkError` carrying the `UnauthorizedError` at `error.data.cause` — see [Protocol versions](../protocol-versions.md). +::: ## Implement OAuthClientProvider -<!-- teaches: the OAuthClientProvider interface — redirectUrl, clientMetadata, clientInformation/saveClientInformation keyed by ctx.issuer, tokens/saveTokens, state, codeVerifier, saveDiscoveryState | salvage: docs/client.md "Full OAuth with user authorization" (MyOAuthProvider block) --> -<!-- code: a minimal OAuthClientProvider class; keep the issuer-keyed credential map (SEP-2352) --> +The provider is the storage and redirect surface the SDK drives: client registrations, tokens, the PKCE verifier, discovery state, and the browser hand-off. Key client credentials by `ctx.issuer` so a `client_id` registered with one authorization server is never sent to another. + +```ts source="../../examples/guides/clients/oauth.examples.ts#MyOAuthProvider_class" +class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map<string, OAuthClientInformationMixed>(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; + + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } +} +``` + +The SDK calls the `save*` methods as the flow produces values and reads them back through `tokens()`, `clientInformation()`, `codeVerifier()`, and `discoveryState()`. On a later `connect()` it reads `tokens()` before anything else, so a provider backed by durable storage skips the browser round trip. ## Finish the flow from the callback -<!-- teaches: transport.finishAuth(URLSearchParams), state comparison, reconnect on a FRESH transport | salvage: docs/client.md "Full OAuth with user authorization" (auth_finishAuth block) --> -<!-- code: compare state, await transport.finishAuth(params), then client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })) --> +The authorization server redirects the end user to `redirectUrl` with `code` and `state` in the query. Compare `state`, hand the whole query to `finishAuth`, and reconnect. + +```ts source="../../examples/guides/clients/oauth.examples.ts#finishAuth_callback" +const callbackUrl = await waitForCallback(); // however your app receives the redirect +const params = new URL(callbackUrl).searchParams; + +// The SDK does not validate `state` — compare it to the value your provider generated. +if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + +await transport.finishAuth(params); + +// Reconnect on a FRESH transport — a started transport cannot be restarted. +// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. +await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); +``` + +`finishAuth(params)` extracts `code`, validates the RFC 9207 `iss` parameter, exchanges the code at the authorization server discovery resolved before the redirect, and saves the tokens through your provider. The second `connect()` finds those tokens and completes without another redirect. + +::: tip +`finishAuth` also takes a positional form, `finishAuth(code, iss)`. Pass the `URLSearchParams` instead: the SDK reads both values from it, and a positional call that drops `iss` is rejected when the authorization server advertises RFC 9207 support. +::: ## Handle issuer mismatch -<!-- teaches: IssuerMismatchError (kind 'authorization_response' vs 'metadata'), never render error_description on mismatch | salvage: docs/client.md "Full OAuth with user authorization" (issuer-validation paragraph) --> -<!-- code: catch IssuerMismatchError around finishAuth --> -<!-- aside: ::: warning — security: a mix-up attacker controls error_description; do not show it. skipIssuerMetadataValidation exists but weakens this. --> +`finishAuth` throws **`IssuerMismatchError`** when the callback's `iss` does not match the issuer the flow started with. + +```ts source="../../examples/guides/clients/oauth.examples.ts#finishAuth_issuerMismatch" +try { + await transport.finishAuth(params); +} catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: never render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; +} +``` + +The error's `kind` is `'authorization_response'` here; the same check runs during discovery against the authorization server's published `issuer` (RFC 8414 §3.3) and throws with `kind: 'metadata'`. + +::: warning +A mismatch means the callback came from an authorization server you did not start the flow with — a mix-up attack. The callback's `error` and `error_description` are attacker-controlled: never render them. The transport's `skipIssuerMetadataValidation` option disables the discovery-leg check; leave it off unless you control the server. +::: ## Pin the resource indicator -<!-- teaches: OAuthClientProvider.validateResourceURL, checkResourceAllowed, resourceUrlFromServerUrl (RFC 8707) | salvage: docs/client.md "Resource indicators (RFC 8707)" --> -<!-- code: validateResourceURL override returning the URL to force, or undefined to omit --> +The SDK binds tokens to your server with the RFC 8707 `resource` parameter: when the server publishes protected resource metadata (RFC 9728), the SDK checks the metadata's `resource` against the server URL and attaches it to the authorization redirect and every token request. Override `validateResourceURL` to force the value — return the URL to send, or `undefined` to omit the parameter. + +```ts source="../../examples/guides/clients/oauth.examples.ts#validateResourceURL_pin" +class PinnedResourceProvider extends MyOAuthProvider { + async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> { + const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) + if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { + throw new Error(`Refusing resource ${resource} for server ${expected.href}`); + } + return expected; + } +} +``` + +`PinnedResourceProvider` sends the server's own URL as `resource` on every leg of the flow and refuses metadata that names a different one. `checkResourceAllowed` and `resourceUrlFromServerUrl` are exported for exactly this override. ## Recap -<!-- the claims this page will prove: -- This page is for clients acting on behalf of a USER; machine-to-machine flows live on clients/machine-auth. -- Pass an OAuthClientProvider as the transport's authProvider; connect() throws UnauthorizedError when the end user must authorize. -- finishAuth(params) with the whole callback query lets the SDK validate iss (RFC 9207) and exchange the code. -- Always reconnect on a fresh transport; OAuth state lives on the provider. -- IssuerMismatchError is the mix-up defense; do not weaken it. ---> +- This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](./machine-auth.md). +- Pass an `OAuthClientProvider` as the transport's `authProvider`; `connect()` throws `UnauthorizedError` after sending the user to the authorization server. +- `finishAuth(params)` with the whole callback query validates `iss` (RFC 9207) and exchanges the code. +- Reconnect on a fresh transport; OAuth state lives on the provider, not the transport. +- `IssuerMismatchError` is the mix-up defense — never render the callback's `error_description`. +- `validateResourceURL` overrides the RFC 8707 `resource` parameter the SDK sends. diff --git a/docs/clients/roots.md b/docs/clients/roots.md index c34a67ece9..48cf5ca62d 100644 --- a/docs/clients/roots.md +++ b/docs/clients/roots.md @@ -1,59 +1,89 @@ --- -status: scaffold shape: how-to --- # Provide roots ::: warning Deprecated — SEP-2577 -<!-- SUNSET BANNER placeholder. Roots are deprecated as of protocol version 2026-07-28 -(SEP-2577) and remain functional for at least twelve months. Migration target named -FIRST: pass paths via tool arguments, resource URIs, or host configuration instead. -Link the deprecated-features registry. This banner is the first thing on the page. --> +Pass paths through tool arguments, resource URIs, or host configuration instead. **Roots** are deprecated as of protocol version 2026-07-28 (SEP-2577) and stay functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). ::: -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Provide roots — SUNSET-FRAMED (SEP-2577), banner at top. -teaches: roots capability, client.setRequestHandler('roots/list'), Client.sendRootsListChanged -source: mined from docs/client.md "Roots" ---> - ## Migrate away first -<!-- teaches: the replacement, not the feature. Name the targets (tool arguments, resource URIs, configuration) before showing any roots API | salvage: docs/client.md "Roots" warning block; net-new framing --> -<!-- code: none — this section is the off-ramp; one link to the deprecated-features registry --> +A **root** is a `file://` URI the client hands to the server as a boundary for its file operations. The 2026-07-28 revision deprecates the request that carries them, and nothing replaces it — give the server its paths directly. + +Send the path a call should act on as a tool argument ([Tools](../servers/tools.md)), expose the locations the server owns as resources ([Resources](../servers/resources.md)), or put fixed directories in the server's own configuration. The rest of this page covers the roots API for clients that still answer 2025-era servers through the deprecation window. ## Declare the roots capability -<!-- teaches: roots: { listChanged: true } in the Client constructor's capabilities option | salvage: docs/client.md "Handling server-initiated requests" (capabilities_declaration) --> -<!-- code: new Client(info, { capabilities: { roots: { listChanged: true } } }) --> +`roots` in the `Client` constructor's `capabilities` tells the server it can ask for the list; `listChanged: true` also lets you notify it when the list changes. + +```ts source="../../examples/guides/clients/roots.examples.ts#roots_capability" +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client( + { name: 'workspace-client', version: '1.0.0' }, + { capabilities: { roots: { listChanged: true } } } +); +``` + +Declare the capability before registering the handler: without it, `setRequestHandler('roots/list', …)` throws. ## Answer roots/list -<!-- teaches: client.setRequestHandler('roots/list', ...) returning { roots } | salvage: docs/client.md "Roots" --> +`setRequestHandler('roots/list', …)` returns `{ roots }`. Every `uri` must start with `file://`; `name` is optional. + +```ts source="../../examples/guides/clients/roots.examples.ts#roots_listHandler" +const roots = [ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' } +]; -```ts -// draft - API verified against packages/client/src/client/client.ts (setRequestHandler) and the roots/list request type (a ServerRequest — the server sends it) in packages/core-internal/src/types/types.ts client.setRequestHandler('roots/list', async () => { - return { - roots: [ - { uri: 'file:///home/user/projects/my-app', name: 'My App' }, - { uri: 'file:///home/user/data', name: 'Data' }, - ], - }; + return { roots }; }); ``` -<!-- result: a server that declares it uses roots receives this list and scopes its file operations to it. --> +A connected server that requests `roots/list` receives exactly what the handler returned: + +``` +[ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' } +] +``` + +Roots are advisory boundaries, not an access grant — the server still reaches the filesystem with its own permissions, and the SDK never enforces the list on either side. + +::: info +On a 2026-07-28 connection there is no server-to-client request channel; the same handler fulfils a `roots/list` request embedded in an `input_required` result — see [Protocol versions](../protocol-versions.md). +::: ## Tell the server when the roots change -<!-- teaches: Client.sendRootsListChanged | salvage: docs/client.md "Roots" (final paragraph) --> -<!-- code: await client.sendRootsListChanged() after the handler's backing list changes --> +`sendRootsListChanged()` sends `notifications/roots/list_changed`; it requires the `listChanged: true` declared above. + +```ts source="../../examples/guides/clients/roots.examples.ts#roots_listChanged" +roots.push({ uri: 'file:///home/user/projects/another-app', name: 'Another app' }); +await client.sendRootsListChanged(); +``` + +The notification carries no payload. A server that watches it requests `roots/list` again and receives the updated list: + +``` +[ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' }, + { + uri: 'file:///home/user/projects/another-app', + name: 'Another app' + } +] +``` ## Recap -<!-- the claims this page will prove: -- Roots are deprecated (SEP-2577); the migration targets are tool arguments, resource URIs, and configuration. -- While you still need them: declare roots: { listChanged: true } and register a roots/list handler returning { roots }. -- sendRootsListChanged() tells the server to re-list. ---> +- Roots are deprecated (SEP-2577): pass paths through tool arguments, resource URIs, or configuration instead. +- `capabilities: { roots: { listChanged: true } }` on the `Client` constructor declares the capability; register the `roots/list` handler only after declaring it. +- The handler returns `{ roots }`, and every root `uri` starts with `file://`. +- Roots are advisory boundaries, not an access grant. +- `sendRootsListChanged()` notifies the server that the list changed; the server re-requests `roots/list` itself. diff --git a/docs/clients/server-requests.md b/docs/clients/server-requests.md index 89014956a8..8d3c75a45b 100644 --- a/docs/clients/server-requests.md +++ b/docs/clients/server-requests.md @@ -1,62 +1,114 @@ --- -status: scaffold shape: how-to --- # Handle requests from the server -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Sampling/elicitation handlers; era unification told once via one cross-link. -teaches: Client capabilities option, Client.setRequestHandler, elicitation/create handler, sampling/createMessage handler, getSupportedElicitationModes, ClientOptions.inputRequired -source: mined from docs/client.md "Handling server-initiated requests", "Sampling", "Elicitation", "Manual multi-round-trip handling" ---> - ## Declare what your client can do -<!-- teaches: ClientCapabilities via the Client constructor's options | salvage: docs/client.md "Handling server-initiated requests" (capabilities_declaration) --> +Declare each **capability** in the `Client` constructor's options — a server only sends your client a request it declared a capability for, and the SDK enforces that on both sides. -```ts -// draft - API verified against packages/client/src/client/client.ts (Client constructor, ClientOptions.capabilities) +```ts source="../../examples/guides/clients/server-requests.examples.ts#Client_capabilities" import { Client } from '@modelcontextprotocol/client'; const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - capabilities: { - sampling: {}, - elicitation: { form: {} }, - }, - } + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {}, url: {} } + } + } ); ``` -<!-- result: the server only sends a request your client declared a capability for; the SDK enforces this on both sides. --> +Every result quoted on this page comes from this client connected over an in-memory transport pair to a server whose tools elicit input and request sampling. [Test a server](../testing.md) shows that wiring; [Elicitation](../servers/elicitation.md) and [Sampling](../servers/sampling.md) show the server side. + +::: tip +An empty `elicitation: {}` declares form mode only — `url` must be listed explicitly. `getSupportedElicitationModes`, exported from `@modelcontextprotocol/client`, turns any `elicitation` capability object into `{ supportsFormMode, supportsUrlMode }`. +::: ## Handle an elicitation request -<!-- teaches: client.setRequestHandler('elicitation/create', ...), form vs URL mode, action accept/decline/cancel | salvage: docs/client.md "Elicitation" --> -<!-- code: setRequestHandler('elicitation/create') branching on request.params.mode, returning { action: 'accept', content } or { action: 'decline' } --> +A tool that calls `elicitInput` sends your client an `elicitation/create` request. Branch on `request.params.mode`: `'url'` carries a URL to open in the user's browser, and anything else is a form your client builds from `request.params.requestedSchema`. + +```ts source="../../examples/guides/clients/server-requests.examples.ts#setRequestHandler_elicitation" +client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer when they finish. + return { action: 'accept' }; + } + // Render request.params.requestedSchema as a form; return what the user entered. + return { action: 'accept', content: { city: 'Lisbon' } }; +}); +``` + +`action` is the user's decision: `'accept'` carries the submitted `content`, `'decline'` and `'cancel'` carry nothing. Calling a tool that asks where to ship an order now round-trips through the form branch: + +``` +[ { type: 'text', text: 'Order placed: Travel mug ships to Lisbon.' } ] +``` + +::: tip +Form requests sent before `mode` existed omit it entirely — branch on `'url'` and treat everything else as a form, never on `mode === 'form'`. +::: ## Handle a sampling request -<!-- teaches: client.setRequestHandler('sampling/createMessage', ...) | salvage: docs/client.md "Sampling" --> -<!-- code: setRequestHandler('sampling/createMessage') returning { model, role, content } from your LLM call --> -<!-- aside: ::: warning — sampling is deprecated (SEP-2577); link clients/roots.md? no — link /protocol-versions for the era story and the servers/sampling.md banner for the sunset --> +::: warning Deprecated — SEP-2577 +Servers should call their LLM provider directly instead of sampling — see [Sampling](../servers/sampling.md). Keep this handler to support servers that have not migrated yet. +::: + +A tool that calls `requestSampling` sends your client a `sampling/createMessage` request: a list of messages to run through a model your application controls. + +```ts source="../../examples/guides/clients/server-requests.examples.ts#setRequestHandler_sampling" +client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage?.content); + + // In production, run the messages through your model here. + return { + model: 'host-model', + role: 'assistant', + content: { type: 'text', text: 'One travel mug to Lisbon.' } + }; +}); +``` + +Calling a tool that summarizes the order logs the prompt the server sent, and the tool result carries the handler's completion: + +``` +Sampling request: { type: 'text', text: 'Summarize this order: 1 Travel mug to Lisbon' } +[ { type: 'text', text: 'host-model: One travel mug to Lisbon.' } ] +``` ## Register each handler once -<!-- teaches: era unification — handlers are era-transparent (older push requests vs an input_required round trip reach the same handler); the page's SINGLE era cross-link | salvage: docs/client.md "Handling server-initiated requests" (era paragraph), "Manual multi-round-trip handling" --> -<!-- code: none — one ::: info container: "How these handlers are delivered differs by protocol version — see /protocol-versions." Nothing else era-shaped on this page. --> +Register each handler once, on the `Client` you construct. The same handler answers a request the server pushes to your client and a request the SDK fulfils for you inside a `callTool()` round — your code never sees the difference. + +::: info +Which of those two delivery paths a connection uses depends on its protocol version — see [Protocol versions](../protocol-versions.md). +::: ## Cap or disable automatic fulfilment -<!-- teaches: ClientOptions.inputRequired ({ autoFulfill, maxRounds }), INPUT_REQUIRED_ROUNDS_EXCEEDED | salvage: docs/client.md "Manual multi-round-trip handling (2026-07-28)" --> -<!-- code: new Client(info, { inputRequired: { maxRounds: 3 } }) --> +When the SDK fulfils requests inside a call, the `inputRequired` option caps how many rounds it runs on your behalf. + +```ts source="../../examples/guides/clients/server-requests.examples.ts#Client_inputRequired" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { sampling: {}, elicitation: { form: {}, url: {} } }, + inputRequired: { maxRounds: 3 } + } +); +``` + +Past `maxRounds` (default 10) the call rejects with an `SdkError` coded `INPUT_REQUIRED_ROUNDS_EXCEEDED`. Set `autoFulfill: false` to turn the loop off entirely: a call that needs input rejects on its first round instead, and the round trips are yours to drive. ## Recap -<!-- the claims this page will prove: -- Declare a capability in the Client constructor or the server never sends that request. -- setRequestHandler('elicitation/create') and setRequestHandler('sampling/createMessage') are the two handlers; each returns a plain result object. -- Register the handler once; the SDK delivers it the same way on every protocol version (one cross-link to /protocol-versions). -- inputRequired on ClientOptions caps the automatic interactive rounds. ---> +- Declare a capability in the `Client` constructor or the server never sends that request. +- `setRequestHandler('elicitation/create')` branches on `mode` and returns the user's `action`, plus `content` on accept. +- `setRequestHandler('sampling/createMessage')` runs the messages through your model and returns `{ model, role, content }`. +- Register each handler once; it answers the request however the connection delivers it. +- `inputRequired` caps the automatic interactive rounds; `autoFulfill: false` disables them. diff --git a/docs/clients/subscriptions.md b/docs/clients/subscriptions.md index f4f130fadc..e651cd2a16 100644 --- a/docs/clients/subscriptions.md +++ b/docs/clients/subscriptions.md @@ -1,62 +1,137 @@ --- -status: scaffold shape: how-to --- + # Subscribe to changes -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: listen filters vs legacy subscribe. -teaches: Client.listen, McpSubscription (honoredFilter, closed, close), Client.setNotificationHandler, ClientOptions.listChanged, Client.subscribeResource, Client.unsubscribeResource -source: mined from docs/client.md "Subscription streams (2026-07-28)", "Automatic list-change tracking", "Manual notification handlers", "Subscribing to resource changes" ---> +A **subscription stream** is one long-lived `subscriptions/listen` request that carries every change notification you opted in to. On a connection that negotiated [2026-07-28](../protocol-versions.md), change notifications arrive only on a stream you open — nothing arrives unsolicited. ## Open a subscription stream -<!-- teaches: Client.listen(filter) -> McpSubscription, setNotificationHandler dispatch | salvage: docs/client.md "Subscription streams (2026-07-28)" --> +`listen` takes a **filter** naming the notification types you want. Register a handler for each type with `setNotificationHandler` before you open the stream. -```ts -// draft - API verified against packages/client/src/client/client.ts (listen(filter: SubscriptionFilter, options?): Promise<McpSubscription>) +```ts source="../../examples/guides/clients/subscriptions.examples.ts#listen_open" client.setNotificationHandler('notifications/tools/list_changed', async () => { - const { tools } = await client.listTools(); - console.log('Tools changed:', tools.length); + const { tools } = await client.listTools(); + console.log('Tools changed:', tools.length); }); const subscription = await client.listen({ - toolsListChanged: true, - resourceSubscriptions: ['config://app'], + toolsListChanged: true, + resourceSubscriptions: ['config://app'] }); console.log('Server honored:', subscription.honoredFilter); ``` -<!-- result: listen() resolves once the server acknowledges; honoredFilter is the subset the server actually agreed to deliver. --> +`listen()` resolves once the server acknowledges the stream, and returns an `McpSubscription` whose `honoredFilter` is the subset of your filter the server agreed to deliver: + +``` +Server honored: { toolsListChanged: true, resourceSubscriptions: [ 'config://app' ] } +``` + +The server narrows the filter to its advertised capabilities — `resourceSubscriptions` survives only when it advertises `resources: { subscribe: true }`, and each list-change field only when the matching `listChanged` capability is set. The four filter fields are `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (an array of resource URIs). ## Handle the notifications -<!-- teaches: Client.setNotificationHandler for notifications/resources/updated and the three list_changed methods | salvage: docs/client.md "Manual notification handlers" --> -<!-- code: setNotificationHandler('notifications/resources/updated', ...) re-reading the resource --> +`resourceSubscriptions` asked for per-resource updates; register the matching handler and re-read the resource when it fires. + +```ts source="../../examples/guides/clients/subscriptions.examples.ts#listen_updated" +client.setNotificationHandler('notifications/resources/updated', async notification => { + const { contents } = await client.readResource({ uri: notification.params.uri }); + console.log('Updated', notification.params.uri, contents); +}); +``` + +Every notification on the stream dispatches through `setNotificationHandler` — the same registration an unsolicited 2025-era notification fires, so register once for either delivery path. When the server publishes a tool change and an update to `config://app`, both handlers fire from the one stream: + +``` +Tools changed: 2 +Updated config://app [ { uri: 'config://app', text: '{"theme":"dark"}' } ] +``` ## Close the stream and react to closure -<!-- teaches: subscription.close(), subscription.closed (resolves 'local' | 'graceful' | 'remote'), the re-listen loop | salvage: docs/client.md "Subscription streams" (watch-loop block) --> -<!-- code: await sub.closed; re-listen only when the reason is 'remote' --> +`close()` tears the stream down. `closed` resolves exactly once with the reason — it never rejects. + +```ts source="../../examples/guides/clients/subscriptions.examples.ts#listen_close" +await subscription.close(); +console.log('Closed:', await subscription.closed); +``` + +The reason names who ended the stream: + +``` +Closed: local +``` + +`'local'` means you closed it, `'graceful'` means the server ended the subscription deliberately, and `'remote'` means the stream dropped without a response. The SDK never re-listens for you. + +Re-listen only on `'remote'`: + +```ts source="../../examples/guides/clients/subscriptions.examples.ts#listen_watchLoop" +while (watching) { + const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); + const reason = await sub.closed; + if (reason !== 'remote') break; // 'local' or 'graceful': done + await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen +} +``` ## Let the SDK open the stream for you -<!-- teaches: ClientOptions.listChanged, Client.autoOpenedSubscription | salvage: docs/client.md "Automatic list-change tracking" --> -<!-- code: new Client(info, { listChanged: { tools: true } }) — the SDK opens and filters the stream from the intersection with the server's capabilities --> +The `listChanged` client option opens and manages the stream itself. + +```ts source="../../examples/guides/clients/subscriptions.examples.ts#listChanged_auto" +const watcher = new Client( + { name: 'notes-watcher', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Refresh failed:', error); + return; + } + console.log('Tools refreshed:', tools?.length); + } + } + } + } +); +``` + +After `connect()` the SDK opens the stream from the intersection of the `listChanged` types you configured and the capabilities the server advertises, and exposes the handle as `autoOpenedSubscription`. On every change the SDK re-fetches the list and hands it to `onChanged`: + +``` +Tools refreshed: 1 +``` + +::: warning +`listChanged` registers its own handler for each configured `list_changed` type during `connect()`. The last registration for a notification type wins, so a manual `setNotificationHandler` for that type registered after connecting silently disables `listChanged` for it. +::: ## Fall back to legacy per-resource subscribe -<!-- teaches: Client.subscribeResource / Client.unsubscribeResource (2025-era resources/subscribe) | salvage: docs/client.md "Subscribing to resource changes" --> -<!-- code: subscribeResource({ uri }), the same notifications/resources/updated handler, unsubscribeResource({ uri }) --> -<!-- aside: ::: info — one-line era cross-link to /protocol-versions: listen() is 2026-07-28; subscribeResource() is 2025-era. Each throws a typed error on the wrong era. --> +On a 2025-era connection, request per-resource updates with `subscribeResource` instead. + +```ts source="../../examples/guides/clients/subscriptions.examples.ts#subscribeResource_legacy" +await client.subscribeResource({ uri: 'config://app' }); + +// The same notifications/resources/updated handler fires. + +await client.unsubscribeResource({ uri: 'config://app' }); +``` + +The notification it produces is the same `notifications/resources/updated`, dispatched to the handler you already registered. + +::: info +`listen()` is 2026-07-28-only and `subscribeResource()` is 2025-era — on the wrong era each rejects with an `SdkError` whose code is `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`. See [Protocol versions](../protocol-versions.md). +::: ## Recap -<!-- the claims this page will prove: -- listen(filter) opens one stream carrying every change notification you asked for; honoredFilter tells you what the server granted. -- Notifications dispatch through setNotificationHandler regardless of how they arrived. -- closed resolves exactly once with the reason; there is no automatic re-listen. -- listChanged in ClientOptions opens and manages the stream for you. -- subscribeResource is the legacy per-resource path; which one your connection supports is an era question (/protocol-versions). ---> +- `listen(filter)` opens one stream carrying every change notification you asked for; `honoredFilter` is the capability-gated subset the server granted. +- Notifications on the stream dispatch through `setNotificationHandler` — the same registrations 2025-era unsolicited notifications fire. +- `closed` resolves exactly once with `'local'`, `'graceful'`, or `'remote'`, and never rejects; there is no automatic re-listen. +- The `listChanged` client option opens and manages the stream for you, exposed as `autoOpenedSubscription`. +- `subscribeResource` and `unsubscribeResource` are the 2025-era per-resource path; which path your connection supports is an era question. diff --git a/examples/guides/clients/caching.examples.ts b/examples/guides/clients/caching.examples.ts new file mode 100644 index 0000000000..a93e529ef7 --- /dev/null +++ b/examples/guides/clients/caching.examples.ts @@ -0,0 +1,148 @@ +/** + * Runnable, type-checked companion for `docs-v2/clients/caching.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). + * + * The page's main program runs for real: a hint-sending `McpServer` is served + * by `createMcpHandler` and the client's transport `fetch` is routed into + * `handler.fetch`, so a real 2026-07-28 Streamable HTTP exchange runs + * in-process without binding a port. The harness counts the JSON-RPC requests + * that actually reach the server; the counts the page quotes verbatim are + * whatever this file prints. It throws (non-zero exit) if a cache-served call + * reaches the server. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/caching.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { ResponseCacheStore } from '@modelcontextprotocol/client'; +import { Client, InMemoryResponseCacheStore, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +// --------------------------------------------------------------------------- +// ## Have the server send the hint +// The factory body carries the page's server-side region. `createMcpHandler` +// (the harness) serves it; the tool and resource give the cache something to +// hold. +// --------------------------------------------------------------------------- + +const handler = createMcpHandler(() => { + //#region cacheHints_server + const server = new McpServer( + { name: 'catalog', version: '1.0.0' }, + { + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 5_000, cacheScope: 'private' } + } + } + ); + //#endregion cacheHints_server + + server.registerTool('search', { description: 'Search the product catalog' }, async () => ({ + content: [{ type: 'text', text: 'Espresso cup\nTravel mug\nMug rack' }] + })); + + server.registerResource('app-config', 'config://app', { mimeType: 'application/json' }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"theme":"dark"}' }] + })); + + return server; +}); + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). The transport's `fetch` is routed into +// `handler.fetch` — [Test a server](docs-v2/testing.md) wiring — and counts +// every JSON-RPC request that reaches the server. +// --------------------------------------------------------------------------- + +const reached = new Map<string, number>(); + +const transport = new StreamableHTTPClientTransport(new URL('http://caching.example/mcp'), { + fetch: (url, init) => { + if (typeof init?.body === 'string') { + const message = JSON.parse(init.body) as { method?: string }; + if (typeof message.method === 'string') reached.set(message.method, (reached.get(message.method) ?? 0) + 1); + } + return handler.fetch(new Request(url, init)); + } +}); + +const client = new Client( + { name: 'caching-docs-harness', version: '1.0.0' }, + // Cache hints ride the 2026-07-28 revision — see docs-v2/protocol-versions.md. + { versionNegotiation: { mode: 'auto' } } +); + +await client.connect(transport); + +// ## Let the cache work — the calls whose request counts the page quotes. + +//#region responseCache_use +const tools = await client.listTools(); // network, then cached for the server's ttlMs +const again = await client.listTools(); // served from cache while still fresh + +await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store +await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write +//#endregion responseCache_use + +console.log('tools/list requests that reached the server:', reached.get('tools/list')); +console.log('resources/read requests that reached the server:', reached.get('resources/read')); + +// Self-verification: the page claims the second listTools() made no round trip +// (two tools/list requests total: the first call and the 'refresh') and that +// the cached result carries the same tools. +if (reached.get('tools/list') !== 2 || reached.get('resources/read') !== 1) { + throw new Error(`caching.md claim failed: tools/list=${reached.get('tools/list')}, resources/read=${reached.get('resources/read')}`); +} +if (again.tools.map(tool => tool.name).join() !== tools.tools.map(tool => tool.name).join()) { + throw new Error('caching.md claim failed: cached listTools() differs from the first result'); +} + +await client.close(); +await handler.close(); + +// --------------------------------------------------------------------------- +// The remaining regions configure a Client; they typecheck but never connect +// (each would need its own server), so they live in wrapper functions that are +// never called. +// --------------------------------------------------------------------------- + +// ## Bring your own store + +function responseCacheStore_shared() { + //#region responseCacheStore_shared + const store = new InMemoryResponseCacheStore({ maxEntries: 2048 }); + + const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store }); + //#endregion responseCacheStore_shared + return client; +} + +// ## Partition the store per user + +function cachePartition_perUser(sharedStore: ResponseCacheStore, userId: string) { + //#region cachePartition_perUser + const client = new Client( + { name: 'gateway', version: '1.0.0' }, + { responseCacheStore: sharedStore, cachePartition: userId } + ); + //#endregion cachePartition_perUser + return client; +} + +// ## Cache against servers that send no hints + +function defaultCacheTtlMs_optIn() { + //#region defaultCacheTtlMs_optIn + const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 }); + //#endregion defaultCacheTtlMs_optIn + return client; +} + +void responseCacheStore_shared; +void cachePartition_perUser; +void defaultCacheTtlMs_optIn; diff --git a/examples/guides/clients/calling.examples.ts b/examples/guides/clients/calling.examples.ts new file mode 100644 index 0000000000..b7aea737e0 --- /dev/null +++ b/examples/guides/clients/calling.examples.ts @@ -0,0 +1,202 @@ +/** + * Companion example for `docs/clients/calling.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness around the + * regions registers the in-memory `orders` server the page's client talks to + * and produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/calling.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { completable, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). The page documents the CLIENT verbs; this +// `orders` server only exists so every block has something real to call and +// every quoted output is real. Any MCP server behaves the same. +// --------------------------------------------------------------------------- + +const orders = [ + { id: 'A-1041', customer: 'Ada', items: 3, total: 61.5, currency: 'EUR', status: 'shipped' }, + { id: 'A-1042', customer: 'Lin', items: 1, total: 18, currency: 'EUR', status: 'open' } +]; + +const server = new McpServer({ name: 'orders', version: '1.0.0' }); + +server.registerTool( + 'lookup-order', + { + description: 'Look up one order by its id', + inputSchema: z.object({ id: z.string().describe('Order id, e.g. A-1041') }) + }, + async ({ id }) => { + const order = orders.find(candidate => candidate.id === id); + if (!order) throw new Error(`No order ${id}`); + return { content: [{ type: 'text', text: `${order.id}: ${order.items} items, ${order.status}` }] }; + } +); + +server.registerTool( + 'order-total', + { + description: 'Total of one order', + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ id: z.string(), total: z.number(), currency: z.string() }) + }, + async ({ id }) => { + const order = orders.find(candidate => candidate.id === id); + if (!order) throw new Error(`No order ${id}`); + const output = { id: order.id, total: order.total, currency: order.currency }; + return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; + } +); + +server.registerTool( + 'export-orders', + { + description: 'Export every order to the given format', + inputSchema: z.object({ format: z.string() }) + }, + async ({ format }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + for (const [index, order] of orders.entries()) { + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: index + 1, total: orders.length, message: `exported ${order.id}` } + }); + } + } + return { content: [{ type: 'text', text: `${orders.length} orders exported as ${format}` }] }; + } +); + +server.registerResource( + 'recent-orders', + 'orders://recent', + { description: 'Ids of the most recent orders', mimeType: 'application/json' }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(orders.map(order => order.id)) }] + }) +); + +server.registerPrompt( + 'summarize-order', + { + description: 'Write a status update for one order', + argsSchema: z.object({ + id: z.string().describe('Order id'), + tone: completable(z.string().describe('Writing tone'), value => + ['formal', 'friendly', 'terse'].filter(tone => tone.startsWith(value)) + ) + }) + }, + ({ id, tone }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Write a ${tone} status update for order ${id}.` } + } + ] + }) +); + +const client = new Client({ name: 'orders-cli', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "List the tools and call one" — the names and the content the page quotes. +//#region listTools_callTool +const { tools } = await client.listTools(); +console.log(tools.map(tool => tool.name)); + +const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } }); +console.log(result.content); +//#endregion listTools_callTool + +// --------------------------------------------------------------------------- +// "Let the SDK walk the pages". `McpServer` answers `tools/list` in one page, +// so the harness swaps in a handler that serves the SAME three definitions two +// per page — the aggregate walk and the per-page call below are both real. +// --------------------------------------------------------------------------- + +server.server.setRequestHandler('tools/list', async request => + request.params?.cursor === undefined ? { tools: tools.slice(0, 2), nextCursor: 'page-2' } : { tools: tools.slice(2) } +); + +// Proof for the page's aggregate claim: against the now-paginating server, +// the no-cursor call still returns every tool, with `nextCursor` cleared. +// Throws (non-zero exit) if the claim is false. +const walked = await client.listTools(); +if (walked.tools.length !== tools.length || walked.nextCursor !== undefined) { + throw new Error(`calling.md claim failed: aggregated walk returned ${JSON.stringify(walked)}`); +} + +// The cursor an earlier page of this server handed back in `nextCursor`. +const heldCursor = 'page-2'; + +// "Let the SDK walk the pages" — one raw page, the output the page quotes. +//#region listTools_onePage +const page = await client.listTools({ cursor: heldCursor }); +console.log(page.tools.map(tool => tool.name), page.nextCursor); +//#endregion listTools_onePage + +// "Read structured output" — the narrowed `structuredContent` the page quotes. +//#region callTool_structured +const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } }); + +const total: unknown = details.structuredContent; +if (typeof total === 'object' && total !== null && 'currency' in total) { + console.log(total); +} +//#endregion callTool_structured + +// "Read a resource" — the uri list and the contents the page quotes. +//#region readResource_basic +const { resources } = await client.listResources(); +console.log(resources.map(resource => resource.uri)); + +const { contents } = await client.readResource({ uri: 'orders://recent' }); +console.log(contents[0]); +//#endregion readResource_basic + +// "Get a prompt" — the prompt names and the filled-in messages the page quotes. +//#region getPrompt_basic +const { prompts } = await client.listPrompts(); +console.log(prompts.map(prompt => prompt.name)); + +const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } }); +console.log(prompt.messages); +//#endregion getPrompt_basic + +// "Autocomplete an argument" — the suggestions the page quotes. +//#region complete_tone +const { completion } = await client.complete({ + ref: { type: 'ref/prompt', name: 'summarize-order' }, + argument: { name: 'tone', value: 'f' } +}); +console.log(completion.values); +//#endregion complete_tone + +// "Track progress on a long call" — the progress updates and the final result. +//#region callTool_progress +const exported = await client.callTool( + { name: 'export-orders', arguments: { format: 'csv' } }, + { + onprogress: update => console.log(update), + resetTimeoutOnProgress: true, + maxTotalTimeout: 600_000 + } +); +console.log(exported.content); +//#endregion callTool_progress + +await client.close(); +await server.close(); diff --git a/examples/guides/clients/connect.examples.ts b/examples/guides/clients/connect.examples.ts new file mode 100644 index 0000000000..d277f5e357 --- /dev/null +++ b/examples/guides/clients/connect.examples.ts @@ -0,0 +1,116 @@ +/** + * Runnable, type-checked companion for `docs-v2/clients/connect.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). + * + * The page's main program — connect over Streamable HTTP, read what the server + * told you, close — runs for real: the harness below builds a `createMcpHandler` + * server and routes `globalThis.fetch` for `http://localhost:3000/mcp` into + * `handler.fetch`, so the HTTP regions execute in-process without binding a + * port. The output the page quotes verbatim is whatever this file prints. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/connect.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { SSEClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A `createMcpHandler` server answers the +// page's `http://localhost:3000/mcp` URL in-process: `globalThis.fetch` for +// that host is routed into `handler.fetch`, so the page's HTTP regions run +// verbatim against a real Streamable HTTP server without binding a port. +// --------------------------------------------------------------------------- + +const handler = createMcpHandler(() => { + const server = new McpServer( + { name: 'travel', version: '2.1.0' }, + { instructions: 'Call list-trips before book-trip. Dates are ISO 8601.' } + ); + server.registerTool( + 'list-trips', + { description: 'List the trips on file', inputSchema: z.object({ year: z.number().int() }) }, + async ({ year }) => ({ content: [{ type: 'text', text: `No trips in ${year}` }] }) + ); + return server; +}); + +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request); + return realFetch(input, init); +}) as typeof fetch; + +// ## Create a client and connect over HTTP + +//#region connect_streamableHttp +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + +await client.connect(transport); +//#endregion connect_streamableHttp + +// ## Read what the server told you at connect time + +//#region connect_introspect +console.log(client.getServerVersion()); +console.log(client.getServerCapabilities()); +console.log(client.getInstructions()); +//#endregion connect_introspect + +// ## Disconnect cleanly + +//#region connect_close +await transport.terminateSession(); +await client.close(); +//#endregion connect_close + +await handler.close(); +globalThis.fetch = realFetch; + +// --------------------------------------------------------------------------- +// The remaining transports cannot run inside this self-terminating harness — +// stdio spawns a process, the SSE fallback needs a legacy server — so their +// regions live in wrapper functions that typecheck but are never called. +// --------------------------------------------------------------------------- + +// ## Connect to a local process over stdio + +async function connect_stdio() { + //#region connect_stdio + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] }); + + await client.connect(transport); + //#endregion connect_stdio +} + +// ## Fall back to SSE for legacy servers + +async function connect_sseFallback(url: string) { + //#region connect_sseFallback + try { + const client = new Client({ name: 'my-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + return client; + } catch { + const client = new Client({ name: 'my-client', version: '1.0.0' }); + await client.connect(new SSEClientTransport(new URL(url))); + return client; + } + //#endregion connect_sseFallback +} + +void connect_stdio; +void connect_sseFallback; diff --git a/examples/guides/clients/machine-auth.examples.ts b/examples/guides/clients/machine-auth.examples.ts new file mode 100644 index 0000000000..799944511b --- /dev/null +++ b/examples/guides/clients/machine-auth.examples.ts @@ -0,0 +1,89 @@ +// docs: typecheck-only +/** + * Type-checked companion for `docs-v2/clients/machine-auth.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). Every flow + * on the page needs a live authorization server, so there is nothing meaningful + * to run — the regions only typecheck. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { AuthProvider } from '@modelcontextprotocol/client'; +import { CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; + +// "Authenticate with client credentials" — the page's lead block. The import line +// is part of the region so the first fence on the page names where the providers +// come from. +//#region clientCredentials_connect +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const authProvider = new ClientCredentialsProvider({ + clientId: 'reporting-job', + clientSecret: 'reporting-job-secret' +}); + +const client = new Client({ name: 'reporting-job', version: '1.0.0' }); +const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + +await client.connect(transport); +//#endregion clientCredentials_connect + +// "Bring your own bearer token" — the minimal AuthProvider shape: token() only. +function bearerToken_provider(getStoredToken: () => Promise<string>) { + //#region bearerToken_provider + const authProvider: AuthProvider = { token: async () => getStoredToken() }; + + const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + //#endregion bearerToken_provider + return transport; +} + +// "Sign with a private key instead of a secret" — private_key_jwt token-endpoint auth. +function privateKeyJwt_provider(pemEncodedKey: string) { + //#region privateKeyJwt_provider + const authProvider = new PrivateKeyJwtProvider({ + clientId: 'reporting-job', + privateKey: pemEncodedKey, + algorithm: 'RS256' + }); + + const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + //#endregion privateKeyJwt_provider + return transport; +} + +// "Act for an enterprise user with cross-app access" — SEP-990 / Enterprise +// Managed Authorization. The assertion callback turns the IdP ID Token into a +// JWT Authorization Grant; the provider exchanges that for the access token. +function crossAppAccess_provider(getIdToken: () => Promise<string>) { + //#region crossAppAccess_provider + const authProvider = new CrossAppAccessProvider({ + assertion: async ctx => { + const grant = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: ctx.authorizationServerUrl, + resource: ctx.resourceUrl, + idToken: await getIdToken(), + clientId: 'idp-exchange-client', + clientSecret: 'idp-exchange-secret', + scope: ctx.scope, + fetchFn: ctx.fetchFn + }); + return grant.jwtAuthGrant; + }, + clientId: 'reporting-job', + clientSecret: 'reporting-job-secret' + }); + + const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); + //#endregion crossAppAccess_provider + return transport; +} + +void bearerToken_provider; +void privateKeyJwt_provider; +void crossAppAccess_provider; diff --git a/examples/guides/clients/middleware.examples.ts b/examples/guides/clients/middleware.examples.ts new file mode 100644 index 0000000000..aaa769041c --- /dev/null +++ b/examples/guides/clients/middleware.examples.ts @@ -0,0 +1,138 @@ +/** + * Companion example for `docs/clients/middleware.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness at the + * bottom routes every middleware stack into an in-process `createMcpHandler` + * — no port, no socket — and produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/middleware.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { OAuthClientProvider } from '@modelcontextprotocol/client'; +import { Client, withLogging, withOAuth } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// "Write a middleware" +// --------------------------------------------------------------------------- + +//#region middleware_create +import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const tagRequests = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Request-Source', 'reports-cli'); + return next(input, { ...init, headers }); +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(tagRequests)(fetch) +}); +//#endregion middleware_create +void transport; + +// --------------------------------------------------------------------------- +// "Compose several middlewares" — the stub base fetch makes the order +// observable without any network. +// --------------------------------------------------------------------------- + +//#region middleware_order +const stamp = (name: string) => + createMiddleware(async (next, input, init) => { + console.log(`-> ${name}`); + const response = await next(input, init); + console.log(`<- ${name}`); + return response; + }); + +const base = async () => new Response('ok'); +await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp'); +//#endregion middleware_order + +// --------------------------------------------------------------------------- +// "Use the built-in logging middleware" +// --------------------------------------------------------------------------- + +//#region middleware_logging +const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch); +//#endregion middleware_logging +void loggedFetch; + +// --------------------------------------------------------------------------- +// "Combine middleware with an auth provider" — never invoked: a working +// `OAuthClientProvider` is the reader's, not this program's (docs/clients/oauth.md). +// --------------------------------------------------------------------------- + +/** Example: OAuth expressed as one layer of a middleware stack. */ +function authenticatedTransport(provider: OAuthClientProvider) { + //#region middleware_withOAuth + const serverUrl = new URL('http://localhost:3000/mcp'); + const authed = new StreamableHTTPClientTransport(serverUrl, { + fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch) + }); + //#endregion middleware_withOAuth + return authed; +} +void authenticatedTransport; + +// --------------------------------------------------------------------------- +// "Inspect the response" +// --------------------------------------------------------------------------- + +//#region middleware_inspect +const observeStatus = createMiddleware(async (next, input, init) => { + const response = await next(input, init); + if (typeof init?.body === 'string') { + const { method } = JSON.parse(init.body) as { method?: string }; + console.log(`${method ?? 'response'} -> HTTP ${response.status}`); + } + return response; +}); +//#endregion middleware_inspect + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-process `createMcpHandler` stands in +// for the network: `serverFetch` has the same shape as global `fetch`, so the +// exact middleware values defined in the regions above compose onto it +// unchanged. Each block connects a real Client over Streamable HTTP and calls +// one tool; the console output is what the page quotes verbatim. +// --------------------------------------------------------------------------- + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'reports', version: '1.0.0' }); + server.registerTool( + 'ping', + { description: 'Reply with pong', inputSchema: z.object({ tag: z.string() }) }, + async ({ tag }) => ({ content: [{ type: 'text', text: `pong ${tag}` }] }) + ); + return server; +}); +type AnyFetch = (url: string | URL, init?: RequestInit) => Promise<Response>; +const serverFetch: AnyFetch = (url, init) => handler.fetch(new Request(url, init)); + +async function drive(fetchImpl: AnyFetch): Promise<void> { + const client = new Client({ name: 'middleware-docs-harness', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { fetch: fetchImpl })); + await client.callTool({ name: 'ping', arguments: { tag: 'docs' } }); + await client.close(); +} + +// "Use the built-in logging middleware" — the lines the page quotes. The default +// logger derives each duration from `performance.now()`; pin it while this stack +// runs so the quoted output is reproducible byte for byte. +console.log('--- withLogging'); +const realNow = performance.now.bind(performance); +performance.now = () => 0; +await drive(applyMiddlewares(tagRequests, withLogging())(serverFetch)); +performance.now = realNow; + +// "Inspect the response" — the method -> status lines the page quotes. +console.log('--- inspect'); +await drive(applyMiddlewares(observeStatus)(serverFetch)); + +await handler.close(); diff --git a/examples/guides/clients/oauth.examples.ts b/examples/guides/clients/oauth.examples.ts new file mode 100644 index 0000000000..e740c45d7d --- /dev/null +++ b/examples/guides/clients/oauth.examples.ts @@ -0,0 +1,181 @@ +// docs: typecheck-only +/** + * Type-checked companion for `docs/clients/oauth.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The page is + * the user-facing authorization-code flow — finishing it needs a browser and an + * authorization server, so nothing here runs in CI; the file only typechecks. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { + OAuthClientInformationContext, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; +import { + checkResourceAllowed, + Client, + IssuerMismatchError, + resourceUrlFromServerUrl, + StreamableHTTPClientTransport, + UnauthorizedError +} from '@modelcontextprotocol/client'; + +// Stand-ins for the host application: how it opens a browser, and how its +// callback endpoint hands the redirect URL back to this code. +declare function onRedirect(url: URL): void; +declare function waitForCallback(): Promise<string>; + +// --------------------------------------------------------------------------- +// "Implement OAuthClientProvider" — the page shows this class AFTER the +// transport that uses it; it lives first here so every region below can see it. +// --------------------------------------------------------------------------- + +//#region MyOAuthProvider_class +class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map<string, OAuthClientInformationMixed>(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; + + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } +} +//#endregion MyOAuthProvider_class + +// --------------------------------------------------------------------------- +// "Hand the transport an OAuth provider" +// --------------------------------------------------------------------------- + +/** Example: connect with an `authProvider`; an auth-gated server throws `UnauthorizedError`. */ +async function authProvider_connect() { + //#region authProvider_connect + const provider = new MyOAuthProvider(); + const client = new Client({ name: 'my-app', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { + authProvider: provider + }); + + try { + await client.connect(transport); + } catch (error) { + if (!(error instanceof UnauthorizedError)) throw error; + // The transport already called provider.redirectToAuthorization(url): + // the end user is in the browser, at the authorization server. + } + //#endregion authProvider_connect + return { client, provider, transport }; +} + +// --------------------------------------------------------------------------- +// "Finish the flow from the callback" +// --------------------------------------------------------------------------- + +/** Example: state check, code exchange, reconnect on a fresh transport. */ +async function finishAuth_callback() { + const { client, provider, transport } = await authProvider_connect(); + const url = new URL('https://api.example.com/mcp'); + //#region finishAuth_callback + const callbackUrl = await waitForCallback(); // however your app receives the redirect + const params = new URL(callbackUrl).searchParams; + + // The SDK does not validate `state` — compare it to the value your provider generated. + if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + + await transport.finishAuth(params); + + // Reconnect on a FRESH transport — a started transport cannot be restarted. + // OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. + await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); + //#endregion finishAuth_callback +} + +// --------------------------------------------------------------------------- +// "Handle issuer mismatch" +// --------------------------------------------------------------------------- + +/** Example: the RFC 9207 mix-up defense around `finishAuth`. */ +async function finishAuth_issuerMismatch(transport: StreamableHTTPClientTransport, params: URLSearchParams) { + //#region finishAuth_issuerMismatch + try { + await transport.finishAuth(params); + } catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: never render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; + } + //#endregion finishAuth_issuerMismatch +} + +// --------------------------------------------------------------------------- +// "Pin the resource indicator" +// --------------------------------------------------------------------------- + +//#region validateResourceURL_pin +class PinnedResourceProvider extends MyOAuthProvider { + async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> { + const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) + if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { + throw new Error(`Refusing resource ${resource} for server ${expected.href}`); + } + return expected; + } +} +//#endregion validateResourceURL_pin diff --git a/examples/guides/clients/roots.examples.ts b/examples/guides/clients/roots.examples.ts new file mode 100644 index 0000000000..9e36f72955 --- /dev/null +++ b/examples/guides/clients/roots.examples.ts @@ -0,0 +1,70 @@ +/** + * Companion example for `docs/clients/roots.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory server that requests `roots/list` and produces + * the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/roots.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region roots_capability +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client( + { name: 'workspace-client', version: '1.0.0' }, + { capabilities: { roots: { listChanged: true } } } +); +//#endregion roots_capability + +//#region roots_listHandler +const roots = [ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' } +]; + +client.setRequestHandler('roots/list', async () => { + return { roots }; +}); +//#endregion roots_listHandler + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory low-level Server plays the +// counterpart: it requests `roots/list`, and requests it again when the client +// sends `notifications/roots/list_changed`. Any MCP server behaves the same. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Server } = await import('@modelcontextprotocol/server'); +const { InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const server = new Server({ name: 'roots-docs-harness', version: '1.0.0' }); + +const relisted = new Promise<void>(resolve => { + server.setNotificationHandler('notifications/roots/list_changed', async () => { + console.log((await server.listRoots()).roots); + resolve(); + }); +}); + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Answer roots/list" — the list the page quotes. +console.log((await server.listRoots()).roots); + +// "Tell the server when the roots change" — the notification triggers the +// harness's re-list above, whose output the page quotes. +//#region roots_listChanged +roots.push({ uri: 'file:///home/user/projects/another-app', name: 'Another app' }); +await client.sendRootsListChanged(); +//#endregion roots_listChanged + +await relisted; +await client.close(); +await server.close(); diff --git a/examples/guides/clients/server-requests.examples.ts b/examples/guides/clients/server-requests.examples.ts new file mode 100644 index 0000000000..00e595a5e7 --- /dev/null +++ b/examples/guides/clients/server-requests.examples.ts @@ -0,0 +1,176 @@ +/** + * Companion example for `docs/clients/server-requests.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects this client over an in-memory transport pair to a server + * whose tools elicit input and request sampling, and produces the output the + * page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/server-requests.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region Client_capabilities +import { Client } from '@modelcontextprotocol/client'; + +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {}, url: {} } + } + } +); +//#endregion Client_capabilities + +// "Handle an elicitation request" — one handler, both modes. +//#region setRequestHandler_elicitation +client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode === 'url') { + // Open request.params.url in the user's browser; answer when they finish. + return { action: 'accept' }; + } + // Render request.params.requestedSchema as a form; return what the user entered. + return { action: 'accept', content: { city: 'Lisbon' } }; +}); +//#endregion setRequestHandler_elicitation + +// "Handle a sampling request" — a canned model stands in for a real provider. +//#region setRequestHandler_sampling +client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage?.content); + + // In production, run the messages through your model here. + return { + model: 'host-model', + role: 'assistant', + content: { type: 'text', text: 'One travel mug to Lisbon.' } + }; +}); +//#endregion setRequestHandler_sampling + +// "Cap or disable automatic fulfilment" — the same constructor with the +// `inputRequired` option added. Wrapped so the file keeps a single live client. +function Client_inputRequired() { + //#region Client_inputRequired + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { sampling: {}, elicitation: { form: {}, url: {} } }, + inputRequired: { maxRounds: 3 } + } + ); + //#endregion Client_inputRequired + return client; +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A server whose tools elicit input and +// request sampling drives the handlers above over an in-memory transport pair. +// Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { getSupportedElicitationModes, InMemoryTransport } = await import('@modelcontextprotocol/client'); +const { McpServer } = await import('@modelcontextprotocol/server'); +const z = await import('zod/v4'); + +const server = new McpServer({ name: 'orders', version: '1.0.0' }); + +// Form-mode elicitation: docs/servers/elicitation.md owns this side. +server.registerTool( + 'place-order', + { + description: 'Place an order after collecting a shipping city', + inputSchema: z.object({ item: z.string() }) + }, + async ({ item }, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Where should we ship the ${item}?`, + requestedSchema: { + type: 'object', + properties: { city: { type: 'string', title: 'City' } }, + required: ['city'] + } + }); + if (answer.action !== 'accept') { + return { content: [{ type: 'text', text: `Order ${answer.action}.` }] }; + } + return { content: [{ type: 'text', text: `Order placed: ${item} ships to ${answer.content?.city}.` }] }; + } +); + +// URL-mode elicitation: exercises the handler's `url` branch. +server.registerTool( + 'link-card', + { + description: 'Link a payment card through a hosted flow', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ + mode: 'url', + message: `Sign in to ${provider} to link your card`, + url: `https://pay.example.com/link/${encodeURIComponent(provider)}`, + elicitationId: crypto.randomUUID() + }); + return { content: [{ type: 'text', text: `Card link: ${answer.action}.` }] }; + } +); + +// Sampling: docs/servers/sampling.md owns this side. +server.registerTool( + 'summarize-order', + { + description: 'Summarize the latest order with the host model', + inputSchema: z.object({ order: z.string() }) + }, + async ({ order }, ctx) => { + const response = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: `Summarize this order: ${order}` } }], + maxTokens: 200 + }); + const block = Array.isArray(response.content) ? response.content[0] : response.content; + const summary = block?.type === 'text' ? block.text : '(non-text)'; + return { content: [{ type: 'text', text: `${response.model}: ${summary}` }] }; + } +); + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Handle an elicitation request" — the form round trip the page quotes. +const placed = await client.callTool({ name: 'place-order', arguments: { item: 'Travel mug' } }); +console.log(placed.content); + +// "Handle a sampling request" — the handler logs the prompt it received, then +// the tool result carries the handler's completion. Both lines are quoted. +const summarized = await client.callTool({ name: 'summarize-order', arguments: { order: '1 Travel mug to Lisbon' } }); +console.log(summarized.content); + +// Proof for the elicitation section's `url` branch — not quoted on the page, +// but the run fails (non-zero exit) if the branch stops working. +const linked = await client.callTool({ name: 'link-card', arguments: { provider: 'examplepay' } }); +const linkedText = linked.content?.[0]; +if (linkedText?.type !== 'text' || linkedText.text !== 'Card link: accept.') { + throw new Error(`server-requests.md url-branch claim failed: ${JSON.stringify(linked.content)}`); +} + +// Proof for the page's ::: tip — an empty `elicitation: {}` capability means +// form mode only; `url` has to be declared explicitly. +const modes = getSupportedElicitationModes({}); +if (modes.supportsFormMode !== true || modes.supportsUrlMode !== false) { + throw new Error(`server-requests.md tip claim failed: ${JSON.stringify(modes)}`); +} + +// Keep the round-cap constructor type-checked (its region is page-only). +void Client_inputRequired; + +await client.close(); +await server.close(); diff --git a/examples/guides/clients/subscriptions.examples.ts b/examples/guides/clients/subscriptions.examples.ts new file mode 100644 index 0000000000..48ed631733 --- /dev/null +++ b/examples/guides/clients/subscriptions.examples.ts @@ -0,0 +1,199 @@ +/** + * Companion example for `docs/clients/subscriptions.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness drives a + * `createMcpHandler` entry in process — no port, no socket — over a + * 2026-07-28 connection, publishes the changes, and produces the output the + * page quotes verbatim. It exits non-zero if a quoted line never appears. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/clients/subscriptions.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Harness server (not shown on the page). A per-request factory whose tool +// set and `config://app` resource the harness mutates between publishes. +// `subscriptions/listen` is served by the entry, so the page's client talks +// to `handler.fetch` directly — the URL is never dialed. +// --------------------------------------------------------------------------- + +let settings = { theme: 'light' }; +let archiveEnabled = false; + +function buildNotesServer(): McpServer { + const server = new McpServer( + { name: 'notes', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true }, resources: { subscribe: true } } } + ); + server.registerTool( + 'search-notes', + { description: 'Search notes', inputSchema: z.object({ query: z.string() }) }, + async ({ query }) => ({ + content: [{ type: 'text', text: `no notes match ${query}` }] + }) + ); + if (archiveEnabled) { + server.registerTool('archive-note', { description: 'Archive a note' }, async () => ({ + content: [{ type: 'text', text: 'archived' }] + })); + } + server.registerResource('config', 'config://app', { mimeType: 'application/json' }, async uri => ({ + contents: [{ uri: uri.href, text: JSON.stringify(settings) }] + })); + return server; +} + +// Each leg gets its own handler instance (and therefore its own event bus), +// so a publish meant for one leg cannot reach the other. +const handler = createMcpHandler(buildNotesServer); +const autoHandler = createMcpHandler(buildNotesServer); + +/** Connect `client` to `target` over the 2026-07-28 revision, in process. */ +async function connect(client: Client, target: typeof handler): Promise<void> { + client.setVersionNegotiation({ mode: 'auto' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => target.fetch(new Request(url, init)) + }) + ); +} + +// Every `console.log` line is also recorded so the harness can wait for the +// exact output the page quotes (and fail loudly if it never arrives). +const logged: string[] = []; +const realLog = console.log.bind(console); +console.log = (...args: unknown[]) => { + logged.push(args.map(String).join(' ')); + realLog(...args); +}; +/** Wait until a logged line starts with `prefix`, or throw after 5 s. */ +async function loggedLine(prefix: string): Promise<void> { + const deadline = Date.now() + 5000; + while (!logged.some(line => line.startsWith(prefix))) { + if (Date.now() > deadline) throw new Error(`timed out waiting for output line "${prefix}"`); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} + +const client = new Client({ name: 'notes-watcher', version: '1.0.0' }); +await connect(client, handler); + +// --------------------------------------------------------------------------- +// "Open a subscription stream" — the honored-filter line the page quotes. +// --------------------------------------------------------------------------- + +//#region listen_open +client.setNotificationHandler('notifications/tools/list_changed', async () => { + const { tools } = await client.listTools(); + console.log('Tools changed:', tools.length); +}); + +const subscription = await client.listen({ + toolsListChanged: true, + resourceSubscriptions: ['config://app'] +}); +console.log('Server honored:', subscription.honoredFilter); +//#endregion listen_open + +// --------------------------------------------------------------------------- +// "Handle the notifications" — the second handler, then one publish of each +// kind. Both notification lines the page quotes come from these handlers. +// --------------------------------------------------------------------------- + +//#region listen_updated +client.setNotificationHandler('notifications/resources/updated', async notification => { + const { contents } = await client.readResource({ uri: notification.params.uri }); + console.log('Updated', notification.params.uri, contents); +}); +//#endregion listen_updated + +archiveEnabled = true; +handler.notify.toolsChanged(); +await loggedLine('Tools changed:'); + +settings = { theme: 'dark' }; +handler.notify.resourceUpdated('config://app'); +await loggedLine('Updated config://app'); + +// --------------------------------------------------------------------------- +// "Close the stream and react to closure" — the close reason the page quotes. +// --------------------------------------------------------------------------- + +//#region listen_close +await subscription.close(); +console.log('Closed:', await subscription.closed); +//#endregion listen_close + +await client.close(); +await handler.close(); + +/** Example: re-listen only on an unexpected disconnect. Never invoked — the page's watch-loop block. */ +async function watchConfig(watching: boolean): Promise<void> { + //#region listen_watchLoop + while (watching) { + const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); + const reason = await sub.closed; + if (reason !== 'remote') break; // 'local' or 'graceful': done + await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen + } + //#endregion listen_watchLoop +} +void watchConfig; + +// --------------------------------------------------------------------------- +// "Let the SDK open the stream for you" — a second client whose stream the +// SDK opens from the `listChanged` option. The harness publishes one more +// tool change to produce the `onChanged` line the page quotes. +// --------------------------------------------------------------------------- + +//#region listChanged_auto +const watcher = new Client( + { name: 'notes-watcher', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Refresh failed:', error); + return; + } + console.log('Tools refreshed:', tools?.length); + } + } + } + } +); +//#endregion listChanged_auto + +await connect(watcher, autoHandler); +if (watcher.autoOpenedSubscription === undefined) { + throw new Error('listChanged should auto-open a subscription stream on a 2026-07-28 connection'); +} +console.log('Auto-opened:', watcher.autoOpenedSubscription.honoredFilter); + +archiveEnabled = false; +autoHandler.notify.toolsChanged(); +await loggedLine('Tools refreshed:'); + +await watcher.autoOpenedSubscription.close(); +await watcher.close(); +await autoHandler.close(); + +/** Example: the 2025-era per-resource path. Never invoked — `resources/subscribe` is not part of 2026-07-28. */ +async function legacySubscribe(client: Client): Promise<void> { + //#region subscribeResource_legacy + await client.subscribeResource({ uri: 'config://app' }); + + // The same notifications/resources/updated handler fires. + + await client.unsubscribeResource({ uri: 'config://app' }); + //#endregion subscribeResource_legacy +} +void legacySubscribe; From 99770bd122ad2c5833bcc06e34872959067d0dd7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:01 +0000 Subject: [PATCH 08/27] docs: write the serving guide pages --- docs/serving/authorization.md | 115 ++++++++++---- docs/serving/express.md | 95 ++++++++---- docs/serving/fastify.md | 92 +++++++---- docs/serving/hono.md | 94 ++++++++---- docs/serving/http.md | 143 +++++++++++++----- docs/serving/legacy-clients.md | 110 ++++++++++---- docs/serving/sessions-state-scaling.md | 107 +++++++++---- docs/serving/stdio.md | 89 +++++++---- docs/serving/web-standard.md | 91 +++++++---- .../guides/serving/authorization.examples.ts | 79 ++++++++++ examples/guides/serving/express.examples.ts | 94 ++++++++++++ examples/guides/serving/fastify.examples.ts | 91 +++++++++++ examples/guides/serving/hono.examples.ts | 79 ++++++++++ examples/guides/serving/http.examples.ts | 139 +++++++++++++++++ .../guides/serving/legacy-clients.examples.ts | 107 +++++++++++++ .../sessions-state-scaling.examples.ts | 89 +++++++++++ examples/guides/serving/stdio.examples.ts | 116 ++++++++++++++ .../guides/serving/webStandard.examples.ts | 104 +++++++++++++ examples/package.json | 3 + examples/tsconfig.json | 8 + pnpm-lock.yaml | 13 +- 21 files changed, 1575 insertions(+), 283 deletions(-) create mode 100644 examples/guides/serving/authorization.examples.ts create mode 100644 examples/guides/serving/express.examples.ts create mode 100644 examples/guides/serving/fastify.examples.ts create mode 100644 examples/guides/serving/hono.examples.ts create mode 100644 examples/guides/serving/http.examples.ts create mode 100644 examples/guides/serving/legacy-clients.examples.ts create mode 100644 examples/guides/serving/sessions-state-scaling.examples.ts create mode 100644 examples/guides/serving/stdio.examples.ts create mode 100644 examples/guides/serving/webStandard.examples.ts diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 47c211c481..374e3cf22d 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -1,59 +1,108 @@ --- -status: scaffold shape: how-to --- # Require authorization -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router. -teaches: requireBearerAuth, OAuthTokenVerifier, mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl, ctx.http.authInfo, per-tool scope checks -source: mined from docs/server.md "Authorization (OAuth resource server)" (the best long example in the set — lens 89); examples/scoped-tools/README.md; examples/bearer-auth/ ---> - -<!-- opening (before any H2) — the one-line auth router, mandatory: -Protecting a server you run -> this page. Signing a user in from a client -> /clients/oauth. No user present -> /clients/machine-auth. --> +Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md). ## Require a bearer token -<!-- teaches: requireBearerAuth({ verifier, requiredScopes, resourceMetadataUrl }) in front of the MCP route; your server is an OAuth RESOURCE server — it verifies tokens, it never issues them | salvage: docs/server.md "Authorization (OAuth resource server)" lead --> -```ts -// draft - API verified against packages/middleware/express/src/auth/bearerAuth.ts (requireBearerAuth) and packages/middleware/express/src/auth/metadataRouter.ts (getOAuthProtectedResourceMetadataUrl) -import { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +Your MCP server is an OAuth **resource server**: it verifies access tokens that an authorization server issued, and it never issues them. `requireBearerAuth` from `@modelcontextprotocol/express` is that whole gate — build it from a verifier and mount it in front of the `/mcp` route from the [Express recipe](./express.md). + +```ts source="../../examples/guides/serving/authorization.examples.ts#requireBearerAuth_basic" +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -// continuing from the Express recipe: `verifier`, `app`, and `node` already exist const mcpServerUrl = new URL('https://api.example.com/mcp'); +const verifier: OAuthTokenVerifier = { verifyAccessToken }; const auth = requireBearerAuth({ - verifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + verifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) }); +const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +const node = toNodeHandler(createMcpHandler(buildServer)); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` -<!-- result: one line — a request without a valid token gets 401 invalid_token with a WWW-Authenticate: Bearer challenge --> + +A request with a missing, malformed, or expired token gets `401` with the OAuth error code `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. Both responses carry a `WWW-Authenticate: Bearer …` challenge whose `resource_metadata` parameter is the URL you passed — that challenge is what starts a client's OAuth flow. + +::: info Coming from v1? +The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half. +::: ## Verify tokens your way -<!-- teaches: OAuthTokenVerifier — verifyAccessToken(token) -> AuthInfo; JWT verification, RFC 7662 introspection, or a call to your IdP | salvage: docs/server.md auth_resourceServer region (verifier half) --> -<!-- code: const verifier: OAuthTokenVerifier = { async verifyAccessToken(token) { ... return { token, clientId, scopes, expiresAt } } } --> + +`verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it. + +```ts source="../../examples/guides/serving/authorization.examples.ts#tokenVerifier_basic" +async function verifyAccessToken(token: string): Promise<AuthInfo> { + const payload = await verifyJwt(token); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; +} +``` + +Throw an `OAuthError` with `OAuthErrorCode.InvalidToken` (both from `@modelcontextprotocol/server`) for a token you reject, and `requireBearerAuth` turns it into the `401` challenge. Any other exception comes back as `500 server_error`. + +::: warning +`requireBearerAuth` also answers `401 invalid_token` for a token whose `expiresAt` is unset. Always populate it — from the JWT `exp` claim or the introspection response's `exp` field. +::: ## Publish protected resource metadata -<!-- teaches: mcpAuthMetadataRouter serves /.well-known/oauth-protected-resource (RFC 9728) so clients can discover your AS; the 401 challenge's resource_metadata points at it | salvage: docs/server.md auth_resourceServer region (metadata half) --> -<!-- code: app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })) --> + +`mcpAuthMetadataRouter` serves the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document that the `401` challenge points at. `oauthMetadata` is your authorization server's own RFC 8414 metadata document. + +```ts source="../../examples/guides/serving/authorization.examples.ts#metadataRouter_basic" +app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); +``` + +The router mounts two well-known routes: `/.well-known/oauth-protected-resource/mcp` — the path-aware RFC 9728 location, the same string `getOAuthProtectedResourceMetadataUrl(mcpServerUrl)` put into the challenge — and `/.well-known/oauth-authorization-server`, a mirror of `oauthMetadata` for clients that probe your origin directly. An unauthenticated client follows `401` → `resource_metadata` → `authorization_servers` to find your AS, obtains a token, and retries. ## Read the caller in your handlers -<!-- teaches: requireBearerAuth sets req.auth; toNodeHandler forwards it; tool handlers read ctx.http.authInfo and factories read ctx.authInfo | salvage: docs/server.md "requireBearerAuth attaches the verified AuthInfo..." paragraph --> -<!-- code: async (args, ctx) => { const who = ctx.http?.authInfo?.clientId; ... } --> + +`requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`, `toNodeHandler` forwards it, and tool handlers inside `buildServer` read it as `ctx.http.authInfo` — the exact object your verifier returned. + +```ts source="../../examples/guides/serving/authorization.examples.ts#authInfo_handler" +server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => { + const caller = ctx.http?.authInfo; + return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] }; +}); +``` + +`ctx.http` is `undefined` when the same server runs over [stdio](./stdio.md), so guard the read if your server serves both transports. + +::: tip +The per-request factory itself receives the same value as `ctx.authInfo`, so it can register a different tool set per caller before any handler runs. +::: ## Enforce per-tool scopes -<!-- teaches: requiredScopes gates the whole endpoint; per-tool scopes are checked in the handler against ctx.http?.authInfo?.scopes, returning isError with insufficient_scope | salvage: examples/scoped-tools/README.md --> -<!-- code: if (!ctx.http?.authInfo?.scopes?.includes('files:write')) return { content: [...], isError: true } --> -<!-- aside: SEP-2350 scope step-up (the client retries after a 403 insufficient_scope challenge) — one line, link /clients/oauth --> + +`requiredScopes` gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing. + +```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_handler" +server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => { + if (!ctx.http?.authInfo?.scopes.includes('notes:write')) { + return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true }; + } + return { content: [{ type: 'text', text: 'All notes deleted' }] }; +}); +``` + +A caller holding only `mcp` gets an ordinary tool result with `isError: true`, so the model reads the refusal and moves on instead of losing the connection. + +::: info +Responding `403 insufficient_scope` at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see [Authenticate a user with OAuth](../clients/oauth.md). +::: ## Recap -<!-- the claims this page proves: -- requireBearerAuth + an OAuthTokenVerifier turn any Express-mounted MCP route into an OAuth resource server. -- The SDK never issues tokens; AS helpers live frozen in @modelcontextprotocol/server-legacy/auth. -- mcpAuthMetadataRouter publishes the RFC 9728 document the 401 challenge points at. -- Validated auth flows req.auth -> ctx.http.authInfo; per-tool scopes are a handler check. ---> + +- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. +- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge. +- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. +- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool scopes are a check inside the handler that returns `isError: true`. +- The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. diff --git a/docs/serving/express.md b/docs/serving/express.md index 6bd56e0400..36a61a2ab7 100644 --- a/docs/serving/express.md +++ b/docs/serving/express.md @@ -1,63 +1,92 @@ --- -status: scaffold shape: how-to --- # Serve with Express -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Express recipe — self-contained, install one-liner at top, one back-link to http.md. -teaches: createMcpExpressApp, toNodeHandler, express.json -> req.body, allowedHosts -source: mined from docs/server.md "Serving the 2026-07-28 draft revision over HTTP" (Express mount line) + "DNS rebinding protection"; packages/middleware/express/README.md ---> - ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express ``` ## Mount the handler -<!-- teaches: toNodeHandler + app.all('/mcp') | salvage: docs/server.md createMcpHandler_node region (Express variant) --> -<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> -```ts -// draft - API verified against packages/middleware/express/src/express.ts, packages/middleware/node/src/toNodeHandler.ts, packages/server/src/server/createMcpHandler.ts +`createMcpHandler` turns a server factory into a web-standard HTTP handler, and `toNodeHandler` adapts it once to Express's `(req, res)`. `createMcpExpressApp` is `express()` with `express.json()` and DNS rebinding protection already applied. + +```ts source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_mount" import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import express from 'express'; +import * as z from 'zod/v4'; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) - return server; + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; }); const app = createMcpExpressApp(); -app.use(express.json()); - const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); - -app.listen(3000); ``` -<!-- result: one line — http://127.0.0.1:3000/mcp answers MCP POSTs --> + +`app` is an ordinary Express app with one route — `/mcp` answers every MCP request — and nothing is listening yet. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding -<!-- teaches: createMcpExpressApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> -<!-- code: createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpExpressApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. + +Binding to all interfaces drops that default — name the hosts you serve instead. + +```ts source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_allowedHosts" +const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +``` + +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body -<!-- teaches: the third toNodeHandler arg is the parsed body (express.json -> req.body); requireBearerAuth sets req.auth and toNodeHandler forwards it to ctx.http.authInfo --> -<!-- code: app.all('/mcp', auth, (req, res) => void node(req, res, req.body)) — one line; link /serving/authorization --> + +`createMcpExpressApp` installed `express.json()`, so `req.body` is the parsed body — passing it as `toNodeHandler`'s third argument keeps the adapter from re-reading the stream Express already consumed. `requireBearerAuth` verifies the bearer token and attaches the result to `req.auth`; `toNodeHandler` forwards it, and handlers read it as `ctx.http.authInfo`. + +```ts source="../../examples/guides/serving/express.examples.ts#requireBearerAuth_mount" +import { requireBearerAuth } from '@modelcontextprotocol/express'; + +const auth = requireBearerAuth({ verifier }); +publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body)); +``` + +`verifier` is your token verification. [Authorization](./authorization.md) covers writing one, requiring scopes, and serving the OAuth metadata documents. ## Run it and verify -<!-- teaches: start the process, point the Inspector (or curl) at http://127.0.0.1:3000/mcp --> -<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> -<!-- result: verbatim tools/list output --> + +Add the listen line and start the process (`npx tsx server.ts`). + +```ts source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_listen" +app.listen(3000); +``` + +POST a `tools/list` request to the endpoint. + +```sh +curl -s -X POST http://127.0.0.1:3000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The response is a single SSE `message` event carrying the `tools/list` result: + +``` +event: message +data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} +``` ## Recap -<!-- the claims this page proves: -- One install line, one file: createMcpExpressApp + toNodeHandler(createMcpHandler(factory)). -- toNodeHandler converts the web-standard handler to (req, res, parsedBody) once. -- DNS rebinding protection is on by default for localhost binds. -- Auth is pass-through: req.auth in, ctx.http.authInfo out. ---> + +- One install line, one file: `createMcpExpressApp()` plus `app.all('/mcp', …)` over `toNodeHandler(createMcpHandler(factory))`. +- A fresh server instance from your factory serves every request. +- `createMcpExpressApp` already runs `express.json()`; pass `req.body` as `toNodeHandler`'s third argument. +- The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. +- `requireBearerAuth` sets `req.auth`; `toNodeHandler` forwards it as `ctx.http.authInfo`. diff --git a/docs/serving/fastify.md b/docs/serving/fastify.md index bed370db14..72ae18b1fa 100644 --- a/docs/serving/fastify.md +++ b/docs/serving/fastify.md @@ -1,60 +1,92 @@ --- -status: scaffold shape: how-to --- # Serve with Fastify -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Fastify recipe — same shape as express.md. -teaches: createMcpFastifyApp, toNodeHandler over request.raw/reply.raw, request.body, allowedHosts -source: mined from packages/middleware/fastify/README.md (server.md never names createMcpFastifyApp — net-new wiring against packages/middleware/fastify/src/fastify.ts) + docs/server.md "DNS rebinding protection" ---> - ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/fastify @modelcontextprotocol/node fastify ``` ## Mount the handler -<!-- teaches: toNodeHandler over request.raw / reply.raw; Fastify parses JSON by default | salvage: packages/middleware/fastify/README.md "Streamable HTTP endpoint (Fastify)" --> -<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> -```ts -// draft - API verified against packages/middleware/fastify/src/fastify.ts, packages/middleware/node/src/toNodeHandler.ts, packages/server/src/server/createMcpHandler.ts +`createMcpHandler` turns a server factory into a web-standard HTTP handler, and `toNodeHandler` adapts it once to Node's `(req, res)` — a Fastify route hands it `request.raw` and `reply.raw`. `createMcpFastifyApp` is `Fastify()` with DNS rebinding protection already applied. + +```ts source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_mount" import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) - return server; + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; }); const app = createMcpFastifyApp(); const node = toNodeHandler(handler); app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body)); - -await app.listen({ port: 3000 }); ``` -<!-- result: one line — http://127.0.0.1:3000/mcp answers MCP POSTs --> + +`app` is an ordinary Fastify instance with one route — `/mcp` answers every MCP request — and nothing is listening yet. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding -<!-- teaches: createMcpFastifyApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> -<!-- code: createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpFastifyApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. + +Binding to all interfaces drops that default — name the hosts you serve instead. + +```ts source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_allowedHosts" +const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +``` + +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body -<!-- teaches: Fastify already parsed request.body — pass it as toNodeHandler's third arg; attach validated AuthInfo to req.raw.auth (or call node with options) so handlers read ctx.http.authInfo --> -<!-- code: node(request.raw, reply.raw, request.body) — one line; link /serving/authorization --> + +Fastify parses JSON bodies itself, so `request.body` is already the parsed body — passing it as `toNodeHandler`'s third argument keeps the adapter from re-reading the consumed stream. Auth rides on the Node request: set `auth` on `request.raw` and `toNodeHandler` forwards it, so handlers read it as `ctx.http.authInfo`. + +```ts source="../../examples/guides/serving/fastify.examples.ts#toNodeHandler_authInfo" +publicApp.all('/mcp', async (request, reply) => { + const auth = await verifyToken(request.headers.authorization); + return node(Object.assign(request.raw, { auth }), reply.raw, request.body); +}); +``` + +`verifyToken` is your token verification. [Authorization](./authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify -<!-- teaches: start the process, point the Inspector (or curl) at http://127.0.0.1:3000/mcp --> -<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> -<!-- result: verbatim tools/list output --> + +Add the listen line and start the process (`npx tsx server.ts`). + +```ts source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_listen" +await app.listen({ port: 3000 }); +``` + +POST a `tools/list` request to the endpoint. + +```sh +curl -s -X POST http://127.0.0.1:3000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The response is a single SSE `message` event carrying the `tools/list` result: + +``` +event: message +data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} +``` ## Recap -<!-- the claims this page proves: -- One install line, one file: createMcpFastifyApp + toNodeHandler(createMcpHandler(factory)). -- Fastify hands the raw req/res pair to the Node adapter; the body is already parsed. -- DNS rebinding protection is on by default for localhost binds. -- Auth is pass-through to ctx.http.authInfo. ---> + +- One install line, one file: `createMcpFastifyApp()` plus `app.all('/mcp', …)` over `toNodeHandler(createMcpHandler(factory))`. +- A fresh server instance from your factory serves every request. +- Fastify already parsed `request.body`; pass it as `toNodeHandler`'s third argument. +- The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. +- Set `auth` on the raw Node request; `toNodeHandler` forwards it as `ctx.http.authInfo`. diff --git a/docs/serving/hono.md b/docs/serving/hono.md index 4e3fdb7d50..ad02a3945e 100644 --- a/docs/serving/hono.md +++ b/docs/serving/hono.md @@ -1,33 +1,30 @@ --- -status: scaffold shape: how-to --- # Serve with Hono -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Hono recipe — same shape as express.md. -teaches: createMcpHonoApp, handler.fetch(c.req.raw), c.get('parsedBody'), allowedHosts -source: mined from docs/server.md "Streamable HTTP" (web-standard mounting paragraph) + "DNS rebinding protection"; packages/middleware/hono/README.md; examples/hono/ ---> - ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono ``` ## Mount the handler -<!-- teaches: handler.fetch on c.req.raw — no Node adapter needed | salvage: packages/middleware/hono/README.md "Streamable HTTP endpoint (Hono)" + examples/hono/server.ts --> -<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> -```ts -// draft - API verified against packages/middleware/hono/src/hono.ts, packages/server/src/server/createMcpHandler.ts -import type { Context } from 'hono'; +`createMcpHandler` turns a server factory into a web-standard HTTP handler, and `handler.fetch` takes the `Request` a Hono route already holds as `c.req.raw` — no Node adapter. `createMcpHonoApp` is `new Hono()` with JSON body parsing and DNS rebinding protection already applied. + +```ts source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_mount" import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { Context } from 'hono'; +import * as z from 'zod/v4'; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) - return server; + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; }); const app = createMcpHonoApp(); @@ -35,29 +32,60 @@ app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('pa export default app; ``` -<!-- result: one line — /mcp answers MCP POSTs on whatever runtime serves the Hono app --> -<!-- prose-tranche note: the explicit `c: Context` annotation is load-bearing, not style. - createMcpHonoApp() returns a plain Hono, so an inferred callback context narrows the - c.get key parameter to `never` and `c.get('parsedBody')` is a type error (TS2769). - Keep the annotation until createMcpHonoApp types its Variables env. --> + +`app` is an ordinary Hono app, and `export default app` is the `{ fetch }` object Cloudflare Workers, Deno, and Bun serve directly; on Node, pass `app` to `serve` from `@hono/node-server`. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model. + +::: tip +Keep the explicit `c: Context` annotation: on an inferred callback context `c.get`'s key parameter narrows to `never` and `c.get('parsedBody')` does not compile. +::: ## Protect against DNS rebinding -<!-- teaches: createMcpHonoApp arms Host + Origin validation for localhost binds; allowedHosts/allowedOrigins for 0.0.0.0 | salvage: docs/server.md "DNS rebinding protection" --> -<!-- code: createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }) --> + +A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpHonoApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. + +Binding to all interfaces drops that default — name the hosts you serve instead. + +```ts source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_allowedHosts" +const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +``` + +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body -<!-- teaches: createMcpHonoApp parses JSON into c.get('parsedBody'); pass validated auth as handler.fetch(c.req.raw, { authInfo, parsedBody }) --> -<!-- code: (c: Context) => handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') }) — one line; link /serving/authorization --> + +`createMcpHonoApp` parses JSON bodies into `c.get('parsedBody')` for you; keep passing it through. Auth travels the same way — `handler.fetch`'s second argument is strictly pass-through, and handlers read it as `ctx.http.authInfo`. + +```ts source="../../examples/guides/serving/hono.examples.ts#McpHttpHandler_fetch_authInfo" +publicApp.all('/mcp', async (c: Context) => { + const authInfo = await verifyToken(c.req.raw); + return handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') }); +}); +``` + +`verifyToken` is your token verification. [Authorization](./authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify -<!-- teaches: start the process (node/bun/wrangler dev), point the Inspector (or curl) at /mcp --> -<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:3000/mcp --> -<!-- result: verbatim tools/list output --> + +Deploy the default export on any runtime that serves a `{ fetch }` object — `wrangler dev server.ts` puts it on `http://127.0.0.1:8787`. POST a `tools/list` request to `/mcp`. + +```sh +curl -s -X POST http://127.0.0.1:8787/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The response is a single SSE `message` event carrying the `tools/list` result: + +``` +event: message +data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} +``` ## Recap -<!-- the claims this page proves: -- One install line, one file: createMcpHonoApp + createMcpHandler(factory).fetch. -- Hono hands the raw Request straight to handler.fetch — no Node adapter. -- DNS rebinding protection is on by default for localhost binds. -- Auth is pass-through via the second fetch argument. ---> + +- One install line, one file: `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch`. +- Hono hands `c.req.raw` straight to `handler.fetch` — no Node adapter. +- A fresh server instance from your factory serves every request. +- The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. +- `authInfo` and `parsedBody` travel in `handler.fetch`'s second argument; handlers read auth as `ctx.http.authInfo`. diff --git a/docs/serving/http.md b/docs/serving/http.md index 6b1496155d..228712d2f9 100644 --- a/docs/serving/http.md +++ b/docs/serving/http.md @@ -1,68 +1,129 @@ --- -status: scaffold shape: how-to --- + # Serve over HTTP -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: createMcpHandler; the per-request factory model lives HERE (recipes link back). -teaches: createMcpHandler, McpServerFactory, McpRequestContext, McpHttpHandler.fetch, toNodeHandler, CreateMcpHandlerOptions (responseMode, onerror), handler.close -era/legacy note: the legacy: posture is owned by serving/legacy-clients.md (proposal §1); this page carries one aside that links it. -section-top note (proposal §3 path 2): the approved tree has no serving/ landing page, so the -two-sentence transport orientation ("launched locally by a host -> stdio; hosted for many -clients -> HTTP") lives at first-server.md's exit ("Pick a transport"); "atop the serving -section" needs a sidebar/section-blurb decision in the site tranche, not a new page. -source: mined from docs/server.md "Streamable HTTP" + "Serving the 2026-07-28 draft revision over HTTP" + "DNS rebinding protection" + "Shutdown" ---> +To host one MCP endpoint that many clients connect to, serve your factory over **Streamable HTTP**. A host that launches the server as a local child process speaks [stdio](./stdio.md) instead. ## Create a handler -<!-- teaches: createMcpHandler | salvage: docs/server.md "Serving the 2026-07-28 draft revision over HTTP" --> -```ts -// draft - API verified against packages/server/src/server/createMcpHandler.ts +`createMcpHandler` takes a **factory** — a function that builds and returns a fresh `McpServer` — and returns the handler that serves it. + +```ts source="../../examples/guides/serving/http.examples.ts#createHandler" import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) — a fresh instance serves every request - return server; + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { + description: 'Save a note', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; }); ``` -<!-- result: one line — handler.fetch is a web-standard (Request) => Promise<Response>; nothing is listening yet --> -<!-- aside (::: info Coming from v1?): createMcpHandler replaces the per-request StreamableHTTPServerTransport + connect() wiring — run the codemod, then see /migration/upgrade-to-v2. --> + +`handler.fetch` is a web-standard `(Request) => Promise<Response>` — nothing is listening yet. The tool calls on this page come from a real `Client` driving the handler's `fetch` in process; [Test a server](../testing.md) shows that wiring. + +Calling `add-note` through it returns the tool result: + +``` +[ { type: 'text', text: 'Saved: ship the release notes' } ] +``` + +The handler also carries `close` for shutdown and the `notify`/`bus` pair that publishes change events to subscribed clients — see [Notifications](../servers/notifications.md). + +::: info Coming from v1? +`createMcpHandler` replaces the per-request `StreamableHTTPServerTransport` + `connect()` wiring — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Understand the per-request factory -<!-- teaches: McpServerFactory, McpRequestContext ({ era, authInfo, requestInfo }); factories must be cheap and side-effect-free. THE canonical home of the factory model — all four recipe pages back-link here --> -<!-- code: factory reading ctx — createMcpHandler(({ authInfo }) => buildServerFor(authInfo)) --> + +The factory runs once per HTTP request: a fresh instance serves every request, and the handler holds nothing between requests. Register tools, resources, and prompts inside the factory, never on a shared instance outside it. + +The factory receives the **request context** — `era`, `authInfo`, and the inbound `Request` as `requestInfo`. Destructure `authInfo` to build the instance around one caller; [Pass authentication through](#pass-authentication-through) shows where the value comes from. + +```ts source="../../examples/guides/serving/http.examples.ts#factoryContext" +const perCaller = createMcpHandler(({ authInfo }) => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Name the authenticated caller' }, async () => ({ + content: [{ type: 'text', text: authInfo?.clientId ?? 'anonymous' }] + })); + return server; +}); +``` + +Every request now gets an instance built for its own caller. Keep the factory cheap and side-effect-free: create connection pools and caches once at module scope and close over them. + +`era` names the protocol revision the request speaks — see [Protocol versions](../protocol-versions.md). + +Because no state lives on the instance, the endpoint is stateless and scales horizontally as-is; sessions, resumability, and multi-node fan-out are their own page, [Sessions, state, and scaling](./sessions-state-scaling.md). ## Mount it on your runtime -<!-- teaches: handler.fetch (Workers/Deno/Bun: export default handler) vs toNodeHandler(handler) for Express/Fastify/node:http | salvage: docs/server.md "handler.fetch is a web-standard..." paragraph --> -<!-- code: createServer(toNodeHandler(handler)).listen(3000) --> -<!-- link strip: /serving/express · /serving/hono · /serving/fastify · /serving/web-standard --> + +On a web-standard runtime — Cloudflare Workers, Deno, Bun — `export default handler` is the entire mount. Node frameworks wrap the handler once with `toNodeHandler` from `@modelcontextprotocol/node`; on plain `node:http`: + +```ts source="../../examples/guides/serving/http.examples.ts#mountNode" +createServer(toNodeHandler(handler)).listen(3000); +``` + +`POST http://localhost:3000/mcp` now reaches the factory. The same wrapped handler mounts under [Express](./express.md), [Fastify](./fastify.md), and [Hono](./hono.md); [Serve on web-standard runtimes](./web-standard.md) covers the `export default` side. ## Validate Host and Origin in front of it -<!-- teaches: the entry does NO Host/Origin validation or token verification itself; createMcp*App factories arm it by default | salvage: docs/server.md "DNS rebinding protection" --> -<!-- code: createMcpExpressApp() / hostHeaderValidationResponse for bare fetch runtimes --> + +The handler trusts its caller: it validates no `Host` header, no `Origin` header, and no token. Mount those checks in front of it — on a localhost bind, the `Host` check is what stops **DNS rebinding**, a malicious page resolving its own domain to `127.0.0.1` so the browser treats your local server as same-origin. + +Under a framework you never wire either check by hand: `createMcpExpressApp`, `createMcpHonoApp`, and `createMcpFastifyApp` all arm both by default on localhost binds — the [Express](./express.md), [Hono](./hono.md), and [Fastify](./fastify.md) recipes start there. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](./web-standard.md#protect-against-dns-rebinding) builds that wrapper. ## Pass authentication through -<!-- teaches: handler.fetch(request, { authInfo }) / toNodeHandler forwards req.auth; read it as ctx.http.authInfo | salvage: docs/server.md "Options:" paragraph + "Authorization (OAuth resource server)" --> -<!-- code: app.all('/mcp', auth, (req, res) => void node(req, res, req.body)) — one line; link /serving/authorization --> + +`authInfo` is pass-through: the handler never reads it from headers and never verifies a token. Verify the bearer token in front of the handler and hand it the result as `fetch`'s second argument, `handler.fetch(request, { authInfo })`; the factory reads it back as `authInfo`, and tool handlers as `ctx.http.authInfo`. + +Under a Node framework the verifying middleware runs first and `toNodeHandler` forwards what it sets — each recipe shows its own mount, and [Require authorization](./authorization.md) builds the verifier with `requireBearerAuth`. + +With an `AuthInfo` whose `clientId` is `alice`, `whoami` from [the factory above](#understand-the-per-request-factory) answers: + +``` +[ { type: 'text', text: 'alice' } ] +``` ## Shape the response stream -<!-- teaches: responseMode 'auto' (default) | 'sse' | 'json'; 'json' drops mid-call notifications --> -<!-- code: createMcpHandler(factory, { responseMode: 'json' }) --> -<!-- aside (::: info): older clients are served statelessly by default; the `legacy:` option and the - full story live on /serving/legacy-clients. Era detail is one line linking /protocol-versions. --> + +The handler answers a request with a single JSON body and upgrades to an SSE stream only when a tool handler emits a notification — progress, logging — before its result. `responseMode` pins one shape instead. + +```ts source="../../examples/guides/serving/http.examples.ts#shapeResponse" +const jsonOnly = createMcpHandler(factory, { responseMode: 'json' }); +``` + +`'json'` never streams: the SDK drops mid-call notifications and delivers only the terminal result. `'sse'` always streams. `subscriptions/listen` streams stay on SSE whichever you pick. + +::: info +The handler serves 2025-era clients statelessly from the same factory by default. The `legacy` option — and where the SSE transport went — is on [Support legacy clients](./legacy-clients.md). +::: ## Shut down -<!-- teaches: handler.close() aborts in-flight modern exchanges | salvage: docs/server.md "Shutdown" --> -<!-- code: process.on('SIGINT', () => handler.close()) --> + +`handler.close()` aborts in-flight exchanges and closes their per-request instances; the handler holds nothing else. + +```ts source="../../examples/guides/serving/http.examples.ts#shutDown" +process.on('SIGINT', async () => { + await handler.close(); + process.exit(0); +}); +``` + +`close()` resolves once every in-flight instance has closed; `fetch` then throws on any further request. ## Recap -<!-- the claims this page proves: -- createMcpHandler(factory) returns { fetch, close, notify, bus }; fetch is web-standard. -- One fresh server instance per request — define tools once in the factory. -- export default on web-standard runtimes; toNodeHandler once for Node frameworks. -- The handler does no Host/Origin validation and no token verification; mount those in front. -- responseMode shapes the response stream; 'json' drops mid-call notifications. ---> + +- `createMcpHandler(factory)` returns `{ fetch, close, notify, bus }`; `fetch` is a web-standard `(Request) => Promise<Response>`. +- The factory builds one fresh instance per request and receives `era`, `authInfo`, and `requestInfo`. +- `export default handler` mounts it on web-standard runtimes; `toNodeHandler(handler)` mounts it once under Node frameworks. +- The handler validates no `Host` or `Origin` header and verifies no token — mount both checks in front of it; the framework app factories arm the header checks for you. +- `authInfo` flows from `fetch(request, { authInfo })` into the factory and tool handlers; each framework recipe shows its own mount. +- `responseMode` pins the response shape; `'json'` drops mid-call notifications. diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index 93ef694fd4..f8a6e0c155 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -1,46 +1,102 @@ --- -status: scaffold shape: how-to --- # Support legacy clients -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: The legacy: option; where SSE went. -teaches: CreateMcpHandlerOptions.legacy ('stateless' | 'reject'), ServeStdioOptions.legacy ('serve' | 'reject'), isLegacyRequest, legacyStatelessFallback, @modelcontextprotocol/server-legacy/sse -source: mined from docs/server.md "Serving the 2026-07-28 draft revision over HTTP" Options + routing paragraphs; docs/faq.md "Why did we remove server SSE transport?"; examples/legacy-routing/, examples/dual-era/ ---> +A **legacy client** speaks a 2025-era protocol revision: it opens with `initialize` and sends no per-request `_meta` envelope. Both serving entry points answer those clients from the same factory that serves modern ones; the `legacy` option decides whether they keep doing it. [Protocol versions](../protocol-versions.md) covers the era model itself. ## Choose a legacy posture -<!-- teaches: legacy: 'stateless' (default — 2025 clients served per request from the same factory) vs 'reject' (modern-only strict) | salvage: docs/server.md "Options:" paragraph under createMcpHandler --> -```ts -// draft - API verified against packages/server/src/server/createMcpHandler.ts (CreateMcpHandlerOptions.legacy: 'stateless' | 'reject') +[`createMcpHandler`](./http.md) has two postures. The default, `legacy: 'stateless'`, serves each legacy request from a fresh instance out of your factory, with no sessions. `legacy: 'reject'` makes the endpoint modern-only. + +```ts source="../../examples/guides/serving/legacy-clients.examples.ts#createMcpHandler_legacyReject" import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -const handler = createMcpHandler( - () => new McpServer({ name: 'notes', version: '1.0.0' }), - { legacy: 'reject' }, -); +const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' }); + +const strict = createMcpHandler(buildServer, { legacy: 'reject' }); ``` -<!-- result: one line — a 2025-era request gets the unsupported-protocol-version error naming the supported revisions; modern traffic is unaffected --> -<!-- aside: what "legacy" means is one line linking /protocol-versions --> + +A 2025-era `initialize` POST to the strict handler gets HTTP `400` and the unsupported-protocol-version error naming the one revision the endpoint serves: + +``` +400 +{ + "jsonrpc": "2.0", + "error": { + "code": -32022, + "message": "Unsupported protocol version: 2025-06-18", + "data": { + "supported": [ + "2026-07-28" + ], + "requested": "2025-06-18" + } + }, + "id": 1 +} +``` + +Drop the option and the same request gets a normal 2025 `InitializeResult` from a fresh instance, torn down when the exchange ends. Per request means no sessions: under the default posture a legacy `GET` (the standalone SSE stream) and `DELETE` (session termination) answer `405 Method not allowed.` — a client that needs those needs the routing below. + +::: tip +A strict endpoint still acknowledges legacy-classified notification POSTs with `202` — and then drops them. Legacy `GET` and `DELETE` answer `405` there too. +::: ## Choose the same posture on stdio -<!-- teaches: ServeStdioOptions.legacy ('serve' default | 'reject'); the era is pinned once per connection | salvage: docs/server.md "Options:" paragraph under serveStdio --> -<!-- code: serveStdio(factory, { legacy: 'reject' }) --> + +[`serveStdio`](./stdio.md) takes the same option with a different default — `'serve'` — and applies it once per connection, not per request. + +```ts source="../../examples/guides/serving/legacy-clients.examples.ts#serveStdio_legacyReject" +serveStdio(buildServer, { legacy: 'reject' }); +``` + +Under `'serve'` a 2025-era opening pins the connection to a legacy instance from your factory and serves it exactly as a hand-wired stdio server would. Under `'reject'` the opening is answered with the same unsupported-protocol-version error and the connection stays open for a modern opening. ## Keep a sessionful 2025 deployment running -<!-- teaches: there is no handler-valued legacy option — route in user land with isLegacyRequest in front of a strict handler and hand legacy traffic to your existing wiring (or legacyStatelessFallback) | salvage: docs/server.md "To keep an existing sessionful 2025 deployment..." paragraph; examples/legacy-routing/server.ts, examples/dual-era/server.ts --> -<!-- code: if (isLegacyRequest(body)) return legacyHandler(request); return strict.fetch(request); --> + +Neither entry point accepts a handler as the `legacy` value. To keep an existing sessionful deployment serving the 2025 clients it already has, route in front of a strict handler with `isLegacyRequest` — the entry's own classification step exported as a predicate, so the branch never disagrees with `createMcpHandler`. + +```ts source="../../examples/guides/serving/legacy-clients.examples.ts#isLegacyRequest_route" +import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server'; + +const legacy = legacyStatelessFallback(buildServer); + +async function serve(request: Request): Promise<Response> { + if (await isLegacyRequest(request)) { + return legacy(request); + } + return strict.fetch(request); +} +``` + +`legacyStatelessFallback(factory)` is the entry's default legacy serving as a standalone handler — it holds the legacy leg's place here. Put your existing wiring there instead and it keeps its sessions, its event store, and its clients: [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) runs a sessionful `StreamableHTTPServerTransport` deployment behind this exact branch. Route every `false` to the strict handler — the modern path owns the error answers for malformed modern requests. + +The `initialize` the strict handler rejected above now completes the 2025 handshake on the legacy leg: + +``` +200 +{ + protocolVersion: '2025-06-18', + capabilities: {}, + serverInfo: { name: 'notes', version: '1.0.0' } +} +``` + +::: tip +Behind an Express body parser the Node stream is already drained: build the `Request` the predicate takes with `toWebRequest(req, req.body)` from `@modelcontextprotocol/node`. +::: ## Know where SSE went -<!-- teaches: the v2 server does not serve the HTTP+SSE (2024) transport; the client keeps SSEClientTransport to reach old servers; a frozen v1 copy lives at @modelcontextprotocol/server-legacy/sse — migrate to Streamable HTTP | salvage: docs/faq.md "Why did we remove server SSE transport?" --> -<!-- code: none — one migration link to /migration/upgrade-to-v2 --> + +The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 moves to Streamable HTTP — `createMcpHandler` above — as part of the [v2 upgrade](../migration/upgrade-to-v2.md). + +The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated). ## Recap -<!-- the claims this page proves: -- Both entries serve 2025 clients from the same factory by default; 'reject' makes them modern-only. -- 'stateless' legacy serving is per-request: 2025 GET/DELETE session operations answer 405. -- An existing sessionful deployment keeps working behind isLegacyRequest routing. -- v2 never serves SSE; the frozen transport lives in @modelcontextprotocol/server-legacy/sse. ---> + +- Both entry points serve 2025-era clients from the same factory by default; `legacy: 'reject'` makes an endpoint modern-only. +- The default HTTP posture is per request and stateless: legacy `GET` and `DELETE` session operations answer `405`. +- `serveStdio` decides the era once per connection; its default is `'serve'`. +- `isLegacyRequest` in front of a strict handler keeps an existing sessionful 2025 deployment serving its clients. +- The v2 server never serves SSE; the frozen v1 transport is `@modelcontextprotocol/server-legacy/sse`, and the client keeps `SSEClientTransport`. diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index e58ade8019..2c85b4d51b 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -1,46 +1,101 @@ --- -status: scaffold shape: how-to --- # Sessions, state, and scaling -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Sessions, Resumability, Multi-node — stateless ruling first, two sentences. -teaches: the stateless-by-default ruling (createMcpHandler), sessionIdGenerator, EventStore/eventStore, ServerEventBus (multi-node listen), the three deployment topologies -source: mined from docs/server.md "Streamable HTTP" Options paragraph + "Shutdown"; examples/README.md "Multi-node deployment patterns" -NOTE: the three H2 titles below are VERBATIM per the approved proposal (§1 + Appendix A "sessions H2s verbatim") — Felix ruling; this page is the one sanctioned exception to imperative micro-step headings, and to the 4-H2 floor. ---> - -<!-- opening (before any H2), exactly two sentences — the stateless ruling: -`createMcpHandler` builds a fresh server instance per request and holds nothing between requests, so a v2 HTTP server is stateless and horizontally scalable by default. Read on only if you run a sessionful 2025-era deployment or need cross-request state. --> +`createMcpHandler` builds a fresh server instance from your factory for every HTTP request and holds nothing between requests, so a v2 server is stateless and scales horizontally by default — [Serve over HTTP](./http.md) is the whole setup. Read on if you run a sessionful 2025-era deployment, need a dropped stream to resume, or push change notifications across nodes. ## Sessions -<!-- teaches: sessionIdGenerator (stateful) vs undefined (stateless); sessions are a 2025-era hand-wired-transport concept | salvage: docs/server.md "Streamable HTTP" Options paragraph; examples/legacy-routing/server.ts --> -```ts -// draft - API verified against packages/middleware/node/src/streamableHttp.ts (NodeStreamableHTTPServerTransport, StreamableHTTPServerTransportOptions.sessionIdGenerator) +A **session** pins a client to one long-lived transport instance; sessions belong to the hand-wired 2025-era transport — the 2026-07-28 revision is per-request and has no `Mcp-Session-Id` ([Protocol versions](../protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode. + +```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_stateful" import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { randomUUID } from 'node:crypto'; const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + sessionIdGenerator: () => randomUUID() }); ``` -<!-- result: one line — responses carry an Mcp-Session-Id header and the client replays it on every request --> -<!-- code (follow-up placeholder): the per-session transports map + routing by Mcp-Session-Id (salvage: examples/legacy-routing/server.ts, docs/server.md "Shutdown" transports map) --> + +The transport answers `initialize` with the generated id in an `Mcp-Session-Id` response header and rejects later requests that arrive without it. The SDK's `StreamableHTTPClientTransport` sends the header back on every request with no configuration. + +One transport instance is one session, so a sessionful deployment keeps a map: build a transport when `initialize` arrives, store it in `onsessioninitialized`, and route every later request to the transport that owns its `Mcp-Session-Id`. This Express route handles all three verbs — `POST`, the `GET` notification stream, and `DELETE` ([Serve with Express](./express.md) covers the app itself). + +```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_routing" +const sessions = new Map<string, NodeStreamableHTTPServerTransport>(); + +const route = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId)!.handleRequest(req, res, req.body); + return; + } + if (!sessionId && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId); + }; + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); +}; + +app.post('/mcp', route); +app.get('/mcp', route); +app.delete('/mcp', route); +``` + +The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session. + +::: tip +On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. +::: ## Resumability -<!-- teaches: EventStore / the eventStore transport option; replaying missed SSE events after a dropped connection | salvage: examples/README.md "Persistent storage mode"; examples/shared/src/inMemoryEventStore.ts; examples/sse-polling/ --> -<!-- code: new NodeStreamableHTTPServerTransport({ sessionIdGenerator, eventStore }) --> + +A sessionful client holds a `GET` SSE stream open for server notifications, and anything sent while that connection is down is lost. An **event store** closes the gap: with one configured, the transport stamps every SSE message with an event id from the store before sending it. + +`EventStore` is a two-method contract — `storeEvent(streamId, message)` persists a message and returns its event id; `replayEventsAfter(lastEventId, { send })` re-sends every later message on that stream. Implement it over storage every node can reach (`databaseEventStore` here) and pass it next to `sessionIdGenerator`. + +```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#resumability_eventStore" +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: databaseEventStore +}); +``` + +When the connection drops, the client reconnects with the last event id it received as a `Last-Event-ID` header and the transport replays everything stored after it. The SDK's `StreamableHTTPClientTransport` reconnects and sends that header on its own. + +::: tip +`examples/shared/src/inMemoryEventStore.ts` in the SDK repository is a complete `EventStore` reference implementation — in memory, so single-process only. +::: ## Multi-node -<!-- teaches: the three topologies — stateless (default, nothing to do), persistent storage (shared eventStore), pub/sub message routing; for subscriptions/listen across nodes, pass a shared ServerEventBus to createMcpHandler({ bus }) | salvage: examples/README.md "Multi-node deployment patterns" (all three ASCII diagrams collapse to prose here) --> -<!-- code: createMcpHandler(factory, { bus: myDistributedBus }) --> + +The stateless default is the scaling story: every node builds a fresh instance from the same factory and holds nothing between requests, so put the nodes behind any load balancer — no session affinity, nothing to share, nothing to configure. + +Sessionful 2025-era nodes hold their sessions in process memory, so they scale two ways. **Persistent storage**: keep `sessionIdGenerator` and point every node at the same `eventStore`, so a dropped stream is resumable from any node that shares the store. **Local state with message routing**: keep per-node sessions and send each session's traffic to the node that owns it — load-balancer affinity, or pub/sub routing between nodes. + +One thing still crosses nodes on a stateless deployment: `subscriptions/listen`. Its streams deliver the change events published on the handler's **`ServerEventBus`** ([Notifications](../servers/notifications.md)), and the default bus is in-process — `handler.notify.toolsChanged()` on node A never reaches a subscriber whose stream node B holds. Implement `ServerEventBus` over your pub/sub (`publish(event)` forwards to the broker; `subscribe(listener)` registers for events arriving from it) and hand one to every node's `createMcpHandler`. + +```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#multiNode_bus" +const handler = createMcpHandler(buildServer, { bus: redisBus }); +``` + +Now `handler.notify.resourceUpdated(uri)` on any node publishes through the shared bus, and every node delivers the notification to its own open subscription streams. ## Recap -<!-- the claims this page proves: -- createMcpHandler is stateless per request; multi-node needs no session affinity. -- Sessions belong to hand-wired 2025-era transports: sessionIdGenerator turns them on. -- An EventStore makes a dropped SSE stream resumable from any node that shares it. -- subscriptions/listen scales across nodes by sharing one ServerEventBus. ---> + +- `createMcpHandler` builds a fresh server per request and holds nothing between requests, so stateless nodes scale behind any load balancer with no session affinity. +- Sessions belong to the hand-wired 2025-era transport: `sessionIdGenerator` turns them on, and responses carry `Mcp-Session-Id`. +- A sessionful deployment keeps one transport per session and routes every request to it by that header; unknown ids get a `404`. +- An `eventStore` makes a dropped SSE stream resumable: the client reconnects with `Last-Event-ID` and the transport replays what it missed. +- `subscriptions/listen` scales across nodes by handing every node's `createMcpHandler` the same `ServerEventBus`. diff --git a/docs/serving/stdio.md b/docs/serving/stdio.md index 091c04b85c..2855626eca 100644 --- a/docs/serving/stdio.md +++ b/docs/serving/stdio.md @@ -1,52 +1,79 @@ --- -status: scaffold shape: how-to --- # Serve over stdio -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: serveStdio and the console.error gotcha. -teaches: serveStdio, StdioServerHandle, console.error-vs-console.log, handle.close -source: mined from docs/server.md "stdio" + "Serving the 2026-07-28 draft revision on stdio" + "Shutdown"; docs/server-quickstart.md "Running your server" (the console.error IMPORTANT box) -era/legacy note: the legacy: posture is owned by serving/legacy-clients.md (proposal §1); this page carries one aside that links it. ---> +A host that launches your server as a local child process talks to it over **stdio**: JSON-RPC requests arrive on stdin, responses leave on stdout. To host one endpoint that many clients connect to, serve the same factory over [HTTP](./http.md) instead. ## Serve a factory over stdio -<!-- teaches: serveStdio | salvage: docs/server.md "Serving the 2026-07-28 draft revision on stdio" --> -```ts -// draft - API verified against packages/server/src/server/serveStdio.ts +`serveStdio` takes a factory; it owns the transport and calls the factory to build the instance that serves the connection. + +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_basic" import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; -serveStdio(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) — the same factory serves every era a client opens with - return server; +const handle = serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) — one factory builds the instance that serves the connection + return server; }); ``` -<!-- result: one line — the process is an MCP server on stdin/stdout; a host that spawns it can call its tools --> -<!-- aside (::: info Coming from v1?): serveStdio replaces the StdioServerTransport + connect() wiring — run the codemod, then see /migration/upgrade-to-v2. --> -<!-- aside (::: info): older clients are served from the same factory by default; the `legacy:` option and the - full story live on /serving/legacy-clients. Era detail is one line linking /protocol-versions. --> + +The process is now an MCP server. A host that spawns it lists and calls whatever the factory registered; until one does, the process waits on stdin. + +::: info Coming from v1? +`serveStdio` replaces the `new StdioServerTransport()` + `server.connect(transport)` wiring — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md). +::: + +::: info +`serveStdio` serves older clients from the same factory by default; the `legacy` option and the full story are on [Legacy clients](./legacy-clients.md). The entry also owns which protocol revision each connection negotiates — see [Protocol versions](../protocol-versions.md). +::: ## Log to stderr, never stdout -<!-- teaches: the console.error gotcha | salvage: docs/server-quickstart.md "Running your server" IMPORTANT box (the #1 real-world stdio bug) --> -<!-- code: console.error('server ready') vs console.log — stdout is the JSON-RPC channel; one console.log corrupts it --> -<!-- result: the verbatim parse-error a host shows when a server writes to stdout --> + +Announce readiness with `console.error`, which writes to stderr. + +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_logStderr" +console.error('notes server is listening on stdio'); +``` + +stdout is the JSON-RPC channel: the host parses every line of it as a protocol message. Add one `console.log('debug: starting the notes server')` to the program above and send it an `initialize` request. Its two output streams now carry: + +``` +[stdout] debug: starting the notes server +[stdout] {"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"notes","version":"1.0.0"}},"jsonrpc":"2.0","id":1} +[stderr] notes server is listening on stdio +``` + +The protocol channel opens with a line no JSON-RPC parser accepts, ahead of the `initialize` response. The `console.error` banner went to stderr, which the host keeps out of the channel and shows in its server log. ## Test it with the Inspector -<!-- teaches: npx @modelcontextprotocol/inspector | salvage: docs/server-quickstart.md "Testing your server" --> -<!-- code: sh placeholder — npx @modelcontextprotocol/inspector node ./build/server.js --> + +The **MCP Inspector** launches your server command itself and connects to it over stdio. + +```sh +npx @modelcontextprotocol/inspector node ./build/server.js +``` + +In the browser tab it opens, click **Connect**; the **Tools** tab lists and calls everything the factory registered, without configuring the server in a host. ## Shut down cleanly -<!-- teaches: StdioServerHandle.close(); SIGINT | salvage: docs/server.md "Shutdown" (stdio half) --> -<!-- code: process.on('SIGINT', () => handle.close()) --> + +`serveStdio` returns a **`StdioServerHandle`**; its `close()` tears down the pinned server instance and the transport. + +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_shutdown" +process.on('SIGINT', () => { + void handle.close(); +}); +``` + +`close()` resolves once the instance the factory built and the underlying transport are both shut down. ## Recap -<!-- the claims this page proves: -- serveStdio(factory) is the stdio entry point; it owns the transport and builds the instance that serves the connection. -- stdout is the protocol channel; log with console.error. -- The Inspector exercises a stdio server without a host. -- handle.close() tears down the pinned instance and the transport. ---> + +- `serveStdio(factory)` is the stdio entry point: it owns the transport and calls your factory to build the instance that serves the connection. +- stdout is the protocol channel; log with `console.error`. +- One `console.log` puts a line no JSON-RPC parser accepts into the stream the host parses. +- `npx @modelcontextprotocol/inspector <command>` exercises a stdio server without configuring it in a host. +- The returned `StdioServerHandle`'s `close()` tears down the pinned instance and the transport. diff --git a/docs/serving/web-standard.md b/docs/serving/web-standard.md index 3a4fda8264..7a935cd17c 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -1,54 +1,91 @@ --- -status: scaffold shape: how-to --- # Serve on web-standard runtimes -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Web-standard runtimes (Workers etc.) recipe — same shape as express.md. -teaches: export default handler ({ fetch }), McpHttpHandler.fetch, hostHeaderValidationResponse/originValidationResponse for bare runtimes -source: mined from docs/server.md "handler.fetch is a web-standard..." paragraph + "DNS rebinding protection" (framework-agnostic helpers); examples/hono/ (web-standard leg) ---> - ```sh npm install @modelcontextprotocol/server ``` ## Mount the handler -<!-- teaches: the handler IS the { fetch } object Workers/Deno/Bun expect from export default | salvage: docs/server.md "on Cloudflare Workers, Deno, or Bun, export default handler is all the mounting you need" --> -<!-- back-link (one, mandatory): a fresh server instance serves every request — /serving/http#understand-the-per-request-factory --> -```ts -// draft - API verified against packages/server/src/server/createMcpHandler.ts (McpHttpHandler is the { fetch, close, notify, bus } shape Workers/Bun/Deno expect from export default) +`createMcpHandler` returns a `{ fetch }` object — the shape Cloudflare Workers, Deno, and Bun expect from a module's default export — so `export default handler` mounts it. + +```ts source="../../examples/guides/serving/webStandard.examples.ts#createMcpHandler_exportDefault" import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'notes', version: '1.0.0' }); - // server.registerTool(...) - return server; + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; }); export default handler; ``` -<!-- result: one line — the deployed Worker (or `bun run` / `deno serve` process) answers MCP POSTs on its URL --> + +The deployed worker answers MCP requests on every path, with no Node adapter and no body middleware. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding -<!-- teaches: no app factory here — call the framework-agnostic guards before handler.fetch: hostHeaderValidationResponse / originValidationResponse from @modelcontextprotocol/server | salvage: docs/server.md "When mounting a handler bare on a fetch-native runtime..." --> -<!-- code: const rejected = hostHeaderValidationResponse(request, ['api.example.com']); if (rejected) return rejected; --> + +The handler performs no `Host` or `Origin` validation, and on a bare fetch-native runtime there is no app factory to arm it for you. Put the framework-agnostic response helpers in front of `fetch` and export `guarded` as the default instead. + +```ts source="../../examples/guides/serving/webStandard.examples.ts#hostHeaderValidationResponse_guard" +import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server'; + +const guarded = { + async fetch(request: Request): Promise<Response> { + const rejected = + hostHeaderValidationResponse(request, ['api.example.com']) ?? + originValidationResponse(request, ['app.example.com']); + return rejected ?? handler.fetch(request); + } +}; +``` + +A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. ## Forward auth and the parsed body -<!-- teaches: route the Request yourself and pass options: handler.fetch(request, { authInfo }); no body middleware exists — fetch reads the Request body itself --> -<!-- code: async fetch(request) { return handler.fetch(request, { authInfo: await verify(request) }); } — link /serving/authorization --> + +There is no body middleware on a fetch-native runtime — `fetch` reads the `Request` itself, so there is no `parsedBody` to forward. The handler never derives auth from request headers either: verify the token yourself and pass the result as `fetch`'s second argument, and handlers read it as `ctx.http.authInfo`. + +```ts source="../../examples/guides/serving/webStandard.examples.ts#McpHttpHandler_fetch_authInfo" +const secured = { + async fetch(request: Request): Promise<Response> { + const authInfo = await verifyToken(request); + return handler.fetch(request, { authInfo }); + } +}; +``` + +`verifyToken` is your token verification. [Authorization](./authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify -<!-- teaches: wrangler dev / deno serve / bun run, then point the Inspector (or curl) at /mcp --> -<!-- code: sh placeholder — npx @modelcontextprotocol/inspector --transport http http://127.0.0.1:8787/mcp --> -<!-- result: verbatim tools/list output --> + +Deploy the default export on your runtime — `wrangler dev server.ts` puts it on `http://127.0.0.1:8787`; `deno serve server.ts` and `bun run server.ts` serve the same `{ fetch }` shape. POST a `tools/list` request to it. + +```sh +curl -s -X POST http://127.0.0.1:8787/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The response is a single SSE `message` event carrying the `tools/list` result: + +``` +event: message +data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} +``` ## Recap -<!-- the claims this page proves: -- The handler is already the export-default shape web-standard runtimes expect. + +- One install line, one file: the handler `createMcpHandler` returns is already the `{ fetch }` default export web-standard runtimes serve. - No Node adapter and no body middleware are involved. -- On a bare runtime you mount Host/Origin validation yourself with the exported response helpers. -- Auth is pass-through via handler.fetch's second argument. ---> +- A fresh server instance from your factory serves every request. +- The handler does no `Host`/`Origin` validation; on a bare runtime, put `hostHeaderValidationResponse` and `originValidationResponse` in front of it. +- Auth is pass-through via `handler.fetch`'s second argument; handlers read it as `ctx.http.authInfo`. diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts new file mode 100644 index 0000000000..9502168958 --- /dev/null +++ b/examples/guides/serving/authorization.examples.ts @@ -0,0 +1,79 @@ +// docs: typecheck-only +/** + * Companion example for `docs/serving/authorization.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The Express middleware needs a listening + * HTTP server and a real authorization server to exercise, so this file only + * typechecks: + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ +//#region requireBearerAuth_basic +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const mcpServerUrl = new URL('https://api.example.com/mcp'); +const verifier: OAuthTokenVerifier = { verifyAccessToken }; + +const auth = requireBearerAuth({ + verifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); + +const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +const node = toNodeHandler(createMcpHandler(buildServer)); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); +//#endregion requireBearerAuth_basic + +//#region tokenVerifier_basic +async function verifyAccessToken(token: string): Promise<AuthInfo> { + const payload = await verifyJwt(token); + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; +} +//#endregion tokenVerifier_basic + +// Stand-in for your JWT library or RFC 7662 introspection call. +declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>; + +// Your authorization server's RFC 8414 metadata document — fetch it from the AS +// at startup or embed it. +const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] +}; + +//#region metadataRouter_basic +app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); +//#endregion metadataRouter_basic + +// The per-request factory from the Express recipe; the lead block mounts it behind `auth`. +function buildServer(): McpServer { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + //#region authInfo_handler + server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => { + const caller = ctx.http?.authInfo; + return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] }; + }); + //#endregion authInfo_handler + + //#region perToolScopes_handler + server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => { + if (!ctx.http?.authInfo?.scopes.includes('notes:write')) { + return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true }; + } + return { content: [{ type: 'text', text: 'All notes deleted' }] }; + }); + //#endregion perToolScopes_handler + + return server; +} diff --git a/examples/guides/serving/express.examples.ts b/examples/guides/serving/express.examples.ts new file mode 100644 index 0000000000..a8c048ca0f --- /dev/null +++ b/examples/guides/serving/express.examples.ts @@ -0,0 +1,94 @@ +/** + * Runnable, type-checked companion for `docs-v2/serving/express.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The + * `createMcpExpressApp_listen` region lives in a wrapper function that is never + * invoked, so running this file never binds a port; the harness below the + * regions produces the response the page's "Run it and verify" section quotes + * verbatim and exits non-zero if it drifts. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/express.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; + +//#region createMcpExpressApp_mount +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); + +const app = createMcpExpressApp(); +const node = toNodeHandler(handler); +app.all('/mcp', (req, res) => void node(req, res, req.body)); +//#endregion createMcpExpressApp_mount + +//#region createMcpExpressApp_allowedHosts +const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +//#endregion createMcpExpressApp_allowedHosts + +// `verifier` stands in for your deployment's token verification (JWT +// validation, RFC 7662 introspection, a call to your IdP). The page points at +// docs-v2/serving/authorization.md for the real thing. +const verifier: OAuthTokenVerifier = { + async verifyAccessToken(token) { + return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; + } +}; + +//#region requireBearerAuth_mount +import { requireBearerAuth } from '@modelcontextprotocol/express'; + +const auth = requireBearerAuth({ verifier }); +publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body)); +//#endregion requireBearerAuth_mount + +// "Run it and verify" — the listen line. Never invoked: docs companions must +// terminate on their own and never bind a port. +function createMcpExpressApp_listen(): void { + //#region createMcpExpressApp_listen + app.listen(3000); + //#endregion createMcpExpressApp_listen +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). The page quotes the response to its curl +// command verbatim; this produces it. The Express route is +// `(req, res) => void node(req, res, req.body)` with `node = toNodeHandler(handler)`, +// and `toNodeHandler` copies `handler.fetch(request)`'s status, headers, and +// body onto `res` unchanged — so `handler.fetch` given the curl command's exact +// request yields the bytes curl prints. (Driving the Express stack itself needs +// a listening socket, which a docs companion never opens.) +// --------------------------------------------------------------------------- + +const response = await handler.fetch( + new Request('http://127.0.0.1:3000/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + }) +); +const text = await response.text(); +console.log(text); + +const quotedOnPage = + 'event: message\n' + + 'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' + + '"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}'; +if (response.status !== 200 || text.trimEnd() !== quotedOnPage) { + throw new Error(`express.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`); +} diff --git a/examples/guides/serving/fastify.examples.ts b/examples/guides/serving/fastify.examples.ts new file mode 100644 index 0000000000..abe75f2aa3 --- /dev/null +++ b/examples/guides/serving/fastify.examples.ts @@ -0,0 +1,91 @@ +/** + * Runnable, type-checked companion for `docs-v2/serving/fastify.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The + * `createMcpFastifyApp_listen` region lives in a wrapper function that is never + * invoked, so running this file never binds a port; the harness below the + * regions drives the real Fastify app in process with `app.inject()`, produces + * the response the page's "Run it and verify" section quotes verbatim, and + * exits non-zero if it drifts. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/fastify.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +//#region createMcpFastifyApp_mount +import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); + +const app = createMcpFastifyApp(); +const node = toNodeHandler(handler); +app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body)); +//#endregion createMcpFastifyApp_mount + +//#region createMcpFastifyApp_allowedHosts +const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +//#endregion createMcpFastifyApp_allowedHosts + +// `verifyToken` stands in for your deployment's token verification (JWT +// validation, RFC 7662 introspection, a call to your IdP). The page points at +// docs-v2/serving/authorization.md for the real thing. +async function verifyToken(authorization: string | undefined): Promise<AuthInfo> { + const token = authorization?.replace(/^Bearer /, '') ?? ''; + return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; +} + +//#region toNodeHandler_authInfo +publicApp.all('/mcp', async (request, reply) => { + const auth = await verifyToken(request.headers.authorization); + return node(Object.assign(request.raw, { auth }), reply.raw, request.body); +}); +//#endregion toNodeHandler_authInfo + +// "Run it and verify" — the listen line. Never invoked: docs companions must +// terminate on their own and never bind a port. +async function createMcpFastifyApp_listen(): Promise<void> { + //#region createMcpFastifyApp_listen + await app.listen({ port: 3000 }); + //#endregion createMcpFastifyApp_listen +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). `app.inject()` runs the real Fastify app — +// the Host/Origin validation hooks, the JSON body parser, and the `/mcp` route +// — entirely in process, no socket. The page quotes its payload verbatim. +// --------------------------------------------------------------------------- + +const injected = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { host: '127.0.0.1:3000', 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + payload: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) +}); +console.log(injected.payload); + +const quotedOnPage = + 'event: message\n' + + 'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' + + '"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}'; +if (injected.statusCode !== 200 || injected.payload.trimEnd() !== quotedOnPage) { + throw new Error(`fastify.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(injected.payload)}`); +} + +await app.close(); +await publicApp.close(); diff --git a/examples/guides/serving/hono.examples.ts b/examples/guides/serving/hono.examples.ts new file mode 100644 index 0000000000..77ecb118fa --- /dev/null +++ b/examples/guides/serving/hono.examples.ts @@ -0,0 +1,79 @@ +/** + * Runnable, type-checked companion for `docs-v2/serving/hono.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The harness + * below the regions drives the real Hono app in process with `app.request()`, + * produces the response the page's "Run it and verify" section quotes verbatim, + * and exits non-zero if it drifts. No port is ever bound. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/hono.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +//#region createMcpHonoApp_mount +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import type { Context } from 'hono'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); + +const app = createMcpHonoApp(); +app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') })); + +export default app; +//#endregion createMcpHonoApp_mount + +//#region createMcpHonoApp_allowedHosts +const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); +//#endregion createMcpHonoApp_allowedHosts + +// `verifyToken` stands in for your deployment's token verification (JWT +// validation, RFC 7662 introspection, a call to your IdP). The page points at +// docs-v2/serving/authorization.md for the real thing. +async function verifyToken(request: Request): Promise<AuthInfo> { + const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''; + return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; +} + +//#region McpHttpHandler_fetch_authInfo +publicApp.all('/mcp', async (c: Context) => { + const authInfo = await verifyToken(c.req.raw); + return handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') }); +}); +//#endregion McpHttpHandler_fetch_authInfo + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). `app.request()` runs the real Hono app — +// the JSON body-parsing middleware, the Host/Origin validation, and the `/mcp` +// route — entirely in process. The page quotes its body verbatim. +// --------------------------------------------------------------------------- + +const response = await app.request('/mcp', { + method: 'POST', + headers: { host: '127.0.0.1:8787', 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) +}); +const text = await response.text(); +console.log(text); + +const quotedOnPage = + 'event: message\n' + + 'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' + + '"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}'; +if (response.status !== 200 || text.trimEnd() !== quotedOnPage) { + throw new Error(`hono.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`); +} diff --git a/examples/guides/serving/http.examples.ts b/examples/guides/serving/http.examples.ts new file mode 100644 index 0000000000..77979ec3d3 --- /dev/null +++ b/examples/guides/serving/http.examples.ts @@ -0,0 +1,139 @@ +/** + * Companion example for `docs/serving/http.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions drives `handler.fetch` in process — no port, no socket — and + * produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/http.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { createServer } from 'node:http'; + +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { McpServerFactory } from '@modelcontextprotocol/server'; + +// --------------------------------------------------------------------------- +// "Create a handler" +// --------------------------------------------------------------------------- + +//#region createHandler +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { + description: 'Save a note', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); +//#endregion createHandler + +// --------------------------------------------------------------------------- +// "Understand the per-request factory" — the factory reads the request context. +// --------------------------------------------------------------------------- + +//#region factoryContext +const perCaller = createMcpHandler(({ authInfo }) => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Name the authenticated caller' }, async () => ({ + content: [{ type: 'text', text: authInfo?.clientId ?? 'anonymous' }] + })); + return server; +}); +//#endregion factoryContext + +// --------------------------------------------------------------------------- +// "Mount it on your runtime" — never invoked: binding a port is the reader's +// deployment step, not this program's. The region typechecks against the real +// module-scope `handler` above. +// --------------------------------------------------------------------------- + +/** Example: mounting the handler on plain `node:http`. */ +function mountNode(): void { + //#region mountNode + createServer(toNodeHandler(handler)).listen(3000); + //#endregion mountNode +} +void mountNode; + +// --------------------------------------------------------------------------- +// "Validate Host and Origin in front of it" / "Pass authentication through" — +// no regions: http.md states the contract and links to the serving recipes +// (web-standard, express, hono, fastify) that build each mount. The harness +// below produces the `alice` result the auth section quotes. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// "Shape the response stream" — never invoked: `responseMode: 'json'` warns at +// construction by design, and the page quotes nothing from it. +// --------------------------------------------------------------------------- + +/** Example: pin the response shape to plain JSON. */ +function shapeResponse(factory: McpServerFactory) { + //#region shapeResponse + const jsonOnly = createMcpHandler(factory, { responseMode: 'json' }); + //#endregion shapeResponse + return jsonOnly; +} +void shapeResponse; + +// --------------------------------------------------------------------------- +// "Shut down" — never invoked: this program exits on its own. +// --------------------------------------------------------------------------- + +/** Example: tear down in-flight modern exchanges on SIGINT. */ +function shutDown(): void { + //#region shutDown + process.on('SIGINT', async () => { + await handler.close(); + process.exit(0); + }); + //#endregion shutDown +} +void shutDown; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A real `Client` drives each handler's +// `fetch` in process — the URL is never dialed (docs/testing.md owns that +// wiring). It produces the two tool results the page quotes verbatim. +// --------------------------------------------------------------------------- + +const { Client, StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); + +// "Create a handler" — the add-note result the page quotes. +const client = new Client({ name: 'http-docs-harness', version: '1.0.0' }); +await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) + }) +); +const saved = await client.callTool({ name: 'add-note', arguments: { text: 'ship the release notes' } }); +console.log(saved.content); +await client.close(); + +// "Pass authentication through" — the caller hands `fetch` a verified +// `AuthInfo`; the factory above reads it back as `ctx.authInfo`. +const authInfo = { token: 'verified-elsewhere', clientId: 'alice', scopes: ['notes'] }; +const alice = new Client({ name: 'http-docs-harness', version: '1.0.0' }); +await alice.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: (url, init) => perCaller.fetch(new Request(url, init), { authInfo }) + }) +); +const who = await alice.callTool({ name: 'whoami' }); +console.log(who.content); +await alice.close(); + +await handler.close(); +await perCaller.close(); diff --git a/examples/guides/serving/legacy-clients.examples.ts b/examples/guides/serving/legacy-clients.examples.ts new file mode 100644 index 0000000000..1a66c470ff --- /dev/null +++ b/examples/guides/serving/legacy-clients.examples.ts @@ -0,0 +1,107 @@ +/** + * Companion example for `docs/serving/legacy-clients.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions drives `handler.fetch` in process — no port, no socket — and + * produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/legacy-clients.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// --------------------------------------------------------------------------- +// "Choose a legacy posture" +// --------------------------------------------------------------------------- + +//#region createMcpHandler_legacyReject +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' }); + +const strict = createMcpHandler(buildServer, { legacy: 'reject' }); +//#endregion createMcpHandler_legacyReject + +// --------------------------------------------------------------------------- +// "Choose the same posture on stdio" — never invoked: `serveStdio` over real +// stdio would hold this program open on stdin. The posture is the point. +// --------------------------------------------------------------------------- + +/** Example: the same posture on the stdio entry. */ +function rejectOnStdio(): void { + //#region serveStdio_legacyReject + serveStdio(buildServer, { legacy: 'reject' }); + //#endregion serveStdio_legacyReject +} +void rejectOnStdio; + +// --------------------------------------------------------------------------- +// "Keep a sessionful 2025 deployment running" +// --------------------------------------------------------------------------- + +//#region isLegacyRequest_route +import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server'; + +const legacy = legacyStatelessFallback(buildServer); + +async function serve(request: Request): Promise<Response> { + if (await isLegacyRequest(request)) { + return legacy(request); + } + return strict.fetch(request); +} +//#endregion isLegacyRequest_route + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A 2025-era client opens with a claim-less +// `initialize` POST; build that request twice and send it to the strict +// handler, then through the `isLegacyRequest` branch. The page quotes both +// outputs verbatim; the self-checks at the bottom exit non-zero if either +// claim stops being observable. +// --------------------------------------------------------------------------- + +const legacyInitialize = () => + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy-host', version: '1.0.0' } } + }) + }); + +// "Choose a legacy posture" — the strict rejection the page quotes. +const rejected = await strict.fetch(legacyInitialize()); +const rejection = (await rejected.json()) as { error: { code: number; data: { supported: string[]; requested: string } } }; +console.log(rejected.status); +console.log(JSON.stringify(rejection, null, 2)); + +// "Keep a sessionful 2025 deployment running" — the same request through the +// branch reaches the legacy leg and completes the 2025 handshake over SSE. +const served = await serve(legacyInitialize()); +const sse = await served.text(); +const dataLine = sse.split('\n').find(line => line.startsWith('data: ')); +const initialized = JSON.parse(dataLine?.slice('data: '.length) ?? '{}') as { + result: { protocolVersion: string; serverInfo: { name: string; version: string } }; +}; +console.log(served.status); +console.log(initialized.result); + +// Self-verification — the page's claims must stay observable. +if (rejected.status !== 400 || rejection.error.code !== -32_022) { + throw new Error(`expected the 400 / -32022 strict rejection, got ${rejected.status} ${JSON.stringify(rejection)}`); +} +if (rejection.error.data.supported[0] !== '2026-07-28' || rejection.error.data.requested !== '2025-06-18') { + throw new Error(`expected the supported/requested revisions in the error data, got ${JSON.stringify(rejection.error.data)}`); +} +if (served.status !== 200 || initialized.result.protocolVersion !== '2025-06-18') { + throw new Error(`expected the legacy leg to complete the 2025 handshake, got ${served.status} ${JSON.stringify(initialized)}`); +} + +await strict.close(); diff --git a/examples/guides/serving/sessions-state-scaling.examples.ts b/examples/guides/serving/sessions-state-scaling.examples.ts new file mode 100644 index 0000000000..33c76b5eb9 --- /dev/null +++ b/examples/guides/serving/sessions-state-scaling.examples.ts @@ -0,0 +1,89 @@ +// docs: typecheck-only +/** + * Companion example for `docs/serving/sessions-state-scaling.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The regions are fragments of an HTTP + * deployment — transport options, an Express route, a handler option — and + * none of them may bind a port, so the file is typecheck-only. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * + * @module + */ + +// "Sessions" lead block — sessionIdGenerator turns sessions on for the +// hand-wired 2025-era Streamable HTTP transport. +//#region sessions_stateful +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { randomUUID } from 'node:crypto'; + +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() +}); +//#endregion sessions_stateful +void transport; + +// Imports for the function-wrapped regions below, kept out of the page's lead block. +import type { EventStore, McpServer, ServerEventBus } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest } from '@modelcontextprotocol/server'; +import type { Express, Request, Response } from 'express'; + +/** + * "Sessions" follow-up — one transport per session, routed by `Mcp-Session-Id` + * (mined from `examples/legacy-routing/server.ts`). + */ +function sessions_routing(app: Express, buildServer: () => McpServer) { + //#region sessions_routing + const sessions = new Map<string, NodeStreamableHTTPServerTransport>(); + + const route = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId)!.handleRequest(req, res, req.body); + return; + } + if (!sessionId && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId); + }; + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); + }; + + app.post('/mcp', route); + app.get('/mcp', route); + app.delete('/mcp', route); + //#endregion sessions_routing +} +void sessions_routing; + +/** "Resumability" — an EventStore implementation next to sessionIdGenerator. */ +function resumability_eventStore(databaseEventStore: EventStore) { + //#region resumability_eventStore + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: databaseEventStore + }); + //#endregion resumability_eventStore + return transport; +} +void resumability_eventStore; + +/** "Multi-node" — every node hands the same pub/sub-backed bus to createMcpHandler. */ +function multiNode_bus(buildServer: () => McpServer, redisBus: ServerEventBus) { + //#region multiNode_bus + const handler = createMcpHandler(buildServer, { bus: redisBus }); + //#endregion multiNode_bus + return handler; +} +void multiNode_bus; diff --git a/examples/guides/serving/stdio.examples.ts b/examples/guides/serving/stdio.examples.ts new file mode 100644 index 0000000000..18a445c5a5 --- /dev/null +++ b/examples/guides/serving/stdio.examples.ts @@ -0,0 +1,116 @@ +/** + * Runnable, type-checked companion for `docs/serving/stdio.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions + * are one linear stdio server program. The file runs in two modes: + * + * - `node --import tsx stdio.examples.ts --serve` — be that stdio server, plus + * the one deliberate `console.log` the page's gotcha section describes, and + * stay alive on stdin like any stdio server. + * - `node --import tsx stdio.examples.ts` (default) — the harness: spawn this + * file with `--serve`, send it an `initialize` request, and print every line + * the child wrote to each of its streams. The page quotes that output + * verbatim; the harness exits non-zero if the corruption it demonstrates + * ever stops being observable. + * + * @module + */ +/* eslint-disable no-console */ + +//#region serveStdio_basic +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +const handle = serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) — one factory builds the instance that serves the connection + return server; +}); +//#endregion serveStdio_basic + +//#region serveStdio_logStderr +console.error('notes server is listening on stdio'); +//#endregion serveStdio_logStderr + +//#region serveStdio_shutdown +process.on('SIGINT', () => { + void handle.close(); +}); +//#endregion serveStdio_shutdown + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). +// +// `--serve` mode: the regions above already made this process a real stdio +// server. Inject the page's gotcha — one `console.log` on the protocol channel +// — and stay alive on stdin (the spawning harness kills the child when done). +// +// Default mode: spawn this file with `--serve`, write a JSON-RPC `initialize` +// request to the child's stdin, then print every line the child wrote to each +// stream. "Log to stderr, never stdout" quotes this output verbatim. +// --------------------------------------------------------------------------- + +if (process.argv.includes('--serve')) { + // The bug the page demonstrates: one log line written to stdout. + console.log('debug: starting the notes server'); +} else { + const { spawn } = await import('node:child_process'); + const { dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const selfPath = fileURLToPath(import.meta.url); + const child = spawn(process.execPath, ['--no-warnings', '--import', 'tsx', selfPath, '--serve'], { + cwd: dirname(selfPath) + }); + + let childStdout = ''; + let childStderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + childStdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + childStderr += chunk; + }); + + // What an MCP host writes first: the `initialize` request, one JSON line on stdin. + child.stdin.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'host', version: '1.0.0' } } + })}\n` + ); + + // Wait for the `initialize` response to reach the child's stdout, then stop the child. + const deadline = Date.now() + 30_000; + while (!childStdout.includes('"id":1') && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 25)); + } + child.kill(); + await new Promise(resolve => child.once('exit', resolve)); + + const stdoutLines = childStdout.trimEnd().split('\n'); + const stderrLines = childStderr.trimEnd().split('\n'); + for (const line of stdoutLines) console.log(`[stdout] ${line}`); + for (const line of stderrLines) console.log(`[stderr] ${line}`); + + // Self-verification — the page's claims must stay observable, or this exits non-zero. + if (stdoutLines[0] !== 'debug: starting the notes server') { + throw new Error(`expected the stray console.log first on stdout, got ${JSON.stringify(stdoutLines)}`); + } + if (!stdoutLines[1]?.includes('"jsonrpc":"2.0"')) { + throw new Error(`expected the initialize response next on stdout, got ${JSON.stringify(stdoutLines)}`); + } + if (!stderrLines.includes('notes server is listening on stdio')) { + throw new Error(`expected the console.error banner on stderr, got ${JSON.stringify(stderrLines)}`); + } + + await handle.close(); + // The regions above also started a real stdio server on this process; end it here. + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +} diff --git a/examples/guides/serving/webStandard.examples.ts b/examples/guides/serving/webStandard.examples.ts new file mode 100644 index 0000000000..cc4172b6dd --- /dev/null +++ b/examples/guides/serving/webStandard.examples.ts @@ -0,0 +1,104 @@ +/** + * Runnable, type-checked companion for `docs-v2/serving/web-standard.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences by + * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). On a + * web-standard runtime the default export's `fetch` IS the request path, so the + * harness below the regions calls it directly with the page's curl request, + * prints the response the page quotes verbatim, and exits non-zero if it (or + * the `guarded` / `secured` wrappers' behavior) drifts. No port is ever bound. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/serving/webStandard.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +//#region createMcpHandler_exportDefault +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + server.registerTool( + 'add-note', + { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); + +export default handler; +//#endregion createMcpHandler_exportDefault + +//#region hostHeaderValidationResponse_guard +import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server'; + +const guarded = { + async fetch(request: Request): Promise<Response> { + const rejected = + hostHeaderValidationResponse(request, ['api.example.com']) ?? + originValidationResponse(request, ['app.example.com']); + return rejected ?? handler.fetch(request); + } +}; +//#endregion hostHeaderValidationResponse_guard + +// `verifyToken` stands in for your deployment's token verification (JWT +// validation, RFC 7662 introspection, a call to your IdP). The page points at +// docs-v2/serving/authorization.md for the real thing. +async function verifyToken(request: Request): Promise<AuthInfo> { + const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''; + return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; +} + +//#region McpHttpHandler_fetch_authInfo +const secured = { + async fetch(request: Request): Promise<Response> { + const authInfo = await verifyToken(request); + return handler.fetch(request, { authInfo }); + } +}; +//#endregion McpHttpHandler_fetch_authInfo + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). `handler.fetch` is exactly what a +// web-standard runtime calls on the default export, so calling it with the +// page's curl request IS the deployment path. The page quotes the body +// verbatim. The two wrapper exports the page describes are exercised too: +// `guarded` must answer 403 for a Host outside its allowlist, and `secured` +// must still serve the request. +// --------------------------------------------------------------------------- + +const curlRequest = (): Request => + new Request('http://127.0.0.1:8787/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + }); + +const response = await handler.fetch(curlRequest()); +const text = await response.text(); +console.log(text); + +const quotedOnPage = + 'event: message\n' + + 'data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object",' + + '"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1}'; +if (response.status !== 200 || text.trimEnd() !== quotedOnPage) { + throw new Error(`web-standard.md "Run it and verify" output drifted from the SDK: ${JSON.stringify(text)}`); +} + +// "Protect against DNS rebinding": a Host outside the allowlist never reaches `fetch`. +const guardedResponse = await guarded.fetch(curlRequest()); +if (guardedResponse.status !== 403) { + throw new Error(`web-standard.md guard claim failed: expected 403, got ${guardedResponse.status}`); +} + +// "Forward auth and the parsed body": the secured wrapper still serves the request. +const securedResponse = await secured.fetch(curlRequest()); +if (securedResponse.status !== 200) { + throw new Error(`web-standard.md auth claim failed: expected 200, got ${securedResponse.status}`); +} diff --git a/examples/package.json b/examples/package.json index 105f7669e5..56f8779e8b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -25,7 +25,9 @@ "@hono/node-server": "catalog:runtimeServerOnly", "@modelcontextprotocol/client": "workspace:^", "@mcp-examples/shared": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", "@modelcontextprotocol/express": "workspace:^", + "@modelcontextprotocol/fastify": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", @@ -34,6 +36,7 @@ "arktype": "catalog:devTools", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", + "fastify": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", "open": "^11.0.0", "valibot": "catalog:devTools", diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 9770b47a55..da5a2125a3 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -12,7 +12,9 @@ "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], "@modelcontextprotocol/core-internal": [ @@ -21,6 +23,12 @@ "@modelcontextprotocol/core-internal/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], + "@modelcontextprotocol/core-internal/schemas": [ + "./node_modules/@modelcontextprotocol/core/node_modules/@modelcontextprotocol/core-internal/src/types/schemas.ts" + ], + "@modelcontextprotocol/core-internal/auth": [ + "./node_modules/@modelcontextprotocol/core/node_modules/@modelcontextprotocol/core-internal/src/shared/auth.ts" + ], "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e74a91e86..9979795255 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,9 +313,15 @@ importers: '@modelcontextprotocol/client': specifier: workspace:^ version: link:../packages/client + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../packages/core '@modelcontextprotocol/express': specifier: workspace:^ version: link:../packages/middleware/express + '@modelcontextprotocol/fastify': + specifier: workspace:^ + version: link:../packages/middleware/fastify '@modelcontextprotocol/hono': specifier: workspace:^ version: link:../packages/middleware/hono @@ -340,6 +346,9 @@ importers: express: specifier: catalog:runtimeServerOnly version: 5.2.1 + fastify: + specifier: catalog:runtimeServerOnly + version: 5.8.4 hono: specifier: catalog:runtimeServerOnly version: 4.12.9 @@ -11452,7 +11461,7 @@ snapshots: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.15 rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: @@ -11466,7 +11475,7 @@ snapshots: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.15 rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: From 6b4c3239695d525378da9d15f1d16b78cbd126c0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:01 +0000 Subject: [PATCH 09/27] docs: write the advanced guide pages --- docs/advanced/custom-methods.md | 129 +++++++--- docs/advanced/custom-transports.md | 230 ++++++++++++++---- docs/advanced/gateway.md | 161 +++++++++--- docs/advanced/low-level-server.md | 173 ++++++++++--- docs/advanced/schema-libraries.md | 171 ++++++++++--- docs/advanced/wire-schemas.md | 163 ++++++++++--- .../advanced/custom-methods.examples.ts | 100 ++++++++ .../advanced/custom-transports.examples.ts | 210 ++++++++++++++++ examples/guides/advanced/gateway.examples.ts | 135 ++++++++++ .../advanced/low-level-server.examples.ts | 179 ++++++++++++++ .../advanced/schema-libraries.examples.ts | 129 ++++++++++ .../guides/advanced/wire-schemas.examples.ts | 104 ++++++++ 12 files changed, 1667 insertions(+), 217 deletions(-) create mode 100644 examples/guides/advanced/custom-methods.examples.ts create mode 100644 examples/guides/advanced/custom-transports.examples.ts create mode 100644 examples/guides/advanced/gateway.examples.ts create mode 100644 examples/guides/advanced/low-level-server.examples.ts create mode 100644 examples/guides/advanced/schema-libraries.examples.ts create mode 100644 examples/guides/advanced/wire-schemas.examples.ts diff --git a/docs/advanced/custom-methods.md b/docs/advanced/custom-methods.md index 5de11e0bf4..4104cfd039 100644 --- a/docs/advanced/custom-methods.md +++ b/docs/advanced/custom-methods.md @@ -1,21 +1,16 @@ --- -status: scaffold shape: how-to --- + # Custom methods -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Vendor-prefixed methods, extension capabilities. -teaches: setRequestHandler (3-arg schema overload), RequestHandlerSchemas, Client.request, ctx.mcpReq.notify, setNotificationHandler, registerCapabilities({ extensions }), getServerCapabilities().extensions -source: mined from docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings"; docs/server.md "Extension capabilities"; docs/client.md "Extension capabilities"; examples/custom-methods/ ---> +A **custom method** is a JSON-RPC method outside the MCP specification. Prefix it with a vendor namespace — `acme/search`, never a bare `search` — so it can never collide with a spec method. ## Handle a vendor-prefixed method on the server -<!-- teaches: setRequestHandler('vendor/x', { params, result }, handler) | salvage: docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings"; examples/custom-methods/server.ts --> -A non-spec method needs schemas: pass `{ params, result }` as the second argument and the SDK validates both directions. -```ts -// draft - API verified against packages/core-internal/src/shared/protocol.ts (setRequestHandler 3-arg Standard Schema overload) and packages/server/src/server/mcp.ts (McpServer.server) +`setRequestHandler` lives on the low-level [`Server`](./low-level-server.md), reached from an `McpServer` as `mcp.server`. A non-spec method needs schemas: pass `{ params, result }` as the second argument and the handler receives the parsed `params` object directly. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setRequestHandler_custom" import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; @@ -25,36 +20,112 @@ const SearchResult = z.object({ items: z.array(z.string()) }); const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' }); mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => { - return { items: Array.from({ length: limit }, (_, i) => `${query}-${i}`) }; + return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) }; }); ``` -<!-- result: the handler receives validated, typed params; a malformed acme/search is rejected before it runs --> + +The SDK validates incoming `params` against `SearchParams` before the handler runs; `result` types the handler's return value. A spec method never takes the schema bundle — `setRequestHandler('tools/call', handler)` resolves its schemas from the method name. + +::: tip +Send `acme/search` with `query: 42` and the request fails before your handler runs — the caller gets back an `Invalid params` JSON-RPC error: + +``` +Invalid params for acme/search: query: Invalid input: expected string, received number +``` + +::: ## Call it from the client -<!-- teaches: Client.request({ method, params }, ResultSchema) | salvage: examples/custom-methods/client.ts --> -<!-- code: await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult) --> + +Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring. For a non-spec method, `client.request` takes the request and a result schema; the SDK validates the response against it before the promise resolves and infers the return type from it. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#request_custom" +const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +console.log(result); +``` + +The handler's return value comes back validated and typed: + +``` +{ items: [ 'mcp-0', 'mcp-1', 'mcp-2' ] } +``` + +::: info +For spec methods, `client.request({ method: 'tools/list' })` takes no schema — the SDK resolves it from the method name, exactly as `setRequestHandler` does on the server. +::: ## Send a custom notification from the handler -<!-- teaches: ctx.mcpReq.notify({ method: 'acme/...', params }) for vendor-prefixed notifications --> -<!-- code: await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }) --> + +A custom **notification** is the one-way mirror of a custom request: vendor-prefixed, no result. Registering a method again replaces its handler — replace `acme/search` with one that reports progress through `ctx.mcpReq.notify`. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setRequestHandler_notify" +mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; +}); +``` + +`ctx.mcpReq.notify` sends each notification to the peer whose request is being handled, on the same connection. ## Receive it on the client -<!-- teaches: setNotificationHandler('acme/...', { params }, handler) — same schema rule as requests --> -<!-- code: client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => ...) --> + +`setNotificationHandler` follows the same rule as `setRequestHandler`: a non-spec notification method takes a `{ params }` schema, and the handler receives the parsed params. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setNotificationHandler_custom" +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + console.log(params); +}); + +await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult); +``` + +That one call logs both stages: + +``` +{ stage: 'start', pct: 0 } +{ stage: 'done', pct: 1 } +``` ## Declare an extension capability -<!-- teaches: registerCapabilities({ extensions: { 'com.example/x': {...} } }) before connecting; prefix-qualified identifiers | salvage: docs/server.md "Extension capabilities"; examples/extension-capabilities/server.ts --> -<!-- code: mcp.server.registerCapabilities({ extensions: { 'com.example/feature-flags': { flags: ['dark-mode'] } } }) --> + +An **extension capability** advertises a vendor feature during capability negotiation: `capabilities.extensions` maps a prefix-qualified extension identifier to that extension's settings object. Declare entries with `registerCapabilities` before connecting. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#registerCapabilities_extensions" +mcp.server.registerCapabilities({ + extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } } +}); +``` + +Every client that connects sees the entry. The settings value is free-form JSON; `{}` means supported with no settings. ## Read the negotiated extensions on the client -<!-- teaches: getServerCapabilities()?.extensions — advertised by initialize on legacy connections and server/discover on 2026-07-28 ones (one-line era cross-link) | salvage: docs/client.md "Extension capabilities" --> -<!-- code: const extensions = client.getServerCapabilities()?.extensions ?? {} --> + +After connecting, the advertised map is on `client.getServerCapabilities()`. + +```ts source="../../examples/guides/advanced/custom-methods.examples.ts#getServerCapabilities_extensions" +const extensions = client.getServerCapabilities()?.extensions ?? {}; +console.log(extensions); +``` + +The map arrives exactly as the server declared it: + +``` +{ + 'com.example/feature-flags': { flags: [ 'dark-mode', 'beta-search' ] } +} +``` + +Legacy connections advertise it in the `initialize` result and 2026-07-28 connections in `server/discover` — see [Protocol versions](../protocol-versions.md). ## Recap -<!-- the claims this page will prove: -* Non-spec methods take a { params, result } schema bundle; spec methods never do. -* client.request(request, ResultSchema) is the calling side; both directions are validated. -* Custom notifications mirror custom requests: notify on one side, setNotificationHandler with { params } on the other. -* capabilities.extensions advertises a vendor feature; the client reads the negotiated map after connect. -* Method names and extension identifiers are prefix-qualified — never bare words. ---> + +- `setRequestHandler(method, { params, result }, handler)` handles a non-spec method; spec methods never take the schema bundle. +- The SDK validates incoming `params` before the handler runs and rejects what fails with an `Invalid params` error; `result` types the handler's return value. +- `client.request(request, ResultSchema)` is the calling side; the SDK validates the response against the schema. +- Custom notifications mirror custom requests: `ctx.mcpReq.notify` on one side, `setNotificationHandler` with `{ params }` on the other. +- `capabilities.extensions` advertises a vendor feature before connecting; the client reads the negotiated map after. +- Method names and extension identifiers are prefix-qualified (`acme/search`, `com.example/feature-flags`) — never bare words. diff --git a/docs/advanced/custom-transports.md b/docs/advanced/custom-transports.md index 49494b7ba3..242d55937e 100644 --- a/docs/advanced/custom-transports.md +++ b/docs/advanced/custom-transports.md @@ -1,79 +1,203 @@ --- -status: scaffold shape: how-to --- # Custom transports -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Implement the Transport interface. -teaches: Transport, TransportSendOptions, JSONRPCMessage, start/send/close contract, onmessage/onerror/onclose, sessionId, setProtocolVersion, hasPerRequestStream, ReadBuffer, serializeMessage, deserializeMessage, InMemoryTransport.createLinkedPair -source: mined from docs/server.md "Transports"; docs/client.md "Connecting to a server"; net-new for the interface walkthrough ---> +A **transport** moves `JSONRPCMessage` values in both directions over a channel the SDK knows nothing about. Implement the `Transport` interface and `connect()` accepts it like a built-in one. ## Implement the `Transport` interface -<!-- teaches: Transport — three methods (start, send, close) and three callbacks (onmessage, onerror, onclose) | salvage: net-new (interface in packages/core-internal/src/shared/transport.ts) --> -A **transport** moves `JSONRPCMessage` values in both directions. Implement three methods and expose three callbacks; the `Client` and `Server` classes drive everything else. -```ts -// draft - API verified against packages/core-internal/src/shared/transport.ts (Transport interface; re-exported by @modelcontextprotocol/server and /client via core-internal/src/exports/public/index.ts) +Three methods — `start`, `send`, `close` — and three callbacks the SDK installs: `onmessage`, `onerror`, `onclose`. This loopback delivers each message straight to a linked peer in the same process. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_loopback" import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server'; -export class WebSocketServerTransport implements Transport { - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; - - constructor(private readonly socket: WebSocket) {} - - async start(): Promise<void> { - this.socket.onmessage = event => { - this.onmessage?.(JSON.parse(String(event.data)) as JSONRPCMessage); - }; - this.socket.onerror = () => this.onerror?.(new Error('websocket error')); - this.socket.onclose = () => this.onclose?.(); - } - - async send(message: JSONRPCMessage): Promise<void> { - this.socket.send(JSON.stringify(message)); - } - - async close(): Promise<void> { - this.socket.close(); - this.onclose?.(); - } +export class LoopbackTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + private peer?: LoopbackTransport; + + /** Cross-wire two ends: whatever one end sends, the other receives. */ + static link(a: LoopbackTransport, b: LoopbackTransport): void { + a.peer = b; + b.peer = a; + } + + async start(): Promise<void> { + // Open your channel here. The loopback has nothing to open. + } + + async send(message: JSONRPCMessage): Promise<void> { + const peer = this.peer; + if (!peer) throw new Error('Loopback peer is gone'); + queueMicrotask(() => peer.onmessage?.(message)); + } + + async close(): Promise<void> { + this.peer = undefined; + this.onclose?.(); + } } ``` -<!-- result: server.connect(new WebSocketServerTransport(socket)) speaks MCP over your channel --> + +The SDK never looks inside your channel: it calls `send` for every outbound message and expects every inbound one on `onmessage`. Both `@modelcontextprotocol/server` and `@modelcontextprotocol/client` export `Transport`, `TransportSendOptions`, and `JSONRPCMessage`, so one implementation serves either side. ## Honor the callback contract -<!-- teaches: callbacks are installed BEFORE start(); never call start() yourself when handing the transport to Client/Server — connect() does; close() must fire onclose --> -<!-- code: none — three-rule contract list, mirrored from the interface JSDoc --> + +The interface carries three rules that no type checker enforces. + +- Never call `start()` on a transport you hand to a `Client` or `Server`: `connect()` installs the three callbacks and then calls `start()` itself. A transport that starts reading before the callbacks exist drops messages. +- `close()` must end by firing your own `onclose` — the protocol layer tears down its side of the connection from that callback, however the channel ended. +- `onerror` reports out-of-band conditions (a malformed frame, a dropped socket) and is not necessarily fatal. For a failure the sender must see, throw from `send` instead. ## Connect it like a built-in transport -<!-- teaches: Client.connect(transport) / Server.connect(transport) take any Transport | salvage: docs/client.md "Connecting to a server" --> -<!-- code: await client.connect(new WebSocketClientTransport(socket)) --> + +`Client.connect()` and `McpServer.connect()` take any `Transport`. Link two loopback ends, hand one to each side, and call a tool. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#connect_loopback" +import { Client } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' }); +server.registerTool('ping', { description: 'Reply with pong' }, async () => ({ + content: [{ type: 'text', text: 'pong' }] +})); + +const client = new Client({ name: 'loopback-client', version: '1.0.0' }); + +const serverEnd = new LoopbackTransport(); +const clientEnd = new LoopbackTransport(); +LoopbackTransport.link(serverEnd, clientEnd); + +await server.connect(serverEnd); +await client.connect(clientEnd); + +const result = await client.callTool({ name: 'ping' }); +console.log(result.content); +``` + +The whole MCP handshake and the tool call run over the loopback; the handler's `content` comes back unchanged: + +``` +[ { type: 'text', text: 'pong' } ] +``` ## Frame messages over a byte stream -<!-- teaches: ReadBuffer, serializeMessage, deserializeMessage — the newline-delimited framing the stdio transports use, exported for reuse --> -<!-- code: readBuffer.append(chunk); for (let msg; (msg = readBuffer.readMessage()); ) this.onmessage?.(msg) --> + +The loopback hands its peer a parsed object. A socket hands you bytes — frame them with the same helpers the stdio transports use: `ReadBuffer` buffers chunks and yields one parsed message per newline-delimited line, `serializeMessage` writes one, and `deserializeMessage` parses a single line you already hold. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_socket" +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server'; +import type { Socket } from 'node:net'; + +export class SocketTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + private readonly readBuffer = new ReadBuffer(); + + constructor(private readonly socket: Socket) {} + + async start(): Promise<void> { + this.socket.on('data', chunk => { + try { + this.readBuffer.append(chunk); + let message = this.readBuffer.readMessage(); + while (message !== null) { + this.onmessage?.(message); + message = this.readBuffer.readMessage(); + } + } catch (error) { + this.onerror?.(error as Error); + } + }); + this.socket.on('error', error => this.onerror?.(error)); + this.socket.on('close', () => this.onclose?.()); + } + + async send(message: JSONRPCMessage): Promise<void> { + this.socket.write(serializeMessage(message)); + } + + async close(): Promise<void> { + this.socket.end(); + } +} +``` + +`readMessage()` returns `null` until a complete line has arrived and skips non-JSON lines, so partial chunks, coalesced writes, and stray debug output all come out as whole `JSONRPCMessage` values or not at all. + +::: tip +`ReadBuffer` throws once its buffer exceeds 10 MB (`STDIO_DEFAULT_MAX_BUFFER_SIZE`). Pass `new ReadBuffer({ maxBufferSize })` to raise the cap. +::: ## Report a session ID and the negotiated version -<!-- teaches: optional members the protocol layer calls back into — sessionId, setProtocolVersion(version), setSupportedProtocolVersions(versions) --> -<!-- code: sessionId getter + setProtocolVersion stub on the class --> + +Three optional members let the protocol layer talk back to your transport; the SDK uses each one only when it is present. Set `sessionId` when your channel has one, and declare `setProtocolVersion` to receive the protocol version the two sides negotiated during `initialize`. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_session" +export class SessionLoopbackTransport extends LoopbackTransport { + sessionId?: string; + + protocolVersion?: string; + + setProtocolVersion(version: string): void { + this.protocolVersion = version; + } +} +``` + +Connect a client and a server over a linked pair of these and both ends end up holding the same version; logging the client end's `protocolVersion` after `connect()` prints: + +``` +2025-11-25 +``` + +Which version you see depends on the connection's protocol era — see [Protocol versions](../protocol-versions.md). + +::: info +`setSupportedProtocolVersions` is the third optional member: `connect()` passes the local side's accepted versions into it, which is how the HTTP server transports know what to allow in the `MCP-Protocol-Version` header. +::: ## Opt into per-request cancellation -<!-- teaches: hasPerRequestStream + TransportSendOptions.requestSignal — only for transports that open one underlying request per outbound JSON-RPC request; single-channel transports leave it undefined --> -<!-- code: readonly hasPerRequestStream = true; send(message, { requestSignal }) honors the abort --> + +Declare `hasPerRequestStream` only on a transport that opens one underlying request per outbound JSON-RPC request, and forward `requestSignal` to that request. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_perRequest" +readonly hasPerRequestStream = true; + +async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', ...options?.headers }, + body: JSON.stringify(message), + signal: options?.requestSignal + }); + this.onmessage?.(parseJSONRPCMessage(await response.json())); +} +``` + +On a 2026-07-28 connection the protocol layer cancels an in-flight request by aborting that request's `requestSignal` instead of sending `notifications/cancelled` — see [Protocol versions](../protocol-versions.md). Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them. ## Test it against the in-memory pair -<!-- teaches: InMemoryTransport.createLinkedPair as the reference Transport implementation and the harness to drive yours --> -<!-- code: const [clientSide, serverSide] = InMemoryTransport.createLinkedPair() --> + +`InMemoryTransport` is the reference implementation: the smallest `Transport` the SDK ships, and a known-good baseline for the client and server you drive your own transport with. + +```ts source="../../examples/guides/advanced/custom-transports.examples.ts#inMemory_pair" +import { InMemoryTransport } from '@modelcontextprotocol/client'; + +const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair(); +``` + +Run the same client and server over both pairs: the `ping` call above returns the same `content` over `InMemoryTransport` as over the loopback, and anything that differs is a bug in your transport. [Test a server](../testing.md) builds its whole harness on this pair. ## Recap -<!-- the claims this page will prove: -* A transport is start/send/close plus onmessage/onerror/onclose — nothing else is required. -* connect() installs the callbacks and calls start() for you; never call start() first. -* ReadBuffer, serializeMessage and deserializeMessage give you stdio-style framing for free. -* sessionId, setProtocolVersion and hasPerRequestStream are optional hooks the protocol layer uses when present. -* InMemoryTransport is both the smallest reference implementation and the test harness for yours. ---> + +- A transport is `start`, `send`, `close` plus `onmessage`, `onerror`, `onclose` — nothing else is required. +- `connect()` installs the callbacks, then calls `start()` for you; `close()` must fire `onclose`. +- `ReadBuffer`, `serializeMessage`, and `deserializeMessage` give you the newline-delimited framing the stdio transports use. +- `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, and `hasPerRequestStream` are optional members the SDK uses only when they are present. +- `InMemoryTransport.createLinkedPair()` is the reference implementation and the baseline to test your transport against. diff --git a/docs/advanced/gateway.md b/docs/advanced/gateway.md index fe695a4738..6da8289e28 100644 --- a/docs/advanced/gateway.md +++ b/docs/advanced/gateway.md @@ -1,65 +1,160 @@ --- -status: scaffold shape: how-to --- # Gateways and worker fleets -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Zero-round-trip reconnect with a prior discover result. -teaches: ConnectOptions.prior, DiscoverResult, Client.getDiscoverResult, Client.discover, versionNegotiation mode 'auto', SdkErrorCode.EraNegotiationFailed, Client.listen -source: mined from docs/client.md "Skipping the probe: connect({ prior })" and "Protocol version negotiation (2026-07-28 revision)"; examples/gateway/ ---> +A **gateway** — a proxy, a worker pool, any process that fronts one MCP server with many short-lived clients — probes the server once and reuses the answer for every connection after it. ## Connect with a prior discover result -<!-- teaches: connect(transport, { prior }) adopts a persisted DiscoverResult with zero round trips | salvage: docs/client.md "Skipping the probe: connect({ prior })" --> -A fleet that already knows the server's advertisement never has to probe again: pass it as `prior` and `connect()` sends nothing on the wire. -```ts -// draft - API verified against packages/client/src/client/client.ts (ConnectOptions.prior, getDiscoverResult) and packages/client/src/index.ts (Client, StreamableHTTPClientTransport, DiscoverResult) +`connect()` takes an optional `prior`: a persisted `DiscoverResult` from an earlier probe. With it, `connect()` adopts the server's advertisement directly and sends nothing on the wire. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#connect_prior" import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const url = new URL('https://api.example.com/mcp'); +const url = new URL('http://localhost:3000/mcp'); -// Probe once (here via the 'auto'-mode connect), persist the result … +// Probe once … const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await bootstrap.connect(new StreamableHTTPClientTransport(url)); const persisted = JSON.stringify(bootstrap.getDiscoverResult()); -// … then every worker connects with zero round trips. +// … then every other client connects with zero round trips. const worker = new Client({ name: 'worker', version: '1.0.0' }); await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); ``` -<!-- result: worker.callTool works immediately; the server sees no extra discover or initialize --> + +`worker` is connected: `callTool` works immediately, and the server has not heard from it yet. + +`connect({ prior })` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md). ## Probe once at bootstrap -<!-- teaches: where a DiscoverResult comes from — an 'auto'/pinned connect or an explicit client.discover(); getDiscoverResult() reads it back | salvage: docs/client.md "Protocol version negotiation (2026-07-28 revision)" --> -<!-- code: bootstrap.getDiscoverResult() after the auto-mode connect --> + +An `'auto'`-mode (or pinned) connect sends `server/discover` and records the answer; `getDiscoverResult()` reads it back. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#bootstrap_probe" +console.log(bootstrap.getDiscoverResult()); +``` + +The recorded value is the server's whole advertisement — supported versions, capabilities, identity, instructions: + +``` +{ + ttlMs: 0, + cacheScope: 'private', + supportedVersions: [ '2026-07-28' ], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'gateway-target', version: '1.0.0' }, + resultType: 'complete' +} +``` + +::: tip +An already-connected client can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. A default-mode connect never probes, so its `getDiscoverResult()` is `undefined` — [Protocol versions](../protocol-versions.md#pin-an-era) lists the negotiation modes. +::: ## Persist the advertisement -<!-- teaches: DiscoverResult round-trips through JSON.stringify/JSON.parse by design — Redis, a config map, a process-local cache | salvage: examples/gateway/client.ts steps 2-3 --> -<!-- code: redis.set(key, JSON.stringify(discovered)); later JSON.parse(await redis.get(key)) as DiscoverResult --> + +The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back wherever a client needs it. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#persist_advertisement" +import type { DiscoverResult } from '@modelcontextprotocol/client'; + +const prior = JSON.parse(persisted) as DiscoverResult; +``` + +Nothing about `prior` is tied to the process that probed: any client that can reach the same URL can adopt it. ## Fan out to workers -<!-- teaches: every worker connects { prior } from the same blob; capabilities, serverInfo, instructions and the negotiated version are adopted directly | salvage: examples/gateway/client.ts step 3 --> -<!-- code: workers.map(name => new Client({ name, version }).connect(transport, { prior })) --> + +Build every replica from the same blob; the `request_count` call after them is the proof. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#fan_out" +const fleet = await Promise.all( + ['worker-a', 'worker-b', 'worker-c'].map(async name => { + const replica = new Client({ name, version: '1.0.0' }); + await replica.connect(new StreamableHTTPClientTransport(url), { prior }); + return replica; + }) +); + +const proof = await worker.callTool({ name: 'request_count' }); +console.log(proof.structuredContent); +``` + +`request_count` is a tool on this page's example server that returns how many MCP requests reached the process. Five clients are connected by now — `bootstrap`, `worker`, three replicas — and the server has answered two requests: + +``` +{ requests: 2 } +``` + +The bootstrap probe was the first request and the `request_count` call itself the second. The four `connect({ prior })` calls sent nothing. ## Reuse only within one authorization context -<!-- teaches: the advertisement is what the server returned for the bootstrap credential — never share a DiscoverResult across principals | salvage: examples/gateway/client.ts security note --> -<!-- code: none — ::: warning aside; the rule is the content --> + +The advertisement is what the server returned to the credential that probed. + +::: warning +Never share a persisted `DiscoverResult` across principals — key the blob on the authorization context that obtained it (a credential hash works). The server still authorizes every request, so a wider `prior` grants nothing, but it misleads client-side capability gating. +::: ## Open a listen stream when a worker needs notifications -<!-- teaches: connect({ prior }) never auto-opens subscriptions/listen; prior-connected workers are request-only until you call client.listen(filter) | salvage: docs/client.md "Skipping the probe: connect({ prior })" final paragraph --> -<!-- code: await worker.listen({ tools: {} }) on the one worker that watches for changes --> + +`connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#listen_worker" +const subscription = await worker.listen({ toolsListChanged: true }); +console.log(subscription.honoredFilter); +``` + +The server acknowledges the filter it agreed to honor: + +``` +{ toolsListChanged: true } +``` + +From here the stream behaves like any other subscription — [Subscriptions](../clients/subscriptions.md) covers the notification handlers and the close semantics. + +::: info +A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`. +::: ## Handle a stale or incompatible advertisement -<!-- teaches: connect({ prior }) is 2026-07-28+ only and rejects with SdkError(EraNegotiationFailed) when no modern version is shared; re-probe and re-persist on that path | salvage: docs/client.md "Skipping the probe: connect({ prior })" --> -<!-- code: catch SdkError, check error.code === SdkErrorCode.EraNegotiationFailed, fall back to a fresh probe --> + +A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport. + +```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_stale" +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; + +const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] }; + +const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +try { + await late.connect(new StreamableHTTPClientTransport(url), { prior: stale }); +} catch (error) { + if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error; + console.log(error.code); + + // Fall back to a fresh probe, then re-persist getDiscoverResult(). + await late.connect(new StreamableHTTPClientTransport(url)); + console.log('re-probed:', late.getNegotiatedProtocolVersion()); +} +``` + +The rejection happens before the transport starts, so the same `Client` connects again on the fallback path: + +``` +ERA_NEGOTIATION_FAILED +re-probed: 2026-07-28 +``` + +Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read. ## Recap -<!-- the claims this page will prove: -* connect(transport, { prior }) adopts a persisted DiscoverResult with zero round trips. -* The advertisement comes from one bootstrap probe ('auto'/pinned connect or client.discover()) and JSON-round-trips by design. -* Workers on the prior path are request-only; call listen() yourself if one needs notifications. -* Never reuse a DiscoverResult across authorization contexts. -* An incompatible prior rejects with EraNegotiationFailed — fall back to a fresh probe. ---> + +- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips. +- The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back. +- The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server. +- Reuse a `DiscoverResult` only across clients that present the same authorization context. +- Prior-connected clients are request-only; call `listen()` on the one that needs notifications. +- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index dacb940892..106abe05d3 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -1,66 +1,161 @@ --- -status: scaffold shape: explanation --- + # Low-level Server -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria. -teaches: Server, ServerOptions.capabilities, setRequestHandler (spec-method overload), RequestTypeMap, McpServer.server, McpServerFactory (accepts Server) -source: mined from docs/migration/upgrade-to-v2.md "Low-level protocol & handler context (ctx)" and "setRequestHandler / setNotificationHandler use method strings"; docs/server.md "Tools" ---> +`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds. ## Build the server and list your tools by hand -<!-- teaches: Server, ServerOptions.capabilities, setRequestHandler('tools/list') | salvage: docs/migration/upgrade-to-v2.md "setRequestHandler / setNotificationHandler use method strings" --> -`Server` gives you the protocol with no registration layer on top: declare the capability, then answer `tools/list` yourself. -```ts -// draft - API verified against packages/server/src/server/server.ts (Server, ServerOptions) and packages/core-internal/src/shared/protocol.ts (setRequestHandler spec-method overload) +Declare the `tools` capability in the constructor and answer `tools/list` yourself. `inputSchema` is the raw JSON Schema the client and the model see. + +```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_listTools" import { Server } from '@modelcontextprotocol/server'; +const catalog = [ + { name: 'Espresso cup', price: 12 }, + { name: 'Travel mug', price: 24 }, + { name: 'Mug rack', price: 36 } +]; + const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'search', - description: 'Search the product catalog', - inputSchema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - }, - }, - ], + tools: [ + { + name: 'search', + description: 'Search the product catalog', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Substring to match against product names' } }, + required: ['query'] + } + } + ] })); ``` -<!-- result: a client's tools/list returns exactly the array you wrote — the SDK derived none of it --> + +A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it. + +::: tip +Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you. +::: ## Handle `tools/call` yourself -<!-- teaches: setRequestHandler('tools/call'), RequestTypeMap['tools/call'], CallToolResult | salvage: docs/migration/upgrade-to-v2.md "Low-level protocol & handler context (ctx)" --> -<!-- code: setRequestHandler('tools/call', async (request, ctx) => ...) — dispatch on request.params.name, read request.params.arguments, return { content } --> + +`tools/call` is one handler for every tool. Dispatch on `request.params.name` and read `request.params.arguments` yourself. + +```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_callTool" +server.setRequestHandler('tools/call', async request => { + if (request.params.name !== 'search') { + return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; + } + const { query } = request.params.arguments as { query: string }; + const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); + return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; +}); +``` + +An in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring — calls `search` with `{ query: 'mug' }` and the handler's `content` comes back unchanged: + +``` +[ { type: 'text', text: 'Travel mug\nMug rack' } ] +``` + +Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes: + +``` +ProtocolError -32603: query.toLowerCase is not a function +``` + +`callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](../servers/errors.md) covers the difference. ## Validate arguments yourself -<!-- teaches: what registerTool was doing for you (JSON Schema derivation + pre-handler validation); fromJsonSchema as the halfway point --> -<!-- code: parse request.params.arguments by hand (or fromJsonSchema(inputSchema)['~standard'].validate) before touching it --> + +From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together. + +`fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments. + +```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_validate" +const SearchArguments = fromJsonSchema<{ query: string }>({ + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] +}); + +server.setRequestHandler('tools/call', async request => { + if (request.params.name !== 'search') { + return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; + } + const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {}); + if (parsed.issues) { + return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true }; + } + const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase())); + return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; +}); +``` + +The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry: + +``` +{ + content: [ { type: 'text', text: 'data/query must be string' } ], + isError: true +} +``` + +Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object. ## Serve it with the same entry points -<!-- teaches: McpServerFactory accepts McpServer | Server — serveStdio and createMcpHandler take this Server unchanged | salvage: docs/server.md "Transports" --> -<!-- code: serveStdio(() => server) — identical to the high-level path --> + +`serveStdio` — from `@modelcontextprotocol/server/stdio` — and `createMcpHandler` each take an `McpServerFactory`, and the factory returns either an `McpServer` or a `Server`. + +```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_serve" +serveStdio(() => server); +createMcpHandler(() => server); +``` + +Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged. ## Reach the low level from `McpServer` -<!-- teaches: McpServer.server escape hatch; mixing registerTool with hand-registered handlers | salvage: docs/server.md "Extension capabilities" (the server.server idiom) --> -<!-- code: mcp.server.setRequestHandler(...) on an existing McpServer --> + +Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for. + +```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch" +const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + +mcp.registerTool( + 'search', + { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, + async ({ query }) => { + const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; + } +); + +const subscriptions = new Set<string>(); +mcp.server.setRequestHandler('resources/subscribe', async request => { + subscriptions.add(request.params.uri); + return {}; +}); +``` + +`registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md). ## Decide which layer to build on -<!-- teaches: the criteria — McpServer for tools/resources/prompts (schema payoff, list-changed bookkeeping, completions); Server when you own dispatch (gateways, dynamic tool sets, non-standard registries, custom methods) --> -<!-- code: none — decision prose; ends with the default ruling: start on McpServer, drop down per handler via mcp.server --> + +Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](../servers/completion.md), and the list/read/get dispatch for every registry. + +Build on `Server` when you own dispatch: a [gateway](./gateway.md) that forwards whatever method arrives, a tool set computed per request from an external registry, or [custom methods](./custom-methods.md) outside the spec. + +You never choose once for the whole program. Start on `McpServer` and take over individual methods through `mcp.server` as they need it. ## Recap -<!-- the claims this page will prove: -* Server is the protocol layer: setRequestHandler(method, handler) and nothing else. -* On Server you write the JSON Schema and the validation that registerTool derives from one Zod schema. -* serveStdio and createMcpHandler accept a Server factory unchanged. -* McpServer.server is the escape hatch — you never have to choose for the whole program. -* Default to McpServer; drop to Server only when you own dispatch. ---> + +- `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top. +- On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema. +- A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result. +- `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged. +- `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch. diff --git a/docs/advanced/schema-libraries.md b/docs/advanced/schema-libraries.md index 3a19dc39f7..18bfd7b3bc 100644 --- a/docs/advanced/schema-libraries.md +++ b/docs/advanced/schema-libraries.md @@ -1,59 +1,164 @@ --- -status: scaffold shape: how-to --- # Schema libraries -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Valibot/ArkType, JSON-Schema-in, pluggable validators. -teaches: registerTool (Standard Schema overload), StandardSchemaWithJSON, @valibot/to-json-schema, fromJsonSchema, jsonSchemaValidator option, AjvJsonSchemaValidator, CfWorkerJsonSchemaValidator -source: mined from docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)" and "Automatic JSON Schema validator selection by runtime"; examples/schema-validators/ ---> - ## Register a tool with an ArkType schema -<!-- teaches: registerTool accepts any Standard-Schema-with-JSON value, not only Zod | salvage: docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)"; examples/schema-validators/server.ts --> -`inputSchema` takes any **Standard Schema** that can produce JSON Schema — ArkType works as-is. -```ts -// draft - API verified against packages/server/src/server/mcp.ts (registerTool StandardSchemaWithJSON overload) +`inputSchema` accepts any **Standard Schema** that can produce JSON Schema — ArkType works as-is, no wrapper, exactly like the Zod schemas in [Tools](../servers/tools.md). + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_arktype" import { McpServer } from '@modelcontextprotocol/server'; import { type } from 'arktype'; -const server = new McpServer({ name: 'greeter', version: '1.0.0' }); +const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' }); server.registerTool( - 'greet', - { description: 'Greet someone', inputSchema: type({ name: 'string' }) }, - async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + 'greet', + { + description: 'Greet someone by name', + inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' }) + }, + async ({ name, times }) => ({ + content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }] + }) ); ``` -<!-- result: same payoff as Zod — derived JSON Schema, pre-handler validation, inferred handler argument types --> + +From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types — `name` is `string`, `times` is `number | undefined`. + +Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring. Call `greet` with `times: 99` and the SDK rejects the call with ArkType's own message; the handler never runs: + +``` +{ + content: [ + { + type: 'text', + text: 'Input validation error: Invalid arguments for tool greet: times: times must be at most 5 (was 99)' + } + ], + isError: true +} +``` + +::: info Coming from v1? +Raw shapes (`inputSchema: { name: z.string() }`) are deprecated — pass a schema object. See the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Register a tool with a Valibot schema -<!-- teaches: Valibot needs the @valibot/to-json-schema wrapper to expose JSON Schema conversion | salvage: examples/schema-validators/server.ts --> -<!-- code: inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) --> + +Valibot does not expose JSON Schema conversion on the schema itself — wrap it with `toStandardJsonSchema` from `@valibot/to-json-schema`. + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_valibot" +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +server.registerTool( + 'shout', + { description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, + async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] }) +); +``` + +`tools/list` now advertises `shout` with the JSON Schema the wrapper derives, and Valibot parses every call that reaches the handler. ## Start from JSON Schema you already have -<!-- teaches: fromJsonSchema(schema) wraps a plain JSON Schema document into a Standard Schema you can pass to inputSchema/outputSchema | salvage: docs/migration/upgrade-to-v2.md "Standard Schema objects (raw shapes deprecated)" --> -<!-- code: inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }) --> + +`fromJsonSchema` (exported from `@modelcontextprotocol/server`) wraps a plain JSON Schema document so you can register it without a schema library. The generic parameter types the handler's arguments; omit it and they are `unknown`. + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_fromJsonSchema" +import { fromJsonSchema } from '@modelcontextprotocol/server'; + +server.registerTool( + 'farewell', + { + description: 'Say goodbye', + inputSchema: fromJsonSchema<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }) + }, + async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] }) +); +``` + +`tools/list` advertises the document you passed, unchanged: + +``` +{ + type: 'object', + properties: { name: { type: 'string' } }, + required: [ 'name' ] +} +``` + +The SDK checks every call against it with a real **JSON Schema validator** — the last two sections pick which one. ## Validate structured output with any library -<!-- teaches: outputSchema works with the same Standard Schema rule; structuredContent is validated against it | salvage: examples/schema-validators/server.ts "get-weather" --> -<!-- code: outputSchema with the chosen library; handler returns { content, structuredContent } --> + +`outputSchema` — and a prompt's `argsSchema` — follow the same Standard Schema rule. Return the matching value as `structuredContent`, next to the human-readable `content`. + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_outputSchema" +server.registerTool( + 'measure', + { + description: 'Measure the length of a name', + inputSchema: type({ name: 'string' }), + outputSchema: type({ name: 'string', length: 'number' }) + }, + async ({ name }) => { + const output = { name, length: name.length }; + return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; + } +); +``` + +The SDK validates `structuredContent` against the ArkType schema before the result leaves your server. Calling `measure` with `{ name: 'Ada' }` returns both renderings: + +``` +{ + content: [ { type: 'text', text: '{"name":"Ada","length":3}' } ], + structuredContent: { name: 'Ada', length: 3 } +} +``` ## Swap the JSON Schema validator -<!-- teaches: jsonSchemaValidator option on ServerOptions; @modelcontextprotocol/server/validators/ajv subpath (Ajv, addFormats, AjvJsonSchemaValidator) | salvage: docs/migration/upgrade-to-v2.md "Automatic JSON Schema validator selection by runtime" --> -<!-- code: new McpServer(info, { jsonSchemaValidator: new AjvJsonSchemaValidator(customAjv) }) --> + +The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](../servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`. + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_ajv" +import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + +const ajv = new Ajv({ strict: true, allErrors: true }); +addFormats(ajv); +const validator = new AjvJsonSchemaValidator(ajv); + +const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator }); +``` + +`strict` now checks elicitation form responses with your `Ajv` instance. + +::: warning +`jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`. +::: ## Pick the validator for your runtime -<!-- teaches: the default is runtime-selected (AJV on Node.js, @cfworker/json-schema on browser/workerd); /validators/cf-worker subpath to force it --> -<!-- code: import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker' --> + +Leave `jsonSchemaValidator` unset and the SDK selects by runtime: AJV on Node.js, `@cfworker/json-schema` on workerd and in browsers. Import from the `validators/cf-worker` subpath to pin the lightweight one anywhere. + +```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_cfWorker" +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; + +const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }); +``` + +`edge` runs the same two validation paths through `@cfworker/json-schema` instead of AJV, on any runtime. ## Recap -<!-- the claims this page will prove: -* inputSchema and outputSchema accept any Standard Schema that exposes JSON Schema — Zod, ArkType, Valibot (via @valibot/to-json-schema). -* The raw-shape ZodRawShape overload is deprecated; pass a schema object. -* fromJsonSchema turns an existing JSON Schema document into something you can register. -* The JSON Schema validator is pluggable: pass jsonSchemaValidator, or import a provider from a validators/ subpath. -* The default validator is chosen by runtime; you only override it to pin or configure one. ---> + +- `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`. +- The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object. +- `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments. +- `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument. +- The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one. diff --git a/docs/advanced/wire-schemas.md b/docs/advanced/wire-schemas.md index 70c383b878..d9a535627c 100644 --- a/docs/advanced/wire-schemas.md +++ b/docs/advanced/wire-schemas.md @@ -1,55 +1,158 @@ --- -status: scaffold shape: how-to --- # Wire schemas -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: @modelcontextprotocol/core for gateways/proxies (raw wire schemas). -teaches: @modelcontextprotocol/core, CallToolResultSchema (and the ~160 spec *Schema constants), JSONRPCMessageSchema, the OAuth/OpenID schema group, where the TypeScript types live instead -source: mined from docs/migration/upgrade-to-v2.md "Zod *Schema constants moved to @modelcontextprotocol/core"; packages/core/src/index.ts header ---> +`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. ## Validate a wire payload -<!-- teaches: @modelcontextprotocol/core exports the exact Zod schemas the SDK validates with; *.safeParse on untrusted JSON | salvage: docs/migration/upgrade-to-v2.md "Zod *Schema constants moved to @modelcontextprotocol/core" --> -A gateway holds raw JSON, not SDK objects. `@modelcontextprotocol/core` ships the spec's Zod schemas so you can validate it directly. -```ts -// draft - API verified against packages/core/src/index.ts (CallToolResultSchema re-export) +`CallToolResultSchema.safeParse` validates an upstream body before you relay it. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_validateResult" import { CallToolResultSchema } from '@modelcontextprotocol/core'; -const parsed = CallToolResultSchema.safeParse(payload); +// The body an upstream server returned for a tools/call you forwarded. +const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}'); + +const parsed = CallToolResultSchema.safeParse(body); if (!parsed.success) { - throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`); + throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`); } +console.log(parsed.data.content); +``` + +`parsed.data` is the typed result: + +``` +[ { type: 'text', text: 'Travel mug' } ] +``` + +Hand the same schema a malformed body and `safeParse` returns the failure instead of throwing. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_validateResult_invalid" +const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' }); +console.log(malformed.error?.issues); +``` + +The error names the field that broke the contract: + +``` +[ + { + expected: 'array', + code: 'invalid_type', + path: [ 'content' ], + message: 'Invalid input: expected array, received string' + } +] ``` -<!-- result: parsed.data is the typed result; a malformed upstream response is rejected at the boundary --> + +::: info Coming from v1? +These are the `*Schema` constants v1 exported from `@modelcontextprotocol/sdk/types.js`. The codemod rewrites the import path — see the [upgrade guide](../migration/upgrade-to-v2.md). +::: ## Decide whether you need this package at all -<!-- teaches: the audience split — Client/Server users never import core; gateways, proxies and test harnesses that touch raw JSON-RPC do --> -<!-- code: none — two-sentence router; links back to servers/ and clients/ for the SDK-object path --> + +If you build with `McpServer` or `Client`, skip this package: [tools](../servers/tools.md) arrive in your handler already validated, and [tool calls](../clients/calling.md) come back as typed results. Reach for `@modelcontextprotocol/core` when nothing stands between you and the JSON — gateways, proxies, test harnesses, [worker fleets](./gateway.md). + +Install it separately (`npm install @modelcontextprotocol/core`) — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` keep a Zod-free public surface and never depend on it. The package is runtime-neutral; `zod` is its only dependency. ## Pick the schema for the message you hold -<!-- teaches: naming convention <SpecType>Schema; the request/result/notification/params families; JSONRPCMessageSchema for the undecoded envelope --> -<!-- code: JSONRPCMessageSchema.parse(line) on an incoming frame --> + +Every named type in the spec has a matching constant, `<SpecType>Schema`. When you do not yet know which one you hold, `JSONRPCMessageSchema` validates the undecoded envelope. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_envelope" +import { JSONRPCMessageSchema } from '@modelcontextprotocol/core'; + +const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}'; +const message = JSONRPCMessageSchema.parse(JSON.parse(frame)); +``` + +`message` narrows to one of the four JSON-RPC shapes — request, notification, result response, error response — and an invalid frame throws a `ZodError`. + +The constants come in the same families as the spec: requests (`CallToolRequestSchema`), results (`ListToolsResultSchema`), notifications (`ProgressNotificationSchema`), and `*ParamsSchema` for when you hold only the `params` object (`CallToolRequestParamsSchema`). ## Route raw JSON-RPC in a proxy -<!-- teaches: parse the envelope once, branch on method, validate params with the per-method schema — no Client or Server in the path --> -<!-- code: switch on message.method, then CallToolRequestSchema.safeParse(message) before forwarding --> + +Parse the envelope once, branch on `method`, then validate with the per-method request schema before forwarding. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_route" +import { CallToolRequestSchema } from '@modelcontextprotocol/core'; + +if ('method' in message) { + switch (message.method) { + case 'tools/call': { + const call = CallToolRequestSchema.parse(message); + console.log(`forward tools/call for ${call.params.name} upstream`); + break; + } + default: + console.log(`forward ${message.method} unchanged`); + } +} +``` + +`call.params.name` is a typed `string`, with no `Client` or `Server` anywhere in the path: + +``` +forward tools/call for search upstream +``` + +For everything beyond validation — sessions, capability negotiation, request correlation — build on the SDK instead: see the [low-level server](./low-level-server.md). ## Validate OAuth and discovery metadata -<!-- teaches: the second export group — OAuth/OpenID *Schema constants for token responses, protected-resource metadata, authorization-server metadata --> -<!-- code: OAuthMetadataSchema.safeParse(await response.json()) --> + +The second export group covers OAuth and OpenID discovery. `OAuthMetadataSchema` validates an authorization server's metadata document. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_oauthMetadata" +import { OAuthMetadataSchema } from '@modelcontextprotocol/core'; + +// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server. +const response = new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }) +); + +const metadata = OAuthMetadataSchema.parse(await response.json()); +console.log(metadata.token_endpoint); +``` + +A document missing a required endpoint fails the parse; a valid one comes back typed: + +``` +https://auth.example.com/token +``` + +The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. ## Get the TypeScript types, guards and errors from the SDK packages -<!-- teaches: core is Zod values ONLY; the spec types, isJSONRPCRequest-style guards and error classes ship from @modelcontextprotocol/server and /client (and z.infer works on any core schema) --> -<!-- code: import type { CallToolResult } from '@modelcontextprotocol/client' next to the core schema import --> + +`@modelcontextprotocol/core` exports Zod values and nothing else. The spec types, the `isJSONRPCRequest`-style guards, and the error classes are public API of `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — import them from whichever package you already depend on. + +```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_types" +import type { CallToolResult } from '@modelcontextprotocol/client'; +import * as z from 'zod/v4'; + +// The SDK's spec type and the schema's own inferred output describe the same value. +const relayed: CallToolResult = parsed.data; +type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>; +``` + +The assignment typechecks: what a core schema parses is what the SDK packages type. A package that depends only on core derives the same types with `z.infer`. + +::: tip +To check a value's shape without taking a Zod dependency at all, use the `isSpecType` guards exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`: `isSpecType.CallToolResult(value)`. +::: ## Recap -<!-- the claims this page will prove: -* @modelcontextprotocol/core re-exports the SDK's own spec + OAuth Zod schemas and nothing else. -* Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not normal Client/Server users. -* Every spec type has a <Name>Schema constant; JSONRPCMessageSchema validates the undecoded envelope. -* Types, guards and error classes are not in core — import them from @modelcontextprotocol/server or /client. -* The package is runtime-neutral; zod is its only dependency. ---> + +- `@modelcontextprotocol/core` exports the SDK's own spec and OAuth/OpenID Zod schemas, and nothing else. +- Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not `Client` or `Server` users. +- Every spec type has a `<Name>Schema` constant; `JSONRPCMessageSchema` validates the undecoded envelope. +- Types, guards and error classes are not in core — import them from `@modelcontextprotocol/server` or `@modelcontextprotocol/client`. +- The package is runtime-neutral; `zod` is its only dependency. diff --git a/examples/guides/advanced/custom-methods.examples.ts b/examples/guides/advanced/custom-methods.examples.ts new file mode 100644 index 0000000000..c50286fcad --- /dev/null +++ b/examples/guides/advanced/custom-methods.examples.ts @@ -0,0 +1,100 @@ +/** + * Companion example for `docs/advanced/custom-methods.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/custom-methods.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region setRequestHandler_custom +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' }); + +mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => { + return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) }; +}); +//#endregion setRequestHandler_custom + +// "Declare an extension capability" — must happen before the server connects. +//#region registerCapabilities_extensions +mcp.server.registerCapabilities({ + extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } } +}); +//#endregion registerCapabilities_extensions + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output advanced/custom-methods.md quotes verbatim. Any MCP client behaves +// the same. Imported dynamically so the page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'custom-methods-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await mcp.connect(serverTransport); +await client.connect(clientTransport); + +// "Call it from the client" — the result the page quotes. +//#region request_custom +const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +console.log(result); +//#endregion request_custom + +// Proof for the page's ::: tip — params that fail `SearchParams` are rejected +// before the handler runs. Throws (non-zero exit) if the claim is false. +let rejection: Error | undefined; +try { + await client.request({ method: 'acme/search', params: { query: 42 } }, SearchResult); +} catch (error) { + rejection = error as Error; +} +if (!rejection) { + throw new Error('custom-methods.md tip claim failed: invalid params were accepted'); +} +console.log(rejection.message); + +// "Read the negotiated extensions on the client" — the map the page quotes. +//#region getServerCapabilities_extensions +const extensions = client.getServerCapabilities()?.extensions ?? {}; +console.log(extensions); +//#endregion getServerCapabilities_extensions + +// "Send a custom notification from the handler" — registering the method again +// replaces its handler with one that reports progress. +//#region setRequestHandler_notify +mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; +}); +//#endregion setRequestHandler_notify + +// "Receive it on the client" — the progress params the page quotes. +//#region setNotificationHandler_custom +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + console.log(params); +}); + +await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult); +//#endregion setNotificationHandler_custom + +// Let the notification microtasks drain before tearing the pair down. +await new Promise(resolve => setImmediate(resolve)); + +await client.close(); +await mcp.close(); diff --git a/examples/guides/advanced/custom-transports.examples.ts b/examples/guides/advanced/custom-transports.examples.ts new file mode 100644 index 0000000000..5232886e30 --- /dev/null +++ b/examples/guides/advanced/custom-transports.examples.ts @@ -0,0 +1,210 @@ +/** + * Companion example for `docs/advanced/custom-transports.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects a client and a server over the loopback transport and + * produces the output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/custom-transports.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region transport_loopback +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server'; + +export class LoopbackTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + private peer?: LoopbackTransport; + + /** Cross-wire two ends: whatever one end sends, the other receives. */ + static link(a: LoopbackTransport, b: LoopbackTransport): void { + a.peer = b; + b.peer = a; + } + + async start(): Promise<void> { + // Open your channel here. The loopback has nothing to open. + } + + async send(message: JSONRPCMessage): Promise<void> { + const peer = this.peer; + if (!peer) throw new Error('Loopback peer is gone'); + queueMicrotask(() => peer.onmessage?.(message)); + } + + async close(): Promise<void> { + this.peer = undefined; + this.onclose?.(); + } +} +//#endregion transport_loopback + +// "Connect it like a built-in transport" — produces the output the page quotes. +//#region connect_loopback +import { Client } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' }); +server.registerTool('ping', { description: 'Reply with pong' }, async () => ({ + content: [{ type: 'text', text: 'pong' }] +})); + +const client = new Client({ name: 'loopback-client', version: '1.0.0' }); + +const serverEnd = new LoopbackTransport(); +const clientEnd = new LoopbackTransport(); +LoopbackTransport.link(serverEnd, clientEnd); + +await server.connect(serverEnd); +await client.connect(clientEnd); + +const result = await client.callTool({ name: 'ping' }); +console.log(result.content); +//#endregion connect_loopback + +// "Frame messages over a byte stream" — typechecked, not run (it needs a real +// socket). The runnable proof on this page is the loopback transport above. +//#region transport_socket +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server'; +import type { Socket } from 'node:net'; + +export class SocketTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + private readonly readBuffer = new ReadBuffer(); + + constructor(private readonly socket: Socket) {} + + async start(): Promise<void> { + this.socket.on('data', chunk => { + try { + this.readBuffer.append(chunk); + let message = this.readBuffer.readMessage(); + while (message !== null) { + this.onmessage?.(message); + message = this.readBuffer.readMessage(); + } + } catch (error) { + this.onerror?.(error as Error); + } + }); + this.socket.on('error', error => this.onerror?.(error)); + this.socket.on('close', () => this.onclose?.()); + } + + async send(message: JSONRPCMessage): Promise<void> { + this.socket.write(serializeMessage(message)); + } + + async close(): Promise<void> { + this.socket.end(); + } +} +//#endregion transport_socket + +// "Report a session ID and the negotiated version". +//#region transport_session +export class SessionLoopbackTransport extends LoopbackTransport { + sessionId?: string; + + protocolVersion?: string; + + setProtocolVersion(version: string): void { + this.protocolVersion = version; + } +} +//#endregion transport_session + +// "Opt into per-request cancellation" — typechecked, not run. +import type { TransportSendOptions } from '@modelcontextprotocol/server'; +import { parseJSONRPCMessage } from '@modelcontextprotocol/server'; + +export class HttpPostTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(private readonly endpoint: URL) {} + + //#region transport_perRequest + readonly hasPerRequestStream = true; + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', ...options?.headers }, + body: JSON.stringify(message), + signal: options?.requestSignal + }); + this.onmessage?.(parseJSONRPCMessage(await response.json())); + } + //#endregion transport_perRequest + + async start(): Promise<void> {} + + async close(): Promise<void> { + this.onclose?.(); + } +} + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). Drives the remaining claims the page makes +// and throws (non-zero exit) if any of them is false. +// --------------------------------------------------------------------------- + +await client.close(); +await server.close(); + +// "Report a session ID and the negotiated version" — the version the page quotes. +const sessionServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' }); +const sessionClient = new Client({ name: 'loopback-client', version: '1.0.0' }); + +const sessionServerEnd = new SessionLoopbackTransport(); +const sessionClientEnd = new SessionLoopbackTransport(); +LoopbackTransport.link(sessionServerEnd, sessionClientEnd); + +await sessionServer.connect(sessionServerEnd); +await sessionClient.connect(sessionClientEnd); + +console.log(sessionClientEnd.protocolVersion); +if (sessionServerEnd.protocolVersion !== sessionClientEnd.protocolVersion) { + throw new Error( + `custom-transports.md claim failed: server end negotiated ${sessionServerEnd.protocolVersion}, client end ${sessionClientEnd.protocolVersion}` + ); +} + +await sessionClient.close(); +await sessionServer.close(); + +// "Test it against the in-memory pair" — the reference transport returns the +// same result the loopback produced. +const referenceServer = new McpServer({ name: 'loopback-demo', version: '1.0.0' }); +referenceServer.registerTool('ping', { description: 'Reply with pong' }, async () => ({ + content: [{ type: 'text', text: 'pong' }] +})); +const referenceClient = new Client({ name: 'loopback-client', version: '1.0.0' }); + +//#region inMemory_pair +import { InMemoryTransport } from '@modelcontextprotocol/client'; + +const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair(); +//#endregion inMemory_pair + +await referenceServer.connect(inMemoryServerEnd); +await referenceClient.connect(inMemoryClientEnd); + +const referenceResult = await referenceClient.callTool({ name: 'ping' }); +if (JSON.stringify(referenceResult.content) !== JSON.stringify(result.content)) { + throw new Error(`custom-transports.md claim failed: in-memory result ${JSON.stringify(referenceResult.content)}`); +} + +await referenceClient.close(); +await referenceServer.close(); diff --git a/examples/guides/advanced/gateway.examples.ts b/examples/guides/advanced/gateway.examples.ts new file mode 100644 index 0000000000..2375c77e88 --- /dev/null +++ b/examples/guides/advanced/gateway.examples.ts @@ -0,0 +1,135 @@ +/** + * Runnable, type-checked companion for `docs/advanced/gateway.md`. + * + * Each `//#region` block is synced byte-for-byte into that page's code fences + * (`pnpm sync:snippets --check` reports drift). + * + * The page's program runs for real: the harness below builds a + * `createMcpHandler` server and routes `globalThis.fetch` for + * `http://localhost:3000/mcp` into `handler.fetch`, so the HTTP regions + * execute in-process without binding a port. The output the page quotes + * verbatim is whatever this file prints. The server's `request_count` tool + * returns how many MCP requests reached the process (`createMcpHandler` + * builds one server instance per request), which is what proves that + * `connect({ prior })` sent nothing. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/gateway.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). Every `new McpServer(...)` is one inbound +// MCP request, so the module-level counter is the number of requests the +// process has answered — `request_count` exposes it to the page's clients. +// --------------------------------------------------------------------------- + +let requests = 0; + +const handler = createMcpHandler(() => { + requests++; + const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + server.registerTool( + 'request_count', + { description: 'Number of MCP requests this server process has answered', outputSchema: z.object({ requests: z.number() }) }, + async () => ({ content: [{ type: 'text', text: String(requests) }], structuredContent: { requests } }) + ); + return server; +}); + +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + if (new URL(request.url).host === 'localhost:3000') return handler.fetch(request); + return realFetch(input, init); +}) as typeof fetch; + +// ## Connect with a prior discover result + +//#region connect_prior +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const url = new URL('http://localhost:3000/mcp'); + +// Probe once … +const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(url)); +const persisted = JSON.stringify(bootstrap.getDiscoverResult()); + +// … then every other client connects with zero round trips. +const worker = new Client({ name: 'worker', version: '1.0.0' }); +await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); +//#endregion connect_prior + +// ## Probe once at bootstrap + +//#region bootstrap_probe +console.log(bootstrap.getDiscoverResult()); +//#endregion bootstrap_probe + +// ## Persist the advertisement + +//#region persist_advertisement +import type { DiscoverResult } from '@modelcontextprotocol/client'; + +const prior = JSON.parse(persisted) as DiscoverResult; +//#endregion persist_advertisement + +// ## Fan out to workers + +//#region fan_out +const fleet = await Promise.all( + ['worker-a', 'worker-b', 'worker-c'].map(async name => { + const replica = new Client({ name, version: '1.0.0' }); + await replica.connect(new StreamableHTTPClientTransport(url), { prior }); + return replica; + }) +); + +const proof = await worker.callTool({ name: 'request_count' }); +console.log(proof.structuredContent); +//#endregion fan_out + +// ## Open a listen stream when a worker needs notifications + +//#region listen_worker +const subscription = await worker.listen({ toolsListChanged: true }); +console.log(subscription.honoredFilter); +//#endregion listen_worker + +await subscription.close(); + +// ## Handle a stale or incompatible advertisement + +//#region prior_stale +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; + +const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] }; + +const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +try { + await late.connect(new StreamableHTTPClientTransport(url), { prior: stale }); +} catch (error) { + if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error; + console.log(error.code); + + // Fall back to a fresh probe, then re-persist getDiscoverResult(). + await late.connect(new StreamableHTTPClientTransport(url)); + console.log('re-probed:', late.getNegotiatedProtocolVersion()); +} +//#endregion prior_stale + +// --------------------------------------------------------------------------- +// Harness teardown. +// --------------------------------------------------------------------------- + +for (const client of [bootstrap, worker, late, ...fleet]) await client.close(); +await handler.close(); +globalThis.fetch = realFetch; diff --git a/examples/guides/advanced/low-level-server.examples.ts b/examples/guides/advanced/low-level-server.examples.ts new file mode 100644 index 0000000000..ba9b8e965c --- /dev/null +++ b/examples/guides/advanced/low-level-server.examples.ts @@ -0,0 +1,179 @@ +/** + * Companion example for `docs/advanced/low-level-server.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness between the + * regions connects in-memory clients and produces every output the page quotes + * verbatim, exiting non-zero on drift. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/low-level-server.examples.ts # from examples/ + * + * `lowLevel_serve` lives in a never-invoked wrapper: `serveStdio` would bind + * this process's real stdin/stdout, so that one region is typecheck-only. + * + * @module + */ +/* eslint-disable no-console, import/no-duplicates */ +// Harness imports. The page's lead block (the first region) carries its own +// `Server` import so the rendered fence stands alone. +import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// --------------------------------------------------------------------------- +// "Build the server and list your tools by hand" +// --------------------------------------------------------------------------- + +//#region lowLevel_listTools +import { Server } from '@modelcontextprotocol/server'; + +const catalog = [ + { name: 'Espresso cup', price: 12 }, + { name: 'Travel mug', price: 24 }, + { name: 'Mug rack', price: 36 } +]; + +const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } }); + +server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'search', + description: 'Search the product catalog', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Substring to match against product names' } }, + required: ['query'] + } + } + ] +})); +//#endregion lowLevel_listTools + +// --------------------------------------------------------------------------- +// "Handle tools/call yourself" +// --------------------------------------------------------------------------- + +//#region lowLevel_callTool +server.setRequestHandler('tools/call', async request => { + if (request.params.name !== 'search') { + return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; + } + const { query } = request.params.arguments as { query: string }; + const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); + return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; +}); +//#endregion lowLevel_callTool + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output low-level-server.md quotes verbatim. Imported dynamically so the +// page's lead region stays self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// The handler answers a valid call exactly like the McpServer version. +const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } }); +console.log(result.content); + +// Nothing validated `query`, so a wrongly-typed argument reaches the handler +// and crashes it: the client sees a JSON-RPC error, not a tool result. +const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error); +if (!(crashed instanceof ProtocolError)) { + throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`); +} +console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`); + +// --------------------------------------------------------------------------- +// "Validate arguments yourself" +// --------------------------------------------------------------------------- + +//#region lowLevel_validate +const SearchArguments = fromJsonSchema<{ query: string }>({ + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'] +}); + +server.setRequestHandler('tools/call', async request => { + if (request.params.name !== 'search') { + return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; + } + const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {}); + if (parsed.issues) { + return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true }; + } + const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase())); + return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; +}); +//#endregion lowLevel_validate + +// The same wrongly-typed call now comes back as an ordinary isError result. +const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } }); +console.log(rejected); +if (rejected.isError !== true) { + throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`); +} + +await client.close(); +await server.close(); + +// --------------------------------------------------------------------------- +// "Serve it with the same entry points" — typecheck-only. `serveStdio` would +// take over this process's stdin/stdout, so the harness never calls this. +// --------------------------------------------------------------------------- + +function lowLevel_serve() { + //#region lowLevel_serve + serveStdio(() => server); + createMcpHandler(() => server); + //#endregion lowLevel_serve +} +void lowLevel_serve; + +// --------------------------------------------------------------------------- +// "Reach the low level from McpServer" +// --------------------------------------------------------------------------- + +//#region lowLevel_escapeHatch +const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + +mcp.registerTool( + 'search', + { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, + async ({ query }) => { + const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); + return { content: [{ type: 'text', text: names.join('\n') }] }; + } +); + +const subscriptions = new Set<string>(); +mcp.server.setRequestHandler('resources/subscribe', async request => { + subscriptions.add(request.params.uri); + return {}; +}); +//#endregion lowLevel_escapeHatch + +// Harness: prove the page's claim for the section above — `registerTool` still +// owns `tools/list`, and the hand-registered handler answers +// `resources/subscribe` on the same connection. +const mcpClient = new Client({ name: 'low-level-docs-harness', version: '1.0.0' }); +const [mcpClientTransport, mcpServerTransport] = InMemoryTransport.createLinkedPair(); +await mcp.connect(mcpServerTransport); +await mcpClient.connect(mcpClientTransport); + +const { tools } = await mcpClient.listTools(); +await mcpClient.subscribeResource({ uri: 'demo://config' }); +if (tools.length !== 1 || tools[0]?.name !== 'search' || !subscriptions.has('demo://config')) { + throw new Error(`low-level-server.md escape-hatch claim failed: ${JSON.stringify({ tools, subscriptions: [...subscriptions] })}`); +} + +await mcpClient.close(); +await mcp.close(); diff --git a/examples/guides/advanced/schema-libraries.examples.ts b/examples/guides/advanced/schema-libraries.examples.ts new file mode 100644 index 0000000000..df1226f5f7 --- /dev/null +++ b/examples/guides/advanced/schema-libraries.examples.ts @@ -0,0 +1,129 @@ +/** + * Companion example for `docs/advanced/schema-libraries.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and produces the output the page quotes + * verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/schema-libraries.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +//#region registerTool_arktype +import { McpServer } from '@modelcontextprotocol/server'; +import { type } from 'arktype'; + +const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' }); + +server.registerTool( + 'greet', + { + description: 'Greet someone by name', + inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' }) + }, + async ({ name, times }) => ({ + content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }] + }) +); +//#endregion registerTool_arktype + +//#region registerTool_valibot +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +server.registerTool( + 'shout', + { description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, + async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] }) +); +//#endregion registerTool_valibot + +//#region registerTool_fromJsonSchema +import { fromJsonSchema } from '@modelcontextprotocol/server'; + +server.registerTool( + 'farewell', + { + description: 'Say goodbye', + inputSchema: fromJsonSchema<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }) + }, + async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] }) +); +//#endregion registerTool_fromJsonSchema + +//#region registerTool_outputSchema +server.registerTool( + 'measure', + { + description: 'Measure the length of a name', + inputSchema: type({ name: 'string' }), + outputSchema: type({ name: 'string', length: 'number' }) + }, + async ({ name }) => { + const output = { name, length: name.length }; + return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; + } +); +//#endregion registerTool_outputSchema + +//#region jsonSchemaValidator_ajv +import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; + +const ajv = new Ajv({ strict: true, allErrors: true }); +addFormats(ajv); +const validator = new AjvJsonSchemaValidator(ajv); + +const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator }); +//#endregion jsonSchemaValidator_ajv + +//#region jsonSchemaValidator_cfWorker +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; + +const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }); +//#endregion jsonSchemaValidator_cfWorker + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). An in-memory client drives the calls whose +// output advanced/schema-libraries.md quotes verbatim. Any MCP client behaves +// the same. Imported dynamically so the page's lead region stays +// self-contained. +// --------------------------------------------------------------------------- + +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); + +const client = new Client({ name: 'schema-libraries-docs-harness', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await server.connect(serverTransport); +await client.connect(clientTransport); + +// "Register a tool with an ArkType schema" — the rejection the page quotes. +const rejected = await client.callTool({ name: 'greet', arguments: { name: 'Ada', times: 99 } }); +console.log(rejected); + +// "Register a tool with a Valibot schema" — proves the page's prose claim that +// the call validates and dispatches like any other tool. +const shouted = await client.callTool({ name: 'shout', arguments: { name: 'Ada' } }); +if (JSON.stringify(shouted.content) !== JSON.stringify([{ type: 'text', text: 'HELLO, ADA' }])) { + throw new Error(`schema-libraries.md valibot claim failed: ${JSON.stringify(shouted)}`); +} + +// "Start from JSON Schema you already have" — the advertised schema the page quotes. +const { tools } = await client.listTools(); +const farewell = tools.find(tool => tool.name === 'farewell'); +console.log(farewell?.inputSchema); + +// "Validate structured output with any library" — the structured result the page quotes. +const measured = await client.callTool({ name: 'measure', arguments: { name: 'Ada' } }); +console.log(measured); + +await client.close(); +await server.close(); +await strict.close(); +await edge.close(); diff --git a/examples/guides/advanced/wire-schemas.examples.ts b/examples/guides/advanced/wire-schemas.examples.ts new file mode 100644 index 0000000000..0676329234 --- /dev/null +++ b/examples/guides/advanced/wire-schemas.examples.ts @@ -0,0 +1,104 @@ +/** + * Companion example for `docs-v2/advanced/wire-schemas.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: it is the gateway path + * the page walks through — raw JSON in, validated with the schemas from + * `@modelcontextprotocol/core`, no `Client` or `Server` anywhere — and it + * prints the exact output the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/wire-schemas.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable @typescript-eslint/no-unused-vars, no-console */ + +// "Validate a wire payload" — the happy path the page quotes. +//#region wireSchemas_validateResult +import { CallToolResultSchema } from '@modelcontextprotocol/core'; + +// The body an upstream server returned for a tools/call you forwarded. +const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}'); + +const parsed = CallToolResultSchema.safeParse(body); +if (!parsed.success) { + throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`); +} +console.log(parsed.data.content); +//#endregion wireSchemas_validateResult + +// "Validate a wire payload" — the rejection the page quotes. +//#region wireSchemas_validateResult_invalid +const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' }); +console.log(malformed.error?.issues); +//#endregion wireSchemas_validateResult_invalid + +// "Pick the schema for the message you hold" — the undecoded envelope. +//#region wireSchemas_envelope +import { JSONRPCMessageSchema } from '@modelcontextprotocol/core'; + +const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}'; +const message = JSONRPCMessageSchema.parse(JSON.parse(frame)); +//#endregion wireSchemas_envelope + +// "Route raw JSON-RPC in a proxy" — branch on method, then the per-method schema. +//#region wireSchemas_route +import { CallToolRequestSchema } from '@modelcontextprotocol/core'; + +if ('method' in message) { + switch (message.method) { + case 'tools/call': { + const call = CallToolRequestSchema.parse(message); + console.log(`forward tools/call for ${call.params.name} upstream`); + break; + } + default: + console.log(`forward ${message.method} unchanged`); + } +} +//#endregion wireSchemas_route + +// "Validate OAuth and discovery metadata" — the second export group. +//#region wireSchemas_oauthMetadata +import { OAuthMetadataSchema } from '@modelcontextprotocol/core'; + +// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server. +const response = new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }) +); + +const metadata = OAuthMetadataSchema.parse(await response.json()); +console.log(metadata.token_endpoint); +//#endregion wireSchemas_oauthMetadata + +// "Get the TypeScript types, guards and errors from the SDK packages" — core is +// Zod values only; the names live in /client and /server. +//#region wireSchemas_types +import type { CallToolResult } from '@modelcontextprotocol/client'; +import * as z from 'zod/v4'; + +// The SDK's spec type and the schema's own inferred output describe the same value. +const relayed: CallToolResult = parsed.data; +type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>; +//#endregion wireSchemas_types + +// --------------------------------------------------------------------------- +// Self-checks (not shown on the page). Throw — non-zero exit — if any claim +// the page makes stops being true. +// --------------------------------------------------------------------------- + +if (malformed.success) { + throw new Error('wire-schemas.md claim failed: a non-array `content` must not parse'); +} +if (!('method' in message) || message.method !== 'tools/call') { + throw new Error('wire-schemas.md claim failed: the envelope did not narrow to a tools/call request'); +} +if (relayed.content[0]?.type !== 'text') { + throw new Error('wire-schemas.md claim failed: parsed result is not assignable to CallToolResult'); +} From 5e96b546ed0019f3e5602986724c8ed966055e88 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:18 +0000 Subject: [PATCH 10/27] docs: write the top-level guide pages --- docs/protocol-versions.md | 192 ++++++++++++++---- docs/testing.md | 143 ++++++++++--- docs/troubleshooting.md | 175 ++++++++++++---- examples/guides/protocolVersions.examples.ts | 138 +++++++++++++ examples/guides/testing.examples.ts | 130 ++++++++++++ examples/guides/troubleshooting.examples.ts | 85 ++++++++ .../guides/troubleshooting.stdio.examples.ts | 23 +++ 7 files changed, 771 insertions(+), 115 deletions(-) create mode 100644 examples/guides/protocolVersions.examples.ts create mode 100644 examples/guides/testing.examples.ts create mode 100644 examples/guides/troubleshooting.examples.ts create mode 100644 examples/guides/troubleshooting.stdio.examples.ts diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 6e07e9bccc..b3adcaef01 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -1,70 +1,182 @@ --- -status: scaffold shape: explanation --- -# Protocol versions -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide. -teaches: ClientOptions.versionNegotiation, Client.getProtocolEra, ProtocolOptions.supportedProtocolVersions, createMcpHandler legacy option, serveStdio legacy option, SdkError(EraNegotiationFailed) -source: mined from docs/migration/support-2026-07-28.md "Serving the 2026-07-28 revision", "Client side: versionNegotiation", "Probe policy", "Appendix: 2025-era vs 2026-era behavior matrix" -NOTE: this is the ONE era page. Every other page's era caveat is a single line linking here -(CONVENTIONS R8 / proposal principle 3). The behavior matrix is MOVED here, not copied — -the support guide links to this page and stops owning it (one maintained copy, ever). ---> +# Protocol versions ## Name the two eras -<!-- teaches: ProtocolEra ('legacy' | 'modern') | salvage: docs/migration/support-2026-07-28.md intro + agent-report 89 §1.2 --> -<!-- code: none — two short paragraphs: an "era" is a behavior family, not a version string; 2025-era = 2024-10-07 … 2025-11-25, 2026-era = 2026-07-28; why the SDK serves both --> + +An **era** is a behavior family, not a version string. Every protocol revision from `2024-10-07` through `2025-11-25` opens with the `initialize` handshake and shares one wire behavior — the SDK calls that family `legacy`. The `2026-07-28` revision starts the `modern` era: no `initialize`, a `server/discover` advertisement instead, and a `_meta` envelope on every request. + +The SDK speaks both eras from the same `Client` and serves both from the same entry points. A connection's era is decided once, at connect time, and every difference it implies is in [the matrix below](#compare-the-eras). ## Negotiate the era from the client -<!-- teaches: ClientOptions.versionNegotiation, Client.getProtocolEra | salvage: docs/migration/support-2026-07-28.md "Client side: versionNegotiation" --> -`versionNegotiation` decides which handshake `connect()` performs; the default is the 2025 `initialize` handshake, byte for byte. -```ts -// draft - API verified against packages/client/src/client/client.ts (ClientOptions.versionNegotiation L206, getProtocolEra L1272) and packages/client/src/client/versionNegotiation.ts (VersionNegotiationOptions.mode) +`versionNegotiation` picks which handshake `connect()` performs. `mode: 'auto'` probes the server with `server/discover` and connects on whichever era it finds. + +```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_auto" import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { versionNegotiation: { mode: 'auto' } }, -); +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); -client.getProtocolEra(); // 'modern' or 'legacy' once connected; undefined before +console.log(client.getProtocolEra()); +``` + +`http://localhost:3000/mcp` is a `createMcpHandler` server — [built below](#serve-both-eras-from-one-entry-point) — so the probe finds the 2026-07-28 era: + +``` +modern +``` + +Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake on the same connection — one extra round trip, no error. + +```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_fallback" +const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await fallback.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); + +console.log(fallback.getProtocolEra()); +``` + +`getProtocolEra()` reports the era the connection landed on; it returns `undefined` before `connect()` resolves and never changes after it. + +``` +legacy ``` -<!-- result: against a 2026-07-28 server getProtocolEra() returns 'modern'; against a 2025-only server the same connect() falls back and returns 'legacy' --> ## Pin an era -<!-- teaches: mode: 'legacy', mode: { pin: '2026-07-28' }, SdkError(EraNegotiationFailed) --> -<!-- code: the three mode values as a placeholder block: absent/'legacy' (no probe), 'auto' (probe + fallback), { pin } (modern only, connect() rejects with SdkError(EraNegotiationFailed) against a 2025-only server) --> + +`mode` takes three values; the first is the default. + +- Absent, or `mode: 'legacy'` — the 2025 `initialize` handshake, byte for byte. No probe. +- `mode: 'auto'` — probe with `server/discover`; fall back to `initialize` against a 2025-only server. +- `mode: { pin: '2026-07-28' }` — that revision or nothing. A pin never falls back. + +Pin against the same 2025-only server and `connect()` rejects instead of falling back. + +```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_pin" +import { SdkError } from '@modelcontextprotocol/client'; + +const pinned = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + +try { + await pinned.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); +} catch (error) { + if (error instanceof SdkError) console.log(`${error.code}: ${error.message}`); +} +``` + +The rejection is a typed, local `SdkError` — nothing reaches the server beyond the probe: + +``` +ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) +``` ## Understand the probe -<!-- teaches: versionNegotiation.probe (timeoutMs, maxRetries), supportedProtocolVersions | salvage: docs/migration/support-2026-07-28.md "Probe policy" --> -<!-- code: probe: { timeoutMs, maxRetries } placeholder; prose covers transport-aware timeouts (stdio falls back, HTTP rejects), the browser CORS exception, and who should NOT default to 'auto' (spawn-per-invocation CLI tools) --> + +`probe` bounds the `server/discover` round trip that `'auto'` and a pin run before anything else. + +```ts source="../examples/guides/protocolVersions.examples.ts#versionNegotiation_probe" +const cli = new Client( + { name: 'my-client', version: '1.0.0' }, + { + versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000, // default: the connection's request timeout + maxRetries: 0 // default: no probe re-sends after a timeout + } + } + } +); +``` + +A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize` on the same stream; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. + +The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`. + +::: warning +Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the extra round trip changes recorded transcripts. Keep the default and expose `'auto'` (or a pin) as a flag. +::: ## Serve both eras from one entry point -<!-- teaches: createMcpHandler legacy: 'stateless' | 'reject', serveStdio legacy option | salvage: docs/migration/support-2026-07-28.md "Server over HTTP: createMcpHandler", "Server over stdio / long-lived connections: serveStdio" --> -<!-- code: createMcpHandler(factory, { legacy: 'stateless' }) placeholder; one line linking /serving/legacy-clients, which owns the legacy: option and the full recipe --> + +`createMcpHandler` is the HTTP entry that answered both clients above: it builds a fresh server per request and passes the factory the `era` that request belongs to. + +```ts source="../examples/guides/protocolVersions.examples.ts#createMcpHandler_bothEras" +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(({ era }) => { + const server = new McpServer({ name: 'forecast', version: '1.0.0' }); + server.registerTool( + 'forecast', + { + description: 'Forecast for a city', + inputSchema: z.object({ city: z.string() }) + }, + async ({ city }) => ({ content: [{ type: 'text', text: `${city}: sunny (${era} era)` }] }) + ); + return server; +}); +``` + +By default the handler also serves 2025-era traffic per request (`legacy: 'stateless'`); pass `legacy: 'reject'` to refuse it. Connect one more client with the default mode to the same URL — no probe, the 2025 handshake — and call the tool from both. + +```ts source="../examples/guides/protocolVersions.examples.ts#createMcpHandler_callBothEras" +const defaultClient = new Client({ name: 'my-client', version: '1.0.0' }); + +await defaultClient.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); + +for (const caller of [client, defaultClient]) { + const result = await caller.callTool({ name: 'forecast', arguments: { city: 'Berlin' } }); + console.log(caller.getProtocolEra(), JSON.stringify(result.content)); +} +``` + +One endpoint, one factory, two eras — and the era reached the handler: + +``` +modern [{"type":"text","text":"Berlin: sunny (modern era)"}] +legacy [{"type":"text","text":"Berlin: sunny (legacy era)"}] +``` + +On stdio, `serveStdio(factory)` from `@modelcontextprotocol/server/stdio` is the same shape per connection: the opening exchange pins the connection's era, and `legacy: 'reject'` refuses 2025 openings. [Serve legacy clients](./serving/legacy-clients.md) owns the `legacy` option and the hosting recipes for both entries. ## Compare the eras -<!-- teaches: the behavior matrix | salvage: docs/migration/support-2026-07-28.md "Appendix: 2025-era vs 2026-era behavior matrix" — MOVED here verbatim (the table carve-out is allowed on this reference-flavored page) --> -<!-- code: none — the nine-axis 2025-era vs 2026-07-28 table lands here as the page's centerpiece --> + +This table is the only copy of the era differences in these docs. `getProtocolEra()` on the client and the factory's `era` on the server tell you which column you are in. + +| Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | +| ------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | +| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | +| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | +| Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | +| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | +| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | +| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | +| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | +| HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | +| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | ## Separate deprecation from era -<!-- teaches: SEP-2577 (sampling, roots, ctx.mcpReq.log) is deprecation, not an era caveat | salvage: agent-report 89 §1.2 + proposal principle 4 --> -<!-- code: none — one short paragraph: deprecated surfaces carry their own on-page sunset banner; this page is not where deprecation lives --> + +Deprecation is not an era difference. `sampling`, `roots`, and the `logging` capability behind `ctx.mcpReq.log()` are deprecated as of `2026-07-28` (SEP-2577) but stay in the specification for at least twelve months; which API carries each one on a given connection is an era difference, and already has its row in the matrix above. Each deprecated surface opens its own page with a sunset banner naming the migration target; nothing in the matrix moves when a deprecation lands. ## Link here instead of explaining inline -<!-- teaches: the quarantine rule for every other page | salvage: proposal principle 3 ("Tell the era story exactly once") --> -<!-- code: none — the one-line cross-link form other pages use, shown as the example sentence authors copy --> + +Era differences live on this page and nowhere else. Every other page in these docs spends at most one sentence on an era and links here; do the same in your own server's documentation. + +> The wire encoding of structured results differs by protocol era — see [Protocol versions](./protocol-versions.md). ## Recap -<!-- the claims this page will prove: -- An era is a behavior family; the SDK serves 2025-era and 2026-07-28 from the same entry points. -- versionNegotiation picks the client handshake; the default is the unchanged 2025 initialize. -- 'auto' probes with server/discover and falls back; a pin never falls back. -- getProtocolEra() tells you what was negotiated. + +- An era is a behavior family: `legacy` covers `2024-10-07` through `2025-11-25`, `modern` starts at `2026-07-28`. +- `versionNegotiation` picks the client handshake; the default is the unchanged 2025 `initialize`, no probe. +- `mode: 'auto'` probes with `server/discover` and falls back to `initialize`; a pin never falls back and rejects with `SdkError(EraNegotiationFailed)`. +- `getProtocolEra()` reports the negotiated era on the client; the `createMcpHandler` / `serveStdio` factory receives the `era` it is about to serve. - The behavior matrix on this page is the only copy; every other page links here in one line. - Deprecation (SEP-2577) is not an era difference. ---> diff --git a/docs/testing.md b/docs/testing.md index 69c191446b..8a302d5ee0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,57 +1,134 @@ --- -status: scaffold shape: how-to --- # Test a server -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: In-memory linked pair + handler.fetch — no sockets. -teaches: createMcpHandler, McpHttpHandler.fetch, StreamableHTTPClientTransportOptions.fetch, Client, InMemoryTransport.createLinkedPair, serveStdio -source: mined from docs/migration/support-2026-07-28.md "In-process testing"; relocated here per agent-report 89 §5 hole 4 ("No testing guide") ---> +Drive your server through a real `Client`, in-process — no port, no socket, no mock transport. ## Serve the handler in-process -<!-- teaches: createMcpHandler + StreamableHTTPClientTransport fetch option | salvage: docs/migration/support-2026-07-28.md "In-process testing" --> -Pass `handler.fetch` as the client transport's `fetch` — the URL is never dialed; every request is served in-process, no port, no socket. -```ts -// draft - API verified against packages/server/src/server/createMcpHandler.ts (createMcpHandler L575, McpServerFactory L115, McpHttpHandler.fetch L214) and packages/client/src/client/streamableHttp.ts (StreamableHTTPClientTransportOptions.fetch L184) -import { McpServer, createMcpHandler } from '@modelcontextprotocol/server'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +Start from the `createServer` factory you ship — here, one tool — and pass `handler.fetch` as the client transport's `fetch` option. + +```ts source="../examples/guides/testing.examples.ts#inProcessHandler" +import assert from 'node:assert/strict'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +function createServer() { + const server = new McpServer({ name: 'pricing', version: '1.0.0' }); + server.registerTool( + 'apply-discount', + { + description: 'Apply a percentage discount to a price', + inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }), + outputSchema: z.object({ total: z.number() }) + }, + async ({ price, percent }) => { + if (price < 0) { + return { content: [{ type: 'text', text: 'price must be >= 0' }], isError: true }; + } + const total = price * (1 - percent / 100); + return { content: [{ type: 'text', text: `$${total}` }], structuredContent: { total } }; + } + ); + return server; +} + +const handler = createMcpHandler(createServer); -const handler = createMcpHandler(() => new McpServer({ name: 'app', version: '1.0.0' })); const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { - fetch: (url, init) => handler.fetch(new Request(url, init)), + fetch: (url, init) => handler.fetch(new Request(url, init)) }); ``` -<!-- result: connecting a Client over this transport exercises the real 2026-07-28 HTTP path with zero network --> + +The transport never dials `http://test.local/mcp` — `handler.fetch` serves every request in-process, through the same `createMcpHandler` you deploy. ## Connect a client and call a tool -<!-- teaches: Client.connect, Client.callTool --> -<!-- code: new Client({...}) + await client.connect(transport) + await client.callTool({ name, arguments }) --> + +Create a `Client`, connect it over the transport, and call the tool. `versionNegotiation: { mode: 'auto' }` negotiates the newest protocol revision the handler serves. + +```ts source="../examples/guides/testing.examples.ts#connectAndCall" +const client = new Client({ name: 'test-harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); + +const result = await client.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } }); +console.log(result.structuredContent); +``` + +The handler answered in-process: + +``` +{ total: 60 } +``` ## Assert on the result -<!-- teaches: CallToolResult.content, structuredContent, isError | salvage: docs/server.md "Tools" result shape --> -<!-- code: expect(result.structuredContent).toEqual(...) and the isError-true branch --> + +Assert on `structuredContent` for the happy path; a handler failure resolves as an ordinary result with `isError: true`, not a thrown error. + +```ts source="../examples/guides/testing.examples.ts#assertResult" +assert.deepStrictEqual(result.structuredContent, { total: 60 }); + +const failed = await client.callTool({ name: 'apply-discount', arguments: { price: -5, percent: 25 } }); +assert.equal(failed.isError, true); +console.log(failed.content); +``` + +There is nothing to `catch` — `failed.content` carries the message the model would read: + +``` +[ { type: 'text', text: 'price must be >= 0' } ] +``` + +::: tip +This page uses `node:assert/strict`; swap in your runner's `expect` — nothing else changes. Arguments the input schema rejects produce the same `isError: true` result, so they assert the same way — see [Tools](./servers/tools.md). +::: ## Tear down between tests -<!-- teaches: handler.close, Client.close | salvage: docs/migration/support-2026-07-28.md McpHttpHandler.close --> -<!-- code: afterEach: await client.close(); await handler.close() --> + +Close both ends in your runner's `afterEach` — the client first, then the handler. + +```ts source="../examples/guides/testing.examples.ts#tearDown" +await client.close(); +await handler.close(); +``` + +`handler.close()` aborts any exchange still in flight, so a hung tool call cannot leak into the next test. ## Pair two instances in memory -<!-- teaches: InMemoryTransport.createLinkedPair | salvage: docs/migration/support-2026-07-28.md "In-process testing" ("connects 2025-era instances only") --> -<!-- code: const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); connect a Server and a Client to each end --> -<!-- era caveat: ONE line linking /protocol-versions — the linked pair is 2025-era only; use handler.fetch for 2026-07-28 coverage --> + +`InMemoryTransport.createLinkedPair()` returns two transports that are each other's wire — connect one instance to each end. + +```ts source="../examples/guides/testing.examples.ts#linkedPair" +import { InMemoryTransport } from '@modelcontextprotocol/client'; + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + +const memServer = createServer(); +const memClient = new Client({ name: 'test-harness', version: '1.0.0' }); +await memServer.connect(serverTransport); +await memClient.connect(clientTransport); +``` + +`memClient.callTool` returns the same results over this pair. `createLinkedPair` connects 2025-era instances only; `handler.fetch` is the in-process entry for 2026-07-28 coverage — see [Protocol versions](./protocol-versions.md). ## Cover stdio by spawning the process -<!-- teaches: serveStdio under a child process, StdioClientTransport | salvage: docs/migration/support-2026-07-28.md "In-process testing" final line --> -<!-- code: StdioClientTransport({ command: 'node', args: ['dist/server.js'] }) --> + +Stdio has no in-process shortcut: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and connects to the child over its stdin and stdout. + +```ts source="../examples/guides/testing.examples.ts#stdioSpawn" +const stdioClient = new Client({ name: 'test-harness', version: '1.0.0' }); +await stdioClient.connect(new StdioClientTransport({ command: 'node', args: ['dist/server.js'] })); +``` + +From here the client behaves exactly as above, and `stdioClient.close()` shuts the child process down. [Serve over stdio](./serving/stdio.md) covers the server side. ## Recap -<!-- the claims this page will prove: -- handler.fetch serves a Request in-process; the transport URL is never dialed. -- One Client + one createMcpHandler is a complete no-socket integration test. -- InMemoryTransport.createLinkedPair() pairs 2025-era instances; it is not a 2026-era entry. -- stdio coverage means spawning the real process. -- Close the client and the handler between tests. ---> + +- `handler.fetch` passed as the transport's `fetch` option serves every request in-process; the transport never dials the URL. +- One `Client` plus one `createMcpHandler` is a complete no-socket integration test of the server you deploy. +- Assert on `structuredContent`; a handler failure resolves as a result with `isError: true`. +- Close the client, then the handler, between tests. +- `InMemoryTransport.createLinkedPair()` pairs 2025-era instances in memory. +- stdio coverage means spawning the real process with `StdioClientTransport`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index adb7042ae3..7871217abb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,68 +1,159 @@ --- -status: scaffold shape: reference --- # Troubleshooting -<!-- SCAFFOLD - structure only; prose comes in a later tranche. -scope: Verbatim error message as each heading; seeded from faq.md; pruning rule stated. -teaches: serveStdio, console.error-on-stdio, zod dedupe, globalThis.crypto, SdkError(EraNegotiationFailed), SdkError(MethodNotSupportedByProtocolVersion), @modelcontextprotocol/server-legacy -source: mined from docs/faq.md (all four entries), docs/server-quickstart.md "IMPORTANT" stdio box, docs/migration/support-2026-07-28.md "Client side: versionNegotiation" -FORMAT RULE (reference page): every H2 below is the VERBATIM error message a reader -pastes into search — not an imperative micro-step. Entries are ordered by how often -they hit, not by topic. ---> - -::: info -<!-- PRUNING RULE (stated on-page, proposal §5): an entry lives only as long as the -surface that produces it. Entries tied to a removed era, package, or Node version are -deleted with it — this page never accretes. --> -::: +Each heading on this page is the verbatim error message. Match yours, then apply that entry's fix. ## `SyntaxError: Unexpected token ... is not valid JSON` -<!-- teaches: stdout is the wire on stdio; log to stderr | salvage: docs/server-quickstart.md "IMPORTANT" box (the #1 real-world stdio bug, agent-report 89 §7) --> -On stdio, standard output carries JSON-RPC. One `console.log` corrupts the stream; log to `stderr`. -```ts -// draft - API verified against packages/server/src/server/serveStdio.ts (serveStdio L375) and packages/server/src/stdio.ts (subpath export) +On stdio, standard output is the wire: the host parses every line your server writes to `stdout` as JSON-RPC. One `console.log` — yours or a dependency's — puts a stray line on it, and the host reports that line with this error. Log to `stderr` instead; `serveStdio` owns `stdout`, and `console.error` is safe anywhere in the process. + +```ts source="../examples/guides/troubleshooting.stdio.examples.ts#serveStdio_stderr" import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; serveStdio(() => { - const server = new McpServer({ name: 'app', version: '1.0.0' }); - console.error('app server running on stdio'); // stderr — never console.log on stdio - return server; + const server = new McpServer({ name: 'app', version: '1.0.0' }); + console.error('app server running on stdio'); // stderr — never console.log + return server; }); ``` -<!-- result: the client parses every stdout line as JSON-RPC; the stderr line shows up in the host's log, not on the wire --> + +The host shows the `stderr` line in the server's log and keeps parsing `stdout` cleanly. [Serve over stdio](./serving/stdio.md) covers the entry point. + +::: tip +The quoted token is the first character of the stray line, which usually identifies the call that wrote it. +::: ## `TS2589: Type instantiation is excessively deep and possibly infinite` -<!-- teaches: single zod version in the tree | salvage: docs/faq.md "Why do I see TS2589 ... after upgrading the SDK?" --> -<!-- code: sh block — npm ls zod / pnpm why zod, then the overrides/resolutions fix --> + +Two copies of `zod` in the dependency tree. The SDK derives its tool, prompt and resource types from Zod v4 schemas; a second `zod` copy makes TypeScript instantiate cross-version types until it hits its recursion limit and fails at an unrelated-looking call site. + +List every installed copy: + +```sh +npm ls zod # or: pnpm why zod / yarn why zod +``` + +Align everything on one Zod 4 version. When a transitive dependency pins another copy, force one with your package manager's override field (`overrides` for npm and pnpm, `resolutions` for Yarn): + +```json +{ + "overrides": { + "zod": "^4.2.0" + } +} +``` + +`npm ls zod` reporting a single version means the duplicate is gone and the error with it. ## `ReferenceError: crypto is not defined` -<!-- teaches: globalThis.crypto polyfill for the OAuth client helpers on Node 18 | salvage: docs/faq.md "How do I enable Web Crypto ..." --> -<!-- code: ts block — node:crypto webcrypto polyfill assignment, mirroring packages/client/vitest.setup.js --> + +The OAuth client helpers sign and verify through the Web Crypto API at `globalThis.crypto`. Every `@modelcontextprotocol/*` package requires Node.js 20, where that global is always defined — this error means the process is running on an older runtime (Node.js 18 and earlier). + +Upgrade Node.js. Where you cannot, assign the polyfill from `node:crypto` before anything touches the SDK: + +```ts source="../examples/guides/troubleshooting.examples.ts#webcrypto_polyfill" +import { webcrypto } from 'node:crypto'; + +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = webcrypto; +} +``` + +With the global in place the [client OAuth](./clients/oauth.md) flows run unchanged. ## `SdkError: ERA_NEGOTIATION_FAILED` -<!-- teaches: connect() rejects when the mode/supported-versions list leaves no era both sides speak | salvage: docs/migration/support-2026-07-28.md "Client side: versionNegotiation" --> -<!-- code: the failing shape (mode: { pin: '2026-07-28' } against a 2025-only server) and the two fixes (mode: 'auto', or add a 2025 entry to supportedProtocolVersions) --> -<!-- era caveat: ONE line linking /protocol-versions for what an era is --> + +`connect()` found no **protocol era** both sides speak. Two shapes produce it: `versionNegotiation: { mode: { pin: ... } }` names a revision the server does not offer over `server/discover`, and pinning never falls back; or `mode: 'auto'` with a `supportedProtocolVersions` list that has no pre-2026 entry, which removes the legacy fallback. + +The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from): + +```ts source="../examples/guides/troubleshooting.examples.ts#connect_pinRejected" +const pinned = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + +try { + await pinned.connect(transport); +} catch (error) { + if (!(error instanceof SdkError)) throw error; + console.log(`${error.code}: ${error.message}`); +} +``` + +The rejection names the pinned revision the server never offered: + +``` +ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) +``` + +Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection. + +```ts source="../examples/guides/troubleshooting.examples.ts#connect_autoFallback" +const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await negotiated.connect(transport); +console.log(negotiated.getProtocolEra()); +``` + +`connect()` resolves and the client reports the era it landed on: + +``` +legacy +``` + +Keep `{ pin }` where a legacy connection is unacceptable and a hard failure is the behavior you want. [Protocol versions](./protocol-versions.md) defines the eras and what each negotiation mode offers. ## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` -<!-- teaches: an outbound spec method that the negotiated era does not define | salvage: docs/migration/support-2026-07-28.md "Appendix" behavior matrix row --> -<!-- code: the failing call and the matrix-backed replacement; one line linking /protocol-versions --> + +You sent a spec method the negotiated protocol era does not define. The SDK raises this locally — nothing reached the transport — and the message names the method, the negotiated revision, and the era-appropriate replacement. + +`subscriptions/listen` exists only on a 2026-07-28 connection; this client negotiated a 2025 era, the default: + +```ts source="../examples/guides/troubleshooting.examples.ts#listen_legacyConnection" +const client = new Client({ name: 'app', version: '1.0.0' }); +await client.connect(transport); + +try { + await client.listen({ resourceSubscriptions: ['file:///logs/app.log'] }); +} catch (error) { + if (!(error instanceof SdkError)) throw error; + console.log(`${error.code}: ${error.message}`); +} +``` + +The message carries the fix: + +``` +METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION: subscriptions/listen requires a 2026-07-28-era connection (negotiated: 2025-11-25). On a 2025-era connection, change notifications are delivered unsolicited: use ClientOptions.listChanged and resources/subscribe instead. +``` + +Either negotiate the era that defines the method — `versionNegotiation: { mode: 'auto' }` against a server that serves 2026-07-28, as in the previous entry — or call the surface the negotiated era does define. [Subscriptions](./clients/subscriptions.md) covers both delivery models; [Protocol versions](./protocol-versions.md) lists which methods each era defines. ## `Module '"@modelcontextprotocol/server"' has no exported member 'SSEServerTransport'` -<!-- teaches: where server SSE and the AS auth helpers went (@modelcontextprotocol/server-legacy) | salvage: docs/faq.md "Why did we remove server SSE transport?" + "Where are the server auth helpers?" --> -<!-- code: the import rewrite — server SSE from @modelcontextprotocol/server-legacy/sse, AS helpers from @modelcontextprotocol/server-legacy/auth; RS helpers are first-class in @modelcontextprotocol/express --> + +`@modelcontextprotocol/server` no longer ships the server-side SSE transport, and the OAuth Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`) left with it. Both live on as a frozen v1 copy in `@modelcontextprotocol/server-legacy`. + +Rewrite the imports: + +```diff +- import { SSEServerTransport } from '@modelcontextprotocol/server'; ++ import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; + +- import { mcpAuthRouter, ProxyOAuthServerProvider } from '@modelcontextprotocol/server'; ++ import { mcpAuthRouter, ProxyOAuthServerProvider } from '@modelcontextprotocol/server-legacy/auth'; +``` + +The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above. ## Recap -<!-- the claims this page will prove: -- Every heading is the exact message you searched for. -- On stdio, stdout is the protocol; log with console.error. -- TS2589 means two zod copies in the tree. -- ERA_NEGOTIATION_FAILED and METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION are negotiation outcomes, explained once on the protocol-versions page. -- Server SSE and the AS helpers live in @modelcontextprotocol/server-legacy. -- Entries die with the surface that produced them. ---> + +- Every heading on this page is the exact message you searched for. +- On stdio, `stdout` carries JSON-RPC; log with `console.error`. +- `TS2589` means two `zod` copies in the dependency tree. +- The SDK raises `ERA_NEGOTIATION_FAILED` and `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` locally — neither is a wire error. +- Server SSE and the Authorization Server helpers live in `@modelcontextprotocol/server-legacy`. + +<!-- maintainers: an entry on this page lives only as long as the surface that +produces it. When an era, package, or supported Node.js version is removed, +delete its entries in the same change — this page never accretes. --> diff --git a/examples/guides/protocolVersions.examples.ts b/examples/guides/protocolVersions.examples.ts new file mode 100644 index 0000000000..6b6e44e69c --- /dev/null +++ b/examples/guides/protocolVersions.examples.ts @@ -0,0 +1,138 @@ +/** + * Companion example for `docs/protocol-versions.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness routes + * `globalThis.fetch` for the page's two URLs in-process — `localhost:3000` is + * the `createMcpHandler` entry built below (serves both eras) and + * `localhost:4000` is a 2025-only server — so the probe, the fallback, and the + * pin rejection the page quotes all execute for real without binding a port. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/protocolVersions.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ + +// ## Serve both eras from one entry point +// (Defined first so the harness can route to it; the page introduces it last.) + +//#region createMcpHandler_bothEras +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const handler = createMcpHandler(({ era }) => { + const server = new McpServer({ name: 'forecast', version: '1.0.0' }); + server.registerTool( + 'forecast', + { + description: 'Forecast for a city', + inputSchema: z.object({ city: z.string() }) + }, + async ({ city }) => ({ content: [{ type: 'text', text: `${city}: sunny (${era} era)` }] }) + ); + return server; +}); +//#endregion createMcpHandler_bothEras + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). `localhost:4000` is a 2025-only server: +// `legacyStatelessFallback` is the same stateless 2025 serving that +// `createMcpHandler` uses for its own legacy traffic, exposed standalone. It +// never recognizes `server/discover`, so an `'auto'` probe against it falls +// back and a pin against it rejects. +// --------------------------------------------------------------------------- + +const { legacyStatelessFallback } = await import('@modelcontextprotocol/server'); +const legacyOnly = legacyStatelessFallback(() => new McpServer({ name: 'forecast-2025', version: '1.0.0' })); + +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + const { host } = new URL(request.url); + if (host === 'localhost:3000') return handler.fetch(request); + if (host === 'localhost:4000') return legacyOnly(request); + return realFetch(input, init); +}) as typeof fetch; + +// ## Negotiate the era from the client — `'auto'` against the both-era endpoint. + +//#region versionNegotiation_auto +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); + +console.log(client.getProtocolEra()); +//#endregion versionNegotiation_auto + +// The same options against the 2025-only endpoint: the probe finds nothing +// modern and `connect()` falls back to `initialize` on the same connection. + +//#region versionNegotiation_fallback +const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await fallback.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); + +console.log(fallback.getProtocolEra()); +//#endregion versionNegotiation_fallback + +// ## Pin an era — a pin never falls back; against a 2025-only server it rejects. + +//#region versionNegotiation_pin +import { SdkError } from '@modelcontextprotocol/client'; + +const pinned = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + +try { + await pinned.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); +} catch (error) { + if (error instanceof SdkError) console.log(`${error.code}: ${error.message}`); +} +//#endregion versionNegotiation_pin + +// ## Serve both eras from one entry point — the era reaches the factory. + +//#region createMcpHandler_callBothEras +const defaultClient = new Client({ name: 'my-client', version: '1.0.0' }); + +await defaultClient.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); + +for (const caller of [client, defaultClient]) { + const result = await caller.callTool({ name: 'forecast', arguments: { city: 'Berlin' } }); + console.log(caller.getProtocolEra(), JSON.stringify(result.content)); +} +//#endregion createMcpHandler_callBothEras + +await client.close(); +await fallback.close(); +await defaultClient.close(); +await handler.close(); +globalThis.fetch = realFetch; + +// --------------------------------------------------------------------------- +// ## Understand the probe — the options block is never connected; it exists to +// typecheck the `probe` shape the page shows. +// --------------------------------------------------------------------------- + +function versionNegotiation_probe(): Client { + //#region versionNegotiation_probe + const cli = new Client( + { name: 'my-client', version: '1.0.0' }, + { + versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000, // default: the connection's request timeout + maxRetries: 0 // default: no probe re-sends after a timeout + } + } + } + ); + //#endregion versionNegotiation_probe + return cli; +} + +void versionNegotiation_probe; diff --git a/examples/guides/testing.examples.ts b/examples/guides/testing.examples.ts new file mode 100644 index 0000000000..9f1e87af51 --- /dev/null +++ b/examples/guides/testing.examples.ts @@ -0,0 +1,130 @@ +/** + * Companion example for `docs-v2/testing.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: it is the no-socket + * client harness the page teaches — `createMcpHandler` served through + * `handler.fetch`, then `InMemoryTransport.createLinkedPair()` — and every + * output the page quotes verbatim is printed by this program. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/testing.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ + +// ## Serve the handler in-process + +//#region inProcessHandler +import assert from 'node:assert/strict'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +function createServer() { + const server = new McpServer({ name: 'pricing', version: '1.0.0' }); + server.registerTool( + 'apply-discount', + { + description: 'Apply a percentage discount to a price', + inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }), + outputSchema: z.object({ total: z.number() }) + }, + async ({ price, percent }) => { + if (price < 0) { + return { content: [{ type: 'text', text: 'price must be >= 0' }], isError: true }; + } + const total = price * (1 - percent / 100); + return { content: [{ type: 'text', text: `$${total}` }], structuredContent: { total } }; + } + ); + return server; +} + +const handler = createMcpHandler(createServer); + +const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) +}); +//#endregion inProcessHandler + +// ## Connect a client and call a tool + +//#region connectAndCall +const client = new Client({ name: 'test-harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); + +const result = await client.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } }); +console.log(result.structuredContent); +//#endregion connectAndCall + +// Proof for the page's claim that this wiring exercises the real 2026-07-28 +// HTTP path. Throws (non-zero exit) if the claim is false. +assert.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); + +// ## Assert on the result + +//#region assertResult +assert.deepStrictEqual(result.structuredContent, { total: 60 }); + +const failed = await client.callTool({ name: 'apply-discount', arguments: { price: -5, percent: 25 } }); +assert.equal(failed.isError, true); +console.log(failed.content); +//#endregion assertResult + +// ## Tear down between tests + +//#region tearDown +await client.close(); +await handler.close(); +//#endregion tearDown + +// ## Pair two instances in memory + +//#region linkedPair +import { InMemoryTransport } from '@modelcontextprotocol/client'; + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + +const memServer = createServer(); +const memClient = new Client({ name: 'test-harness', version: '1.0.0' }); +await memServer.connect(serverTransport); +await memClient.connect(clientTransport); +//#endregion linkedPair + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). Proves the page's prose claims, then +// closes the linked pair so the program terminates on its own. +// --------------------------------------------------------------------------- + +// "The same `callTool` from above returns the same result over this pair." +const paired = await memClient.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } }); +assert.deepStrictEqual(paired.structuredContent, { total: 60 }); + +// Proof for the page's era caveat: the linked pair runs the 2025 handshake, +// not the 2026-07-28 revision the `handler.fetch` harness above negotiated. +assert.equal(memClient.getNegotiatedProtocolVersion(), '2025-11-25'); + +await memClient.close(); +await memServer.close(); + +// ## Cover stdio by spawning the process +// +// `StdioClientTransport.start()` spawns a real child process, so this region +// lives in a wrapper that typechecks but is never called. + +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +async function coverStdio() { + //#region stdioSpawn + const stdioClient = new Client({ name: 'test-harness', version: '1.0.0' }); + await stdioClient.connect(new StdioClientTransport({ command: 'node', args: ['dist/server.js'] })); + //#endregion stdioSpawn + return stdioClient; +} + +void coverStdio; + +console.log('testing.examples.ts: all assertions passed'); diff --git a/examples/guides/troubleshooting.examples.ts b/examples/guides/troubleshooting.examples.ts new file mode 100644 index 0000000000..8b4508c6a7 --- /dev/null +++ b/examples/guides/troubleshooting.examples.ts @@ -0,0 +1,85 @@ +/** + * Companion example for `docs/troubleshooting.md`. + * + * Every `ts` fence on that page except the stdio one is synced from a + * `//#region` in this file (`pnpm sync:snippets --check`); the stdio fence + * lives in `troubleshooting.stdio.examples.ts` because `serveStdio` binds + * stdin and would keep this program from terminating. The file also runs: the + * harness below connects in-memory clients to a 2025-only server and produces + * the error messages the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/troubleshooting.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +/* eslint-disable unicorn/no-typeof-undefined -- the typeof form also covers runtimes where the global is not declared at all */ +//#region webcrypto_polyfill +import { webcrypto } from 'node:crypto'; + +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = webcrypto; +} +//#endregion webcrypto_polyfill + +import { Client, InMemoryTransport, SdkError } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). A bare `McpServer` connected over an +// in-memory pair never serves `server/discover`, so it stands in for any +// server that has not adopted the 2026-07-28 revision. Each scenario gets a +// fresh server + linked transport pair. +// --------------------------------------------------------------------------- + +const servers: McpServer[] = []; +async function legacyServerTransport(): Promise<InMemoryTransport> { + const server = new McpServer({ name: 'app', version: '1.0.0' }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + servers.push(server); + return clientSide; +} + +// "SdkError: ERA_NEGOTIATION_FAILED" — the rejection the page quotes. +let transport = await legacyServerTransport(); +//#region connect_pinRejected +const pinned = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + +try { + await pinned.connect(transport); +} catch (error) { + if (!(error instanceof SdkError)) throw error; + console.log(`${error.code}: ${error.message}`); +} +//#endregion connect_pinRejected + +// "SdkError: ERA_NEGOTIATION_FAILED" — the `mode: 'auto'` fix the page quotes. +transport = await legacyServerTransport(); +//#region connect_autoFallback +const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await negotiated.connect(transport); +console.log(negotiated.getProtocolEra()); +//#endregion connect_autoFallback + +// "SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION" — the rejection the page quotes. +transport = await legacyServerTransport(); +//#region listen_legacyConnection +const client = new Client({ name: 'app', version: '1.0.0' }); +await client.connect(transport); + +try { + await client.listen({ resourceSubscriptions: ['file:///logs/app.log'] }); +} catch (error) { + if (!(error instanceof SdkError)) throw error; + console.log(`${error.code}: ${error.message}`); +} +//#endregion listen_legacyConnection + +await negotiated.close(); +await client.close(); +for (const server of servers) { + await server.close(); +} diff --git a/examples/guides/troubleshooting.stdio.examples.ts b/examples/guides/troubleshooting.stdio.examples.ts new file mode 100644 index 0000000000..96e1d7e81d --- /dev/null +++ b/examples/guides/troubleshooting.stdio.examples.ts @@ -0,0 +1,23 @@ +// docs: typecheck-only +/** + * Companion example for the stdio entry on `docs/troubleshooting.md`. + * + * This file is separate from `troubleshooting.examples.ts` because + * `serveStdio` binds stdin: importing it into the runnable companion would + * keep that program from terminating. Verified by + * `pnpm --filter @modelcontextprotocol/examples typecheck` and + * `pnpm sync:snippets --check`; never executed. + * + * @module + */ +/* eslint-disable no-console */ +//#region serveStdio_stderr +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'app', version: '1.0.0' }); + console.error('app server running on stdio'); // stderr — never console.log + return server; +}); +//#endregion serveStdio_stderr From f043fd1980047fc39b47cf56d02b8b6a9767c659 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:18 +0000 Subject: [PATCH 11/27] docs: update the page tree and conventions for the written tranche --- docs/_meta/CONVENTIONS.md | 2 + docs/_meta/_TREE.md | 84 +++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/docs/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md index 7636193336..a8f62e61ea 100644 --- a/docs/_meta/CONVENTIONS.md +++ b/docs/_meta/CONVENTIONS.md @@ -11,6 +11,8 @@ agent reads this file before touching a page. THE REGISTER — "restrained tiangolo". Felix chose this against live samples. Every fully written page must pass EVERY rule; the voice reviewer fails pages per-rule. R1. First screenful: a code block, or one lead-in sentence then a code block. Zero preamble. + Carve-out (Felix, 2026-06-30): the LANDING PAGE (index.md) leads with a short "what is + MCP" orientation paragraph + a spec link BEFORE the code. Every other page: code first. R2. No main-flow paragraph exceeds 3 sentences; most are 1-2. R3. Second person, imperative, present tense. Never "we", never "the user", never "one can". R4. Every code block has a one-line lead-in naming the ONE thing it adds or changes. diff --git a/docs/_meta/_TREE.md b/docs/_meta/_TREE.md index 4f84919241..3d1d4e25fa 100644 --- a/docs/_meta/_TREE.md +++ b/docs/_meta/_TREE.md @@ -1,8 +1,8 @@ # docs-v2 draft — full tree (45 pages) -This is the Phase 2 docs draft: 3 fully written CALIBRATION pages, 39 SCAFFOLD pages (H2 outlines with one verified lead code block each), and 3 VERBATIM-COPY migration files, on the approved 45-page structure. -React to two things: (1) voice — read the three CALIBRATION pages (`index.md`, `get-started/first-server.md`, `servers/tools.md`) against `_meta/CONVENTIONS.md`; (2) structure — this file plus each scaffold's H2 outline. Scaffold prose comes in a later tranche; do not review scaffold wording. -Format: `path | shape | status | scope`. Sections appear in nav order. +This is the Phase 2 docs draft: 3 CALIBRATION pages (Felix-approved voice exemplars), 39 fully WRITTEN pages (prose complete, every `ts` fence backed by a typechecking companion in `examples/guides/`), and 3 VERBATIM-COPY migration files, on the approved 45-page structure. +Every WRITTEN page is reviewable as final prose: judge it against `_meta/CONVENTIONS.md` (REGISTER R1–R15) with the three CALIBRATION pages (`index.md`, `get-started/first-server.md`, `servers/tools.md`) as the voice baseline, and check structure against this file plus the page's H2 set. +Format: `path | shape | status | scope`. Sections appear in nav order. Status values: CALIBRATION (locked exemplar), WRITTEN (fully written this tranche), VERBATIM-COPY (byte-identical copy, untouched). ## Top level @@ -15,76 +15,76 @@ Format: `path | shape | status | scope`. Sections appear in nav order. | path | shape | status | scope | | --- | --- | --- | --- | | `get-started/first-server.md` | tutorial | CALIBRATION | Setup once → one tool → run → see it answer | -| `get-started/real-host.md` | tutorial | SCAFFOLD | Plug your server into Claude Code / VS Code / Cursor | -| `get-started/first-client.md` | tutorial | SCAFFOLD | Connect, list, call, read, close — neutral, no vendor SDK | -| `get-started/packages.md` | explanation | SCAFFOLD | Which of the 10 packages, why subpaths exist | +| `get-started/real-host.md` | tutorial | WRITTEN | Plug your server into Claude Code / VS Code / Cursor | +| `get-started/first-client.md` | tutorial | WRITTEN | Connect, list, call, read, close — neutral, no vendor SDK | +| `get-started/packages.md` | explanation | WRITTEN | Which of the 10 packages, why subpaths exist | ## servers/ | path | shape | status | scope | | --- | --- | --- | --- | | `servers/tools.md` | how-to | CALIBRATION | Register, the schema payoff, structured output | -| `servers/resources.md` | how-to | SCAFFOLD | Static + templated resources, list callbacks | -| `servers/prompts.md` | how-to | SCAFFOLD | Register prompts, message construction | -| `servers/completion.md` | how-to | SCAFFOLD | Autocomplete a schema field | -| `servers/logging-progress-cancellation.md` | how-to | SCAFFOLD | The ctx every handler receives: logging, progress, cancellation | -| `servers/elicitation.md` | how-to | SCAFFOLD | Ask the user (form mode, URL mode) | -| `servers/sampling.md` | how-to | SCAFFOLD | Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first | -| `servers/input-required.md` | how-to | SCAFFOLD | Handle input_required (multi-round-trip requests) | -| `servers/notifications.md` | how-to | SCAFFOLD | Notify clients of changes | -| `servers/errors.md` | how-to | SCAFFOLD | isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out) | +| `servers/resources.md` | how-to | WRITTEN | Static + templated resources, list callbacks | +| `servers/prompts.md` | how-to | WRITTEN | Register prompts, message construction | +| `servers/completion.md` | how-to | WRITTEN | Autocomplete a schema field | +| `servers/logging-progress-cancellation.md` | how-to | WRITTEN | The ctx every handler receives: logging, progress, cancellation | +| `servers/elicitation.md` | how-to | WRITTEN | Ask the user (form mode, URL mode) | +| `servers/sampling.md` | how-to | WRITTEN | Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first | +| `servers/input-required.md` | how-to | WRITTEN | Handle input_required (multi-round-trip requests) | +| `servers/notifications.md` | how-to | WRITTEN | Notify clients of changes | +| `servers/errors.md` | how-to | WRITTEN | isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out) | ## serving/ | path | shape | status | scope | | --- | --- | --- | --- | -| `serving/stdio.md` | how-to | SCAFFOLD | serveStdio and the console.error gotcha | -| `serving/http.md` | how-to | SCAFFOLD | createMcpHandler; the per-request factory model lives HERE (recipes link back) | -| `serving/express.md` | how-to | SCAFFOLD | Express recipe — self-contained, install one-liner at top, one back-link to http.md | -| `serving/hono.md` | how-to | SCAFFOLD | Hono recipe — same shape as express.md | -| `serving/fastify.md` | how-to | SCAFFOLD | Fastify recipe — same shape as express.md | -| `serving/web-standard.md` | how-to | SCAFFOLD | Web-standard runtimes (Workers etc.) recipe — same shape as express.md | -| `serving/sessions-state-scaling.md` | how-to | SCAFFOLD | Sessions, Resumability, Multi-node — stateless ruling first, two sentences | -| `serving/authorization.md` | how-to | SCAFFOLD | Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router | -| `serving/legacy-clients.md` | how-to | SCAFFOLD | The legacy: option; where SSE went | +| `serving/stdio.md` | how-to | WRITTEN | serveStdio and the console.error gotcha | +| `serving/http.md` | how-to | WRITTEN | createMcpHandler; the per-request factory model lives HERE (recipes link back) | +| `serving/express.md` | how-to | WRITTEN | Express recipe — self-contained, install one-liner at top, one back-link to http.md | +| `serving/hono.md` | how-to | WRITTEN | Hono recipe — same shape as express.md | +| `serving/fastify.md` | how-to | WRITTEN | Fastify recipe — same shape as express.md | +| `serving/web-standard.md` | how-to | WRITTEN | Web-standard runtimes (Workers etc.) recipe — same shape as express.md | +| `serving/sessions-state-scaling.md` | how-to | WRITTEN | Sessions, Resumability, Multi-node — stateless ruling first, two sentences | +| `serving/authorization.md` | how-to | WRITTEN | Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router | +| `serving/legacy-clients.md` | how-to | WRITTEN | The legacy: option; where SSE went | ## clients/ | path | shape | status | scope | | --- | --- | --- | --- | -| `clients/connect.md` | how-to | SCAFFOLD | Client + transports, what you can ask after connect | -| `clients/calling.md` | how-to | SCAFFOLD | The verbs; auto-aggregating pagination | -| `clients/server-requests.md` | how-to | SCAFFOLD | Sampling/elicitation handlers; era unification told once via one cross-link | -| `clients/roots.md` | how-to | SCAFFOLD | Provide roots — SUNSET-FRAMED (SEP-2577), banner at top | -| `clients/subscriptions.md` | how-to | SCAFFOLD | listen filters vs legacy subscribe | -| `clients/oauth.md` | how-to | SCAFFOLD | User-facing authorization-code flow. Opens with the one-line auth router | -| `clients/machine-auth.md` | how-to | SCAFFOLD | Client credentials, private-key JWT, cross-app access | -| `clients/middleware.md` | how-to | SCAFFOLD | Compose request/response middleware | -| `clients/caching.md` | how-to | SCAFFOLD | Client store + server cache hints, presented as one feature | +| `clients/connect.md` | how-to | WRITTEN | Client + transports, what you can ask after connect | +| `clients/calling.md` | how-to | WRITTEN | The verbs; auto-aggregating pagination | +| `clients/server-requests.md` | how-to | WRITTEN | Sampling/elicitation handlers; era unification told once via one cross-link | +| `clients/roots.md` | how-to | WRITTEN | Provide roots — SUNSET-FRAMED (SEP-2577), banner at top | +| `clients/subscriptions.md` | how-to | WRITTEN | listen filters vs legacy subscribe | +| `clients/oauth.md` | how-to | WRITTEN | User-facing authorization-code flow. Opens with the one-line auth router | +| `clients/machine-auth.md` | how-to | WRITTEN | Client credentials, private-key JWT, cross-app access | +| `clients/middleware.md` | how-to | WRITTEN | Compose request/response middleware | +| `clients/caching.md` | how-to | WRITTEN | Client store + server cache hints, presented as one feature | ## Top level | path | shape | status | scope | | --- | --- | --- | --- | -| `protocol-versions.md` | explanation | SCAFFOLD | Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide | +| `protocol-versions.md` | explanation | WRITTEN | Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide | ## advanced/ | path | shape | status | scope | | --- | --- | --- | --- | -| `advanced/low-level-server.md` | explanation | SCAFFOLD | Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria | -| `advanced/custom-methods.md` | how-to | SCAFFOLD | Vendor-prefixed methods, extension capabilities | -| `advanced/schema-libraries.md` | how-to | SCAFFOLD | Valibot/ArkType, JSON-Schema-in, pluggable validators | -| `advanced/custom-transports.md` | how-to | SCAFFOLD | Implement the Transport interface | -| `advanced/wire-schemas.md` | how-to | SCAFFOLD | @modelcontextprotocol/core for gateways/proxies (raw wire schemas) | -| `advanced/gateway.md` | how-to | SCAFFOLD | Zero-round-trip reconnect with a prior discover result | +| `advanced/low-level-server.md` | explanation | WRITTEN | Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria | +| `advanced/custom-methods.md` | how-to | WRITTEN | Vendor-prefixed methods, extension capabilities | +| `advanced/schema-libraries.md` | how-to | WRITTEN | Valibot/ArkType, JSON-Schema-in, pluggable validators | +| `advanced/custom-transports.md` | how-to | WRITTEN | Implement the Transport interface | +| `advanced/wire-schemas.md` | how-to | WRITTEN | @modelcontextprotocol/core for gateways/proxies (raw wire schemas) | +| `advanced/gateway.md` | how-to | WRITTEN | Zero-round-trip reconnect with a prior discover result | ## Top level | path | shape | status | scope | | --- | --- | --- | --- | -| `testing.md` | how-to | SCAFFOLD | In-memory linked pair + handler.fetch — no sockets | -| `troubleshooting.md` | reference | SCAFFOLD | Verbatim error message as each heading; seeded from faq.md; pruning rule stated | +| `testing.md` | how-to | WRITTEN | In-memory linked pair + handler.fetch — no sockets | +| `troubleshooting.md` | reference | WRITTEN | Verbatim error message as each heading; seeded from faq.md; pruning rule stated | ## migration/ From ca1adaf61cb275946683a69ed4316e70133fffc5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:18 +0000 Subject: [PATCH 12/27] test: run the guide companion examples in CI --- .github/workflows/examples.yml | 6 ++ package.json | 1 + scripts/run-guide-examples.ts | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 scripts/run-guide-examples.ts diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 34ec6b152c..197041e07f 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -45,3 +45,9 @@ jobs: - name: Run all example pairs (transport × era) run: pnpm tsx scripts/examples/run-examples.ts + + # Every docs-page companion under examples/guides/ is a real program + # (the page's quoted output comes from it). Run each one; a file whose + # first line is "// docs: typecheck-only" is skipped. + - name: Run guide examples + run: pnpm docs:examples diff --git a/package.json b/package.json index 433f8b1132..bbb593c297 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "sync:snippets": "tsx scripts/sync-snippets.ts", "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", "run:examples": "tsx scripts/examples/run-examples.ts", + "docs:examples": "tsx scripts/run-guide-examples.ts", "docs:api": "typedoc", "docs:index": "tsx scripts/build-docs-index.ts", "docs:dev": "vitepress dev docs", diff --git a/scripts/run-guide-examples.ts b/scripts/run-guide-examples.ts new file mode 100644 index 0000000000..bfd2a4d04d --- /dev/null +++ b/scripts/run-guide-examples.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env tsx +/** + * Run every guide companion under `examples/guides/**` as a real program. + * + * Each docs page's code fences are synced from a companion + * `examples/guides/<...>/<page>.examples.ts` (see `scripts/sync-snippets.ts`). + * Companions that quote output are self-verifying top-level-await scripts: they + * drive an in-memory client/server pair (or a web-standard `handler.fetch`), + * assert what the page claims, and exit non-zero on any mismatch. This harness + * runs each one with `tsx` and reports PASS/FAIL from the child's exit code. + * + * A companion with nothing meaningful to execute opts out by making its FIRST + * line exactly: + * + * // docs: typecheck-only + * + * Those files are still type-checked by the `@modelcontextprotocol/examples` + * package; they are listed here as SKIP and never spawned. + * + * Files run SEQUENTIALLY (they are cheap, and this avoids any port/stdin + * contention). A run that exceeds the per-file timeout is a FAIL ("hung — + * possible unclosed handle"): companions must terminate on their own, so they + * never bind a port and never block on stdin (stdin is closed for the child). + */ +import { spawn } from 'node:child_process'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..'); +const GUIDES = join(ROOT, 'examples', 'guides'); +const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); + +/** Marker that opts a companion out of being executed (first line, exact). */ +const TYPECHECK_ONLY = '// docs: typecheck-only'; + +/** Per-file timeout: a companion that has not exited by now is hung. */ +const TIMEOUT_MS = 90_000; + +interface FileResult { + file: string; + ok: boolean; + durationMs: number; + detail: string; +} + +function isTypecheckOnly(file: string): boolean { + const firstLine = readFileSync(file, 'utf8').split('\n', 1)[0] ?? ''; + return firstLine.trimEnd() === TYPECHECK_ONLY; +} + +function runOne(file: string): Promise<FileResult> { + const started = Date.now(); + return new Promise(resolvePromise => { + // stdin is 'ignore' so a companion that (incorrectly) reads stdin sees + // EOF immediately instead of hanging until the timeout. + const child = spawn(TSX, [file], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', d => (output += String(d))); + child.stderr.on('data', d => (output += String(d))); + const finish = (ok: boolean, detail: string): void => resolvePromise({ file, ok, durationMs: Date.now() - started, detail }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(false, `timed out after ${TIMEOUT_MS / 1000}s (hung — possible unclosed handle)\n${output}`); + }, TIMEOUT_MS); + child.on('close', code => { + clearTimeout(timer); + if (code === 0) finish(true, ''); + else finish(false, `exit ${code}\n${output}`); + }); + child.on('error', err => { + clearTimeout(timer); + finish(false, `spawn error: ${err.message}\n${output}`); + }); + }); +} + +async function main(): Promise<void> { + const files = readdirSync(GUIDES, { recursive: true, encoding: 'utf8' }) + .filter(name => name.endsWith('.examples.ts')) + .map(name => join(GUIDES, name)) + .sort(); + if (files.length === 0) { + console.error(`No *.examples.ts files found under ${GUIDES}`); + process.exit(1); + } + + const results: FileResult[] = []; + let skipped = 0; + for (const file of files) { + const name = relative(ROOT, file); + if (isTypecheckOnly(file)) { + skipped++; + console.log(`SKIP ${name} (typecheck-only)`); + continue; + } + const result = await runOne(file); + results.push(result); + console.log(`${result.ok ? 'PASS' : 'FAIL'} ${name} (${(result.durationMs / 1000).toFixed(1)}s)`); + if (!result.ok) console.log(result.detail); + } + + const failed = results.filter(r => !r.ok); + console.log('\n=== guide examples summary ==='); + console.log(`files: ${results.length} run / ${skipped} typecheck-only / ${failed.length} failed`); + for (const r of failed) console.log(` FAIL ${relative(ROOT, r.file)}`); + + process.exit(failed.length === 0 ? 0 : 1); +} + +void main(); From 4deaaa45c50df2ce616487f674b10cffb77a6698 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:43:18 +0000 Subject: [PATCH 13/27] docs: enable dead-link checking for the v2 site --- docs/.vitepress/config.mts | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c0efd77ddc..23129a7356 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -26,9 +26,6 @@ export default defineConfig({ base: '/v2/', srcExclude: ['v1/**', '_meta/**'], sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io/v2/' }, - // Phase-2 preview: most pages are scaffolds with placeholder cross-links; the dead-link gate - // is suspended on this branch only so the structure is browsable. Re-enable before any merge. - ignoreDeadLinks: true, markdown: { config(md) { // Spec-generated JSDoc (packages/core-internal/src/types/spec.types.*.ts) carries From 62a833802442b8f4951de13085b39427abf054f6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 15:56:23 +0000 Subject: [PATCH 14/27] docs(serving): tighten headings and prose on three serving pages - sessions-state-scaling: rename the three H2s to imperative micro-steps (Pin a client to a session / Resume a dropped stream / Scale across nodes) so the page matches the rest of the serving how-to recipes - legacy-clients: rewrite the serveStdio 'reject' sentence in active voice - web-standard: stop telling the reader to export the guarded handler as the default; the 'Run it and verify' step curls the default export from 127.0.0.1, which the Host allowlist would 403, contradicting the quoted output --- docs/serving/legacy-clients.md | 2 +- docs/serving/sessions-state-scaling.md | 6 +++--- docs/serving/web-standard.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/serving/legacy-clients.md b/docs/serving/legacy-clients.md index f8a6e0c155..62980e01cc 100644 --- a/docs/serving/legacy-clients.md +++ b/docs/serving/legacy-clients.md @@ -51,7 +51,7 @@ A strict endpoint still acknowledges legacy-classified notification POSTs with ` serveStdio(buildServer, { legacy: 'reject' }); ``` -Under `'serve'` a 2025-era opening pins the connection to a legacy instance from your factory and serves it exactly as a hand-wired stdio server would. Under `'reject'` the opening is answered with the same unsupported-protocol-version error and the connection stays open for a modern opening. +Under `'serve'` a 2025-era opening pins the connection to a legacy instance from your factory and serves it exactly as a hand-wired stdio server would. Under `'reject'` the entry answers the opening with the same unsupported-protocol-version error and keeps the connection open for a modern opening. ## Keep a sessionful 2025 deployment running diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index 2c85b4d51b..8519ea0bb4 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -5,7 +5,7 @@ shape: how-to `createMcpHandler` builds a fresh server instance from your factory for every HTTP request and holds nothing between requests, so a v2 server is stateless and scales horizontally by default — [Serve over HTTP](./http.md) is the whole setup. Read on if you run a sessionful 2025-era deployment, need a dropped stream to resume, or push change notifications across nodes. -## Sessions +## Pin a client to a session A **session** pins a client to one long-lived transport instance; sessions belong to the hand-wired 2025-era transport — the 2026-07-28 revision is per-request and has no `Mcp-Session-Id` ([Protocol versions](../protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode. @@ -59,7 +59,7 @@ The map cleans itself up: `transport.onclose` fires when the session ends, wheth On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. ::: -## Resumability +## Resume a dropped stream A sessionful client holds a `GET` SSE stream open for server notifications, and anything sent while that connection is down is lost. An **event store** closes the gap: with one configured, the transport stamps every SSE message with an event id from the store before sending it. @@ -78,7 +78,7 @@ When the connection drops, the client reconnects with the last event id it recei `examples/shared/src/inMemoryEventStore.ts` in the SDK repository is a complete `EventStore` reference implementation — in memory, so single-process only. ::: -## Multi-node +## Scale across nodes The stateless default is the scaling story: every node builds a fresh instance from the same factory and holds nothing between requests, so put the nodes behind any load balancer — no session affinity, nothing to share, nothing to configure. diff --git a/docs/serving/web-standard.md b/docs/serving/web-standard.md index 7a935cd17c..4a8f9215dc 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -32,7 +32,7 @@ The deployed worker answers MCP requests on every path, with no Node adapter and ## Protect against DNS rebinding -The handler performs no `Host` or `Origin` validation, and on a bare fetch-native runtime there is no app factory to arm it for you. Put the framework-agnostic response helpers in front of `fetch` and export `guarded` as the default instead. +The handler performs no `Host` or `Origin` validation, and on a bare fetch-native runtime there is no app factory to arm it for you. Put the framework-agnostic response helpers in front of `fetch`. ```ts source="../../examples/guides/serving/webStandard.examples.ts#hostHeaderValidationResponse_guard" import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server'; From 4d8b3e6207888d124c11ad80b4597a544e0509a9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 16:19:14 +0000 Subject: [PATCH 15/27] docs: retire the v1-era guide pages Remove docs/server.md, docs/client.md, docs/server-quickstart.md, docs/client-quickstart.md, and docs/faq.md now that the page tree under docs/get-started, docs/servers, docs/serving, docs/clients, docs/protocol-versions.md, and docs/troubleshooting.md replaces them, and drop the "Current guides (being replaced)" sidebar group. Repoint every inbound reference: - README.md: the Getting Started bullets now point at docs/get-started/first-server.md and first-client.md, and the Documentation section links the tutorials, the documentation site, and docs/troubleshooting.md in place of the Server Guide / Client Guide / FAQ bullets. - packages/server/README.md and packages/client/README.md (embedded into the typedoc API pages) link to the published site instead of the deleted GitHub pages. - packages/server/src/server/requestStateCodec.ts: the Web Crypto error message now points at the troubleshooting page (which carries the Node.js polyfill entry) instead of docs/faq.md. - docs/migration/{index,upgrade-to-v2,support-2026-07-28}.md: replace the faq.md / server.md / client.md links with their live equivalents. - examples/README.md, examples/guides/README.md, the quickstart story READMEs, and examples/oauth-client-credentials/README.md describe and link the live pages. - docs/_meta/CONVENTIONS.md: the illustrative scaffold comment and the live sync-checked fence reference live files. Also remove examples/guides/serverGuide.examples.ts and clientGuide.examples.ts: their only consumers were the deleted guide pages. --- README.md | 16 +- docs/.vitepress/config.mts | 11 - docs/_meta/CONVENTIONS.md | 30 +- docs/client-quickstart.md | 442 ------- docs/client.md | 1065 ----------------- docs/faq.md | 85 -- docs/migration/index.md | 2 +- docs/migration/support-2026-07-28.md | 8 +- docs/migration/upgrade-to-v2.md | 2 +- docs/server-quickstart.md | 474 -------- docs/server.md | 1006 ---------------- examples/README.md | 6 +- examples/client-quickstart/README.md | 4 +- examples/guides/README.md | 2 +- examples/guides/clientGuide.examples.ts | 923 -------------- examples/guides/serverGuide.examples.ts | 886 -------------- examples/oauth-client-credentials/README.md | 2 +- examples/server-quickstart/README.md | 4 +- packages/client/README.md | 2 +- packages/server/README.md | 3 +- .../server/src/server/requestStateCodec.ts | 2 +- 21 files changed, 43 insertions(+), 4932 deletions(-) delete mode 100644 docs/client-quickstart.md delete mode 100644 docs/client.md delete mode 100644 docs/faq.md delete mode 100644 docs/server-quickstart.md delete mode 100644 docs/server.md delete mode 100644 examples/guides/clientGuide.examples.ts delete mode 100644 examples/guides/serverGuide.examples.ts diff --git a/README.md b/README.md index 3d8d5a84f0..663a9a8e38 100644 --- a/README.md +++ b/README.md @@ -129,21 +129,21 @@ async function main() { main(); ``` -Ready to build something real? Follow the step-by-step quickstart tutorials: +Ready to build something real? Follow the step-by-step tutorials: -- [Build a weather server](docs/server-quickstart.md) — server quickstart -- [Build an LLM-powered chatbot](docs/client-quickstart.md) — client quickstart +- [Build your first server](docs/get-started/first-server.md) — a stdio weather-alert server, from `npm init` to a tool call +- [Build your first client](docs/get-started/first-client.md) — connect to that server, list its tools, and call them -The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and -[`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see: +For runnable, end-to-end examples beyond the tutorials, see: - [`examples/README.md`](examples/README.md) — runnable, self-verifying client/server example pairs (one story per directory) ## Documentation -- [Server Guide](docs/server.md) — building MCP servers: transports, tools, resources, prompts, server-initiated requests, and deployment -- [Client Guide](docs/client.md) — building MCP clients: connecting, tools, resources, prompts, server-initiated requests, and error handling -- [FAQ](docs/faq.md) — frequently asked questions and troubleshooting +- [Build a server](docs/get-started/first-server.md) — your first MCP server, step by step +- [Build a client](docs/get-started/first-client.md) — your first MCP client, step by step +- [Documentation site](https://ts.sdk.modelcontextprotocol.io/v2/) — the full guides: tools, resources, prompts, serving over HTTP and stdio, clients, OAuth, and migration +- [Troubleshooting](docs/troubleshooting.md) — common errors and their fixes - [API docs](https://modelcontextprotocol.github.io/typescript-sdk/) - [MCP documentation](https://modelcontextprotocol.io/docs) - [MCP specification](https://modelcontextprotocol.io/specification/latest) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 23129a7356..8f94309f62 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -127,17 +127,6 @@ export default defineConfig({ { text: '2026-07-28 protocol support', link: '/migration/support-2026-07-28' } ] }, - { - text: 'Current guides (being replaced)', - collapsed: true, - items: [ - { text: 'Server quickstart', link: '/server-quickstart' }, - { text: 'Client quickstart', link: '/client-quickstart' }, - { text: 'Server guide', link: '/server' }, - { text: 'Client guide', link: '/client' }, - { text: 'FAQ', link: '/faq' } - ] - }, { text: 'API Reference', collapsed: true, diff --git a/docs/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md index a8f62e61ea..85fbc6ee56 100644 --- a/docs/_meta/CONVENTIONS.md +++ b/docs/_meta/CONVENTIONS.md @@ -98,7 +98,7 @@ SCAFFOLD FORMAT — every non-calibration page is a SKELETON, not prose. Exact s --> ## <Imperative micro-step heading> - <!-- teaches: X | salvage: docs/server.md "<heading>" --> + <!-- teaches: X | salvage: docs/servers/tools.md "<heading>" --> <fenced ts block: the page's ONE defining lead code block, REAL verified API, with a first-line comment: // draft - API verified against packages/<pkg>/src/<file>.ts> <!-- result: one line, what the reader observes --> @@ -141,11 +141,12 @@ const server = new McpServer( //#endregion instructions_basic ``` -Regions normally live inside a wrapper function whose name equals the region name -(see `examples/guides/serverGuide.examples.ts`). The sync script dedents the region -body to the indentation of the `//#region` line, so the indentation inside the wrapper -function is stripped in the rendered fence. Region extraction only works for `.ts` -files. Region names follow `exportedName_variant` (e.g. `registerTool_basic`). +Regions live at the top level of the companion file, or nested inside a helper function +when the snippet needs surrounding setup (see `examples/guides/serving/stdio.examples.ts` +for the former and `examples/guides/clients/machine-auth.examples.ts` for the latter). +The sync script dedents the region body to the indentation of the `//#region` line, so +any wrapper indentation is stripped in the rendered fence. Region extraction only works +for `.ts` files. Region names follow `exportedName_variant` (e.g. `registerTool_search`). ### 2. Fence attribute syntax (in the `.md` page) @@ -158,14 +159,15 @@ Real, working example (this exact fence is live in this file and is verified by top-level page, hence the extra `../` — a page at `docs-v2/<page>.md` uses one `../`): ````md -```ts source="../../examples/guides/serverGuide.examples.ts#instructions_basic" -const server = new McpServer( - { name: 'db-server', version: '1.0.0' }, - { - instructions: - 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' - } -); +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_basic" +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +const handle = serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + // server.registerTool(...) — one factory builds the instance that serves the connection + return server; +}); ``` ```` diff --git a/docs/client-quickstart.md b/docs/client-quickstart.md deleted file mode 100644 index 53bebe5df4..0000000000 --- a/docs/client-quickstart.md +++ /dev/null @@ -1,442 +0,0 @@ ---- -title: Client Quickstart ---- - -# Quickstart: Build an LLM-powered chatbot - -In this tutorial, we'll build an LLM-powered chatbot that connects to an MCP server, discovers its tools, and uses Claude to call them. - -Before you begin, it helps to have gone through the [server quickstart](./server-quickstart.md) so you understand how clients and servers communicate. - -[You can find the complete code for this tutorial here.](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart) - -## Prerequisites - -This quickstart assumes you have familiarity with: - -- TypeScript -- LLMs like Claude - -Before starting, ensure your system meets these requirements: - -- Node.js 20 or higher installed (or **Bun** / **Deno** — the SDK supports all three runtimes) -- Latest version of `npm` installed -- An Anthropic API key from the [Anthropic Console](https://console.anthropic.com/settings/keys) - -> [!TIP] -> This tutorial uses Node.js and npm, but you can substitute `bun` or `deno` commands where appropriate. For example, use `bun add` instead of `npm install`, or run the client with `bun run` / `deno run`. - -## Set up your environment - -First, let's create and set up our project: - -**macOS/Linux:** - -```bash -# Create project directory -mkdir mcp-client -cd mcp-client - -# Initialize npm project -npm init -y - -# Install dependencies -npm install @anthropic-ai/sdk @modelcontextprotocol/client - -# Install dev dependencies -npm install -D @types/node typescript - -# Create source file -mkdir src -touch src/index.ts -``` - -**Windows:** - -```powershell -# Create project directory -md mcp-client -cd mcp-client - -# Initialize npm project -npm init -y - -# Install dependencies -npm install @anthropic-ai/sdk @modelcontextprotocol/client - -# Install dev dependencies -npm install -D @types/node typescript - -# Create source file -md src -new-item src\index.ts -``` - -Update your `package.json` to set `type: "module"` and a build script: - -```json -{ - "type": "module", - "scripts": { - "build": "tsc" - } -} -``` - -Create a `tsconfig.json` in the root of your project: - -```json -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["ESNext"], - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} -``` - -## Creating the client - -### Basic client structure - -First, let's set up our imports and create the basic client class in `src/index.ts`: - -```ts source="../examples/client-quickstart/src/index.ts#prelude" -import Anthropic from '@anthropic-ai/sdk'; -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import readline from 'readline/promises'; - -const ANTHROPIC_MODEL = 'claude-sonnet-4-6'; - -class MCPClient { - private mcp: Client; - private _anthropic: Anthropic | null = null; - private tools: Anthropic.Tool[] = []; - - constructor() { - // Initialize MCP client - this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' }); - } - - private get anthropic(): Anthropic { - // Lazy-initialize Anthropic client when needed - return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); - } -``` - -### Server connection management - -Next, we'll implement the method to connect to an MCP server: - -```ts source="../examples/client-quickstart/src/index.ts#connectToServer" - async connectToServer(serverScriptPath: string) { - try { - // Determine script type and appropriate command - const isJs = serverScriptPath.endsWith('.js'); - const isPy = serverScriptPath.endsWith('.py'); - if (!isJs && !isPy) { - throw new Error('Server script must be a .js or .py file'); - } - const command = isPy - ? (process.platform === 'win32' ? 'python' : 'python3') - : process.execPath; - - // Initialize transport and connect to server - const transport = new StdioClientTransport({ command, args: [serverScriptPath] }); - await this.mcp.connect(transport); - - // List available tools - const toolsResult = await this.mcp.listTools(); - this.tools = toolsResult.tools.map((tool) => ({ - name: tool.name, - description: tool.description ?? '', - input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, - })); - console.log('Connected to server with tools:', this.tools.map(({ name }) => name)); - } catch (e) { - console.log('Failed to connect to MCP server: ', e); - throw e; - } - } -``` - -### Query processing logic - -Now let's add the core functionality for processing queries and handling tool calls: - -```ts source="../examples/client-quickstart/src/index.ts#processQuery" - async processQuery(query: string) { - const messages: Anthropic.MessageParam[] = [ - { - role: 'user', - content: query, - }, - ]; - - // Initial Claude API call - let response = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: 1000, - messages, - tools: this.tools, - }); - - // Process responses, executing tool calls until Claude stops requesting them - const finalText = []; - - while (true) { - const toolUses: Anthropic.ToolUseBlock[] = []; - for (const content of response.content) { - if (content.type === 'text') { - finalText.push(content.text); - } else if (content.type === 'tool_use') { - toolUses.push(content); - } - } - - if (toolUses.length === 0) { - break; - } - - // Execute every requested tool call and collect the results - const toolResults: Anthropic.ToolResultBlockParam[] = []; - for (const toolUse of toolUses) { - const toolArgs = toolUse.input as Record<string, unknown>; - const result = await this.mcp.callTool({ - name: toolUse.name, - arguments: toolArgs, - }); - - finalText.push(`[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`); - - // Extract text from tool result content blocks - const toolResultText = result.content - .filter((block) => block.type === 'text') - .map((block) => block.text) - .join('\n'); - - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: toolResultText, - // Tell Claude when the tool call failed - ...(result.isError ? { is_error: true } : {}), - }); - } - - // Continue the conversation: the assistant turn, then ALL tool - // results together in a single user turn - messages.push({ - role: 'assistant', - content: response.content, - }); - messages.push({ - role: 'user', - content: toolResults, - }); - - // Get next response from Claude - response = await this.anthropic.messages.create({ - model: ANTHROPIC_MODEL, - max_tokens: 1000, - messages, - tools: this.tools, - }); - } - - return finalText.join('\n'); - } -``` - -### Interactive chat interface - -Now we'll add the chat loop and cleanup functionality: - -```ts source="../examples/client-quickstart/src/index.ts#chatLoop" - async chatLoop() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - console.log('\nMCP Client Started!'); - console.log('Type your queries or "quit" to exit.'); - - while (true) { - const message = await rl.question('\nQuery: '); - if (message.toLowerCase() === 'quit') { - break; - } - const response = await this.processQuery(message); - console.log('\n' + response); - } - } finally { - rl.close(); - } - } - - async cleanup() { - await this.mcp.close(); - } -} -``` - -### Main entry point - -Finally, we'll add the main execution logic: - -```ts source="../examples/client-quickstart/src/index.ts#main" -async function main() { - const serverScriptPath = process.argv[2]; - if (!serverScriptPath) { - console.log('Usage: node build/index.js <path_to_server_script>'); - return; - } - const mcpClient = new MCPClient(); - try { - await mcpClient.connectToServer(serverScriptPath); - - // Check if we have a valid API key to continue - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { - console.log( - '\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:' - + '\n export ANTHROPIC_API_KEY=your-api-key-here' - ); - return; - } - - await mcpClient.chatLoop(); - } catch (e) { - console.error('Error:', e); - process.exit(1); - } finally { - await mcpClient.cleanup(); - process.exit(0); - } -} - -main(); -``` - -## Running the client - -To run your client with any MCP server: - -**macOS/Linux:** - -```bash -# Build TypeScript -npm run build - -# Run the client with a Node.js MCP server -ANTHROPIC_API_KEY=your-key-here node build/index.js path/to/server/build/index.js - -# Example: connect to the weather server from the server quickstart -ANTHROPIC_API_KEY=your-key-here node build/index.js /absolute/path/to/weather/build/index.js -``` - -**Windows:** - -```powershell -# Build TypeScript -npm run build - -# Run the client with a Node.js MCP server -$env:ANTHROPIC_API_KEY="your-key-here"; node build/index.js path\to\server\build\index.js -``` - -**The client will:** - -1. Connect to the specified server -2. List available tools -3. Start an interactive chat session where you can: - - Enter queries - - See tool executions - - Get responses from Claude - -## What's happening under the hood - -When you submit a query: - -1. Your query is sent to Claude along with the tool descriptions discovered during connection -2. Claude decides which tools (if any) to use -3. The client executes any requested tool calls through the server -4. Results are sent back to Claude -5. Claude provides a natural language response -6. The response is displayed to you - -> [!NOTE] -> By default, the client uses the legacy 2025-era `initialize` handshake, so it works with any 2025-era server, including the weather server from the server quickstart. To opt into the 2026-07-28 draft revision, see [Protocol version negotiation](./client.md#protocol-version-negotiation-2026-07-28-revision). - -## Troubleshooting - -### Server Path Issues - -- Double-check the path to your server script is correct -- Use the absolute path if the relative path isn't working -- On Windows, both backslashes (`\`) and forward slashes (`/`) work as path separators -- Verify the server file has the correct extension (`.js` for Node.js or `.py` for Python) - -Example of correct path usage: - -**macOS/Linux:** - -```bash -# Relative path -node build/index.js ./server/build/index.js - -# Absolute path -node build/index.js /Users/username/projects/mcp-server/build/index.js -``` - -**Windows:** - -```powershell -# Relative path -node build/index.js .\server\build\index.js - -# Absolute path (either format works) -node build/index.js C:\projects\mcp-server\build\index.js -node build/index.js C:/projects/mcp-server/build/index.js -``` - -### Response Timing - -- The first response might take up to 30 seconds to return -- This is normal and happens while: - - The server initializes - - Claude processes the query - - Tools are being executed -- Subsequent responses are typically faster -- Don't interrupt the process during this initial waiting period - -### Common Error Messages - -If you see: - -- `Error: Cannot find module`: Check your build folder and ensure TypeScript compilation succeeded -- `Error: spawn ... ENOENT` or an immediate exit: check the server script path and that `node` / `python3` can run it (the client spawns the server, so don't start it yourself) -- `Tool execution failed`: Verify the tool's required environment variables are set -- `ANTHROPIC_API_KEY is not set`: Check your environment variables (e.g., `export ANTHROPIC_API_KEY=...`) -- `TypeError`: Ensure you're using the correct types for tool arguments -- `BadRequestError`: Ensure you have enough credits to access the Anthropic API - -## Next steps - -Now that you have a working client, here are some ways to go further: - -- [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client. -- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable client examples. -- [**FAQ**](./faq.md) — Troubleshoot common errors. diff --git a/docs/client.md b/docs/client.md deleted file mode 100644 index 01822e5929..0000000000 --- a/docs/client.md +++ /dev/null @@ -1,1065 +0,0 @@ ---- -title: Client Guide ---- - -# Building MCP clients - -This guide covers the TypeScript SDK APIs for building MCP clients. For protocol-level concepts, see the [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture). - -A client connects to a server, discovers what it offers — tools, resources, prompts — and invokes them. Beyond that core loop, this guide covers authentication, error handling, and responding to server-initiated requests like sampling and elicitation. - -## Imports - -The examples below use these imports. Adjust based on which features and transport you need: - -```ts source="../examples/guides/clientGuide.examples.ts#imports" -import type { - AuthProvider, - CallToolResult, - InputRequiredResult, - OAuthClientInformationContext, - OAuthClientInformationMixed, - OAuthClientMetadata, - OAuthClientProvider, - OAuthDiscoveryState, - OAuthTokens -} from '@modelcontextprotocol/client'; -import { - applyMiddlewares, - checkResourceAllowed, - Client, - ClientCredentialsProvider, - createMiddleware, - CrossAppAccessProvider, - discoverAndRequestJwtAuthGrant, - isInputRequiredResult, - IssuerMismatchError, - LOG_LEVEL_META_KEY, - PrivateKeyJwtProvider, - ProtocolError, - resourceUrlFromServerUrl, - SdkError, - SdkErrorCode, - SdkHttpError, - SSEClientTransport, - StreamableHTTPClientTransport, - TRACEPARENT_META_KEY, - TRACESTATE_META_KEY, - UnauthorizedError -} from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -``` - -## Connecting to a server - -### Streamable HTTP - -For remote HTTP servers, use `StreamableHTTPClientTransport`: - -```ts source="../examples/guides/clientGuide.examples.ts#connect_streamableHttp" -const client = new Client({ name: 'my-client', version: '1.0.0' }); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); - -await client.connect(transport); -``` - -For a full interactive client over Streamable HTTP, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). - -### stdio - -For local, process-spawned servers (Claude Desktop, CLI tools), use `StdioClientTransport`. The transport spawns the server process and communicates over stdin/stdout: - -```ts source="../examples/guides/clientGuide.examples.ts#connect_stdio" -const client = new Client({ name: 'my-client', version: '1.0.0' }); - -const transport = new StdioClientTransport({ - command: 'node', - args: ['server.js'] -}); - -await client.connect(transport); -``` - -### SSE fallback for legacy servers - -To support both modern Streamable HTTP and legacy SSE servers, try `StreamableHTTPClientTransport` first and fall back to `SSEClientTransport` on failure: - -```ts source="../examples/guides/clientGuide.examples.ts#connect_sseFallback" -const baseUrl = new URL(url); - -try { - // Try modern Streamable HTTP transport first - const client = new Client({ name: 'my-client', version: '1.0.0' }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - return { client, transport }; -} catch { - // Fall back to legacy SSE transport - const client = new Client({ name: 'my-client', version: '1.0.0' }); - const transport = new SSEClientTransport(baseUrl); - await client.connect(transport); - return { client, transport }; -} -``` - -The snippet above is the complete pattern; wrap the `catch` body with whatever error reporting your host needs. - -### Protocol version negotiation (2026-07-28 revision) - -By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: - -```ts source="../examples/guides/clientGuide.examples.ts#Client_versionNegotiation" -// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake -// against a 2025-only server. -const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); -await client.connect(transport); - -client.getProtocolEra(); // 'modern' or 'legacy' -client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' -``` - -- **absent / `mode: 'legacy'` (the default)** — today's 2025 connect sequence; no probe, no new headers. -- **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. -- **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). - -Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an -already-constructed instance via `client.setVersionNegotiation()`. See the [2026-07-28 support guide › Probe policy](./migration/support-2026-07-28.md#probe-policy) for the full failure semantics and probe-timeout behavior. -The version lists come from `ClientOptions.supportedProtocolVersions`: under `'auto'`, its 2026-era entries form the modern offer (default: the SDK's modern list), and a list with no 2025-era entry removes the legacy fallback; `connect()` rejects with `SdkError(EraNegotiationFailed)` instead of downgrading. The same modern subset bounds the overlap check of `connect({ prior })`. - -#### Skipping the probe: `connect({ prior })` - -A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained `DiscoverResult` via -`ConnectOptions.prior` and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate. - -```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior" -// Probe once (here via the 'auto'-mode connect), persist the result … -const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); -await bootstrap.connect(new StreamableHTTPClientTransport(url)); -const persisted = JSON.stringify(bootstrap.getDiscoverResult()); - -// … then every worker connects with zero round trips. -const worker = new Client({ name: 'worker', version: '1.0.0' }); -await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); -``` - -`client.getDiscoverResult()` returns the value that the `'auto'`/pinned probe path, an explicit `client.discover()` call, or a -prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted -`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof. - -Unlike an `'auto'`/pinned connect, `connect({ prior })` never auto-opens a `subscriptions/listen` stream. Workers on this path are assumed request-only. A configured `listChanged` option registers its handlers but stays silent. Call [`client.listen(filter)`](#subscription-streams-2026-07-28) yourself if a prior-connected client should observe changes. - -### Disconnecting - -Call `await client.close()` to disconnect. Pending requests are rejected with a `CONNECTION_CLOSED` error. - -For Streamable HTTP, terminate the server-side session first (per the MCP specification): - -```ts source="../examples/guides/clientGuide.examples.ts#disconnect_streamableHttp" -await transport.terminateSession(); // notify the server (recommended) -await client.close(); -``` - -For stdio, `client.close()` handles graceful process shutdown (closes stdin, then SIGTERM, then SIGKILL if needed). - -### Server instructions - -Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP -specification). Retrieve it after connecting and include it in the model's system prompt: - -```ts source="../examples/guides/clientGuide.examples.ts#serverInstructions_basic" -const instructions = client.getInstructions(); - -const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); - -console.log(systemPrompt); -``` - -### Extension capabilities - -The negotiated server capabilities include `extensions` — a map from extension identifier to that extension's settings object. Read it after connecting via `client.getServerCapabilities()`: - -```ts source="../examples/guides/clientGuide.examples.ts#extensionCapabilities_read" -const extensions = client.getServerCapabilities()?.extensions ?? {}; - -if ('com.example/feature-flags' in extensions) { - // Advertised on this connection; the entry's value is its settings object. -} -``` - -See [Extension capabilities](./server.md#extension-capabilities) in the server guide for the declaring side. - -## Authentication - -MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an `AuthProvider` to `StreamableHTTPClientTransport`. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. - -### Bearer tokens - -For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws `UnauthorizedError` immediately: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_tokenProvider" -const authProvider: AuthProvider = { token: async () => getStoredToken() }; - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); -``` - -See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleTokenProvider.ts) for a complete runnable example. - -### Client credentials - -`ClientCredentialsProvider` handles the `client_credentials` grant flow for service-to-service communication: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_clientCredentials" -const authProvider = new ClientCredentialsProvider({ - clientId: 'my-service', - clientSecret: 'my-secret' -}); - -const client = new Client({ name: 'my-client', version: '1.0.0' }); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); - -await client.connect(transport); -``` - -### Private key JWT - -`PrivateKeyJwtProvider` signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_privateKeyJwt" -const authProvider = new PrivateKeyJwtProvider({ - clientId: 'my-service', - privateKey: pemEncodedKey, - algorithm: 'RS256' -}); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); -``` - -For a runnable `client_credentials` example, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts) — its README shows the `private_key_jwt` swap (the in-repo demo Authorization -Server only implements `client_secret_basic`/`client_secret_post`, so there is no runnable `private_key_jwt` leg). - -### Full OAuth with user authorization - -For user-facing applications, implement the `OAuthClientProvider` interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted -client credentials by the `ctx.issuer` passed to `clientInformation()` / `saveClientInformation()` so credentials registered with one authorization server are never sent to another: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_oauthClientProvider" -class MyOAuthProvider implements OAuthClientProvider { - // Key DCR-obtained credentials by issuer so a client_id registered with one - // authorization server is never returned for another (SEP-2352). - private creds = new Map<string, OAuthClientInformationMixed>(); - private storedTokens?: OAuthTokens; - private verifier?: string; - private discovery?: OAuthDiscoveryState; - lastState?: string; - - readonly redirectUrl = 'http://localhost:8090/callback'; - readonly clientMetadata: OAuthClientMetadata = { - client_name: 'My MCP Client', - redirect_uris: ['http://localhost:8090/callback'], - // Loopback redirect → the SDK would default this to 'native'; set - // explicitly when the heuristic is wrong for your deployment (SEP-837). - application_type: 'native' - }; - - clientInformation(ctx?: OAuthClientInformationContext) { - return ctx ? this.creds.get(ctx.issuer) : undefined; - } - saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { - if (ctx) this.creds.set(ctx.issuer, info); - } - tokens() { - return this.storedTokens; - } - saveTokens(tokens: OAuthTokens) { - // In production, persist to OS keychain / secure storage — never plain files. - this.storedTokens = tokens; - } - // CSRF binding for the redirect — the SDK puts this on the authorize URL; - // your callback handler compares it before calling `finishAuth`. - state() { - this.lastState = crypto.randomUUID(); - return this.lastState; - } - // Callback-leg AS-binding (SEP-2352): record what discovery resolved before - // the redirect so the SDK can verify the code is exchanged at the same AS. - saveDiscoveryState(state: OAuthDiscoveryState) { - this.discovery = state; - } - discoveryState() { - return this.discovery; - } - redirectToAuthorization(url: URL) { - onRedirect(url); - } - saveCodeVerifier(v: string) { - this.verifier = v; - } - codeVerifier() { - if (!this.verifier) throw new Error('no code verifier'); - return this.verifier; - } -} - -const provider = new MyOAuthProvider(); -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - authProvider: provider -}); -``` - -The `connect()` call throws `UnauthorizedError` when authorization is needed — catch it, complete the browser flow, hand the callback query -to `transport.finishAuth()`, and reconnect. Passing the whole `URLSearchParams` lets the SDK extract `code` and validate the RFC 9207 `iss` parameter for you: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_finishAuth" -const client = new Client({ name: 'my-client', version: '1.0.0' }); -const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); -try { - await client.connect(transport); - return client; -} catch (error) { - // With version negotiation, the connect-time 401 may surface wrapped as - // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. - const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; - if (!(root instanceof UnauthorizedError)) throw error; - // The transport called redirectToAuthorization(); fall through to the browser callback. -} - -const callbackUrl = await waitForCallback(); -const params = new URL(callbackUrl).searchParams; - -// The SDK does not validate `state` — compare it to the value your provider generated. -if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); - -try { - // Preferred: hand over the whole query — the SDK extracts `code` and - // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived - // `error`/`error_description` text on mismatch. - await transport.finishAuth(params); -} catch (error) { - if (error instanceof IssuerMismatchError) { - // Mix-up attack: do NOT render params.get('error_description') to the user. - throw new Error('Authorization failed: issuer mismatch'); - } - throw error; -} - -// Reconnect on a FRESH transport — a started transport cannot be restarted; -// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. -await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); -return client; -``` - -For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and -[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). - -Issuer validation also runs during discovery: the authorization server metadata's `issuer` must match the issuer identifier the well-known URL was built from (RFC 8414 §3.3), and a mismatch throws `IssuerMismatchError` -with `kind: 'metadata'` (the callback-leg RFC 9207 check above uses `kind: 'authorization_response'`). For authorization servers known to publish a mismatched `issuer`, both HTTP transports accept `skipIssuerMetadataValidation: true` (honoured when `authProvider` is an -`OAuthClientProvider`). This weakens mix-up protection, so leave it off unless you control the server. The migration guide's [Authorization-server mix-up defense](./migration/upgrade-to-v2.md#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33--action-required) section -describes the full model. - -#### Resource indicators (RFC 8707) - -The SDK binds tokens to your MCP server with the RFC 8707 `resource` parameter automatically. When protected resource metadata (RFC 9728) is discovered, the metadata's `resource` value is checked against the server URL (same origin, path prefix; see -`checkResourceAllowed()`) and attached to the authorization redirect and every token request. When the server publishes no resource metadata, no `resource` parameter is sent. - -Implement `validateResourceURL` on your provider to override the selection. Return a URL to force a specific `resource` value, or `undefined` to omit the parameter: - -```ts source="../examples/guides/clientGuide.examples.ts#auth_validateResourceURL" -class PinnedResourceProvider extends MyOAuthProvider { - async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> { - const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) - if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { - throw new Error(`Refusing resource ${resource} for server ${expected.href}`); - } - return expected; - } -} -``` - -`checkResourceAllowed` and `resourceUrlFromServerUrl` are exported from `@modelcontextprotocol/client` for custom implementations. - -### Cross-App Access (Enterprise Managed Authorization) - -`CrossAppAccessProvider` implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access -protected MCP servers on their behalf. - -This provider handles a two-step OAuth flow: - -1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange -2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant - -```ts source="../examples/guides/clientGuide.examples.ts#auth_crossAppAccess" -const authProvider = new CrossAppAccessProvider({ - assertion: async ctx => { - // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn - const result = await discoverAndRequestJwtAuthGrant({ - idpUrl: 'https://idp.example.com', - audience: ctx.authorizationServerUrl, - resource: ctx.resourceUrl, - idToken: await getIdToken(), - clientId: 'my-idp-client', - clientSecret: 'my-idp-secret', - scope: ctx.scope, - fetchFn: ctx.fetchFn - }); - return result.jwtAuthGrant; - }, - clientId: 'my-mcp-client', - clientSecret: 'my-mcp-secret' -}); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); -``` - -The `assertion` callback receives a context object with: - -- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically) -- `resourceUrl` – The MCP resource URL (discovered automatically) -- `scope` – Optional scope passed to `auth()` or from `clientMetadata` -- `fetchFn` – Fetch implementation to use for HTTP requests - -For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`: - -- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP -- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition -- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server - -> [!NOTE] -> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth -> standards. - -## Tools - -Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). - -Use `listTools()` to discover available tools, and `callTool()` to invoke one. `listTools()` walks every page on your behalf and returns -the complete list (pass an explicit `{ cursor }` for per-page control): - -```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" -const { tools } = await client.listTools(); -console.log( - 'Available tools:', - tools.map(t => t.name) -); - -const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 } -}); -console.log(result.content); -``` - -The aggregate walk is capped at `ClientOptions.listMaxPages` pages (default 64; `0` disables the cap). If a server's pagination never terminates, the call rejects with `SdkError` code `LIST_PAGINATION_EXCEEDED`. The same applies to `listPrompts()`, `listResources()`, and `listResourceTemplates()`. - -Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM: - -```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" -const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 } -}); - -// Machine-readable output for the client application. SEP-2106: structuredContent is -// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. -if (result.structuredContent !== undefined) { - const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } - if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { - console.log(sc.bmi); - } -} -``` - -### Tracking progress - -Pass `onprogress` to receive incremental progress notifications from long-running tools. Use `resetTimeoutOnProgress` to keep the request alive while the server is actively reporting, and `maxTotalTimeout` as an absolute cap: - -```ts source="../examples/guides/clientGuide.examples.ts#callTool_progress" -const result = await client.callTool( - { name: 'long-operation', arguments: {} }, - { - onprogress: ({ progress, total }: { progress: number; total?: number }) => { - console.log(`Progress: ${progress}/${total ?? '?'}`); - }, - resetTimeoutOnProgress: true, - maxTotalTimeout: 600_000 - } -); -console.log(result.content); -``` - -### `x-mcp-header` parameter mirroring (2026-07-28 draft) - -On a 2026-07-28 connection over Streamable HTTP, `callTool()` mirrors any argument whose `inputSchema` property carries an `x-mcp-header` annotation into an `Mcp-Param-{Name}` HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers -are built from the client's cached `tools/list` result (see [Response caching](#response-caching-2026-07-28-draft)); if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. -On a cache miss the call is sent without `Mcp-Param-*` headers -and, when a conforming server rejects it with `-32020` (`HeaderMismatch`), `callTool()` refreshes the definition cache once and retries. - -On a non-stdio modern connection `listTools()` (and the internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named -headers cannot be statically allow-listed for credentialed CORS), so calling an `x-mcp-header` tool with a non-null designated argument from a browser against a server that enforces SEP-2243 validation will be rejected — a known limitation. The legacy-era `callTool`/`listTools` -paths are unchanged. - -## Resources - -Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). - -Use `listResources()` and `readResource()` to discover and read server-provided data. `listResources()` walks every page on your -behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): - -```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" -const { resources } = await client.listResources(); -console.log( - 'Available resources:', - resources.map(r => r.name) -); - -const { contents } = await client.readResource({ uri: 'config://app' }); -for (const item of contents) { - console.log(item); -} -``` - -To discover URI templates for dynamic resources, use `listResourceTemplates()`. - -### Subscribing to resource changes - -If the server supports resource subscriptions, use `subscribeResource()` to receive notifications when a resource changes, then re-read it: - -```ts source="../examples/guides/clientGuide.examples.ts#subscribeResource_basic" -await client.subscribeResource({ uri: 'config://app' }); - -client.setNotificationHandler('notifications/resources/updated', async notification => { - if (notification.params.uri === 'config://app') { - const { contents } = await client.readResource({ uri: 'config://app' }); - console.log('Config updated:', contents); - } -}); - -// Later: stop receiving updates -await client.unsubscribeResource({ uri: 'config://app' }); -``` - -> [!NOTE] -> `resources/subscribe` is a 2025-era method. On a 2026-07-28 connection, `subscribeResource()` throws a typed `SdkError` (`MethodNotSupportedByProtocolVersion`); request per-resource updates through the `resourceSubscriptions` field of a -> [subscription stream](#subscription-streams-2026-07-28) instead. The `notifications/resources/updated` handler is identical on both paths. - -## Prompts - -Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). - -Use `listPrompts()` and `getPrompt()` to list available prompts and retrieve them with arguments. `listPrompts()` walks every page on -your behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): - -```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" -const { prompts } = await client.listPrompts(); -console.log( - 'Available prompts:', - prompts.map(p => p.name) -); - -const { messages } = await client.getPrompt({ - name: 'review-code', - arguments: { code: 'console.log("hello")' } -}); -console.log(messages); -``` - -## Completions - -Both prompts and resources can support argument completions. Use `complete()` to request autocompletion suggestions from the server as a user types: - -```ts source="../examples/guides/clientGuide.examples.ts#complete_basic" -const { completion } = await client.complete({ - ref: { - type: 'ref/prompt', - name: 'review-code' - }, - argument: { - name: 'language', - value: 'type' - } -}); -console.log(completion.values); // e.g. ['typescript'] -``` - -## Response caching (2026-07-28 draft) - -On a 2026-07-28 connection, the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) carry `ttlMs` / `cacheScope` freshness hints (SEP-2549). The client honours them automatically: `listTools()`, -`listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` serve a still-fresh cached result without a round trip. `ttlMs` is capped at 24 hours (`MAX_CACHE_TTL_MS`); a missing or zero `ttlMs` means the result is never -served from cache, so against servers that don't send hints (including all 2025-era servers), nothing changes. - -Override the disposition per call with `cacheMode`: - -```ts source="../examples/guides/clientGuide.examples.ts#responseCache_basic" -const tools = await client.listTools(); // network, then cached for the server's ttlMs -const again = await client.listTools(); // served from cache while still fresh - -await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store -await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write -``` - -`'bypass'` leaves the cache byte-untouched, including the internal `tools/list` entry that [`x-mcp-header` parameter mirroring](#x-mcp-header-parameter-mirroring-2026-07-28-draft) and output-schema validation read. Cached entries are evicted automatically when the server -signals a change: a `list_changed` notification drops the matching list entries, and `notifications/resources/updated` drops the cached body for that URI (see [Notifications](#notifications)). - -Three `ClientOptions` fields tune the behavior: - -- **`responseCacheStore`**: the backing store; defaults to a per-client `InMemoryResponseCacheStore` (at most 512 `resources/read` entries by default). Supply your own `ResponseCacheStore` implementation (the interface is async-ready, so a - Redis-style store fits) to persist entries or share one store across clients. Entries are keyed by connected-server identity, so co-tenants never collide. -- **`cachePartition`**: opaque per-principal identifier (e.g. the auth subject) isolating `'private'`-scoped entries when one store serves several principals. `'public'`-scoped entries are shared within a server's namespace; `'private'` ones never cross partitions. -- **`defaultCacheTtlMs`**: TTL applied when a result arrives without `ttlMs` (any legacy-era response, for example). The default `0` means such results are never served from cache; list results are still stored (already stale) so the `tools/list`-derived index behind - mirroring and output validation keeps working, while `resources/read` bodies with a resolved TTL of `0` are not stored at all. Raise it to enable TTL caching against servers that don't send hints. - -> [!IMPORTANT] -> When one `responseCacheStore` is shared across users, always set `cachePartition` per principal. Without it, one user's `'private'`-scoped resource bodies can be served to another. - -## Notifications - -### Automatic list-change tracking - -The `listChanged` client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and -error-first callbacks: - -```ts source="../examples/guides/clientGuide.examples.ts#listChanged_basic" -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - listChanged: { - tools: { - onChanged: (error, tools) => { - if (error) { - console.error('Failed to refresh tools:', error); - return; - } - console.log('Tools updated:', tools); - } - }, - prompts: { - onChanged: (error, prompts) => console.log('Prompts updated:', prompts) - } - } - } -); -``` - -`listChanged` is era-transparent: on a 2025-era connection it is fed by unsolicited notifications; on a 2026-07-28 connection the SDK [auto-opens a subscription stream](#subscription-streams-2026-07-28) for the configured types. - -### Manual notification handlers - -For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with `setNotificationHandler()`: - -```ts source="../examples/guides/clientGuide.examples.ts#notificationHandler_basic" -// Server log messages (sent by the server during request processing) -client.setNotificationHandler('notifications/message', notification => { - const { level, data } = notification.params; - console.log(`[${level}]`, data); -}); - -// Server's resource list changed — re-fetch the list -client.setNotificationHandler('notifications/resources/list_changed', async () => { - const { resources } = await client.listResources(); - console.log('Resources changed:', resources.length); -}); -``` - -> [!WARNING] -> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). It remains fully functional on -> 2025-era connections during the deprecation window (at least twelve months); on the 2026-07-28 revision the log level travels per request instead (see below). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. - -To control the minimum severity of log messages the server sends, use `setLoggingLevel()`: - -```ts source="../examples/guides/clientGuide.examples.ts#setLoggingLevel_basic" -await client.setLoggingLevel('warning'); -``` - -`logging/setLevel` is not part of the 2026-07-28 revision, so on a connection that negotiated a modern era (see [Protocol version negotiation](#protocol-version-negotiation-2026-07-28-revision)) `setLoggingLevel()` rejects with `SdkError(MethodNotSupportedByProtocolVersion)`. On 2026-07-28 connections the level is declared **per request** instead: set the `io.modelcontextprotocol/logLevel` `_meta` key (exported as `LOG_LEVEL_META_KEY`) on each request you want logs for. When the key is absent, the server sends no `notifications/message` for that request; the client never attaches it automatically. - -```ts source="../examples/guides/clientGuide.examples.ts#logLevelMeta_modern" -const result = await client.callTool({ - name: 'fetch-data', - arguments: { url: 'https://example.com' }, - _meta: { [LOG_LEVEL_META_KEY]: 'debug' } -}); -``` - -Messages arrive through the same `notifications/message` handler shown above. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#ctxmcpreqlog-and-the-per-request-loglevel) for the server-side semantics. - -> [!WARNING] -> `listChanged` and `setNotificationHandler()` resolve per notification type by last registration wins: `listChanged` installs its handler during `connect()`, so a manual handler registered -> after connecting silently disables `listChanged` for that type, and one registered before connecting is overwritten by it. - -### Subscription streams (2026-07-28) - -On a 2026-07-28 connection the server delivers change notifications only on a `subscriptions/listen` stream the client opens: nothing arrives unsolicited. The `listChanged` option handles this transparently: on a modern connection it auto-opens a stream whose filter is the -intersection of the configured sub-options and the server's advertised capabilities (the handle is exposed as `autoOpenedSubscription`). To open a stream explicitly, use `listen()`: - -```ts source="../examples/guides/clientGuide.examples.ts#listen_basic" -client.setNotificationHandler('notifications/tools/list_changed', async () => { - const { tools } = await client.listTools(); - console.log('Tools changed:', tools.length); -}); -client.setNotificationHandler('notifications/resources/updated', async notification => { - console.log('Resource updated:', notification.params.uri); -}); - -const subscription = await client.listen({ - toolsListChanged: true, - resourceSubscriptions: ['config://app'] -}); -console.log('Server honored:', subscription.honoredFilter); - -// Later: tear the stream down -await subscription.close(); -``` - -`listen()` resolves once the server acknowledges the subscription. `honoredFilter` is the capability-gated subset the server agreed to deliver (e.g. `resourceSubscriptions` requires the server to advertise `resources: { subscribe: true }`). Notifications on the stream -dispatch to the same `setNotificationHandler` registrations as 2025-era unsolicited notifications. - -There is no automatic re-listen. `subscription.closed` resolves exactly once (it never rejects) with the reason: `'local'` (you called `close()`), `'graceful'` (the server ended the subscription deliberately, e.g. on shutdown), or `'remote'` (unexpected disconnect). A watch -loop re-listens on unexpected closes: - -```ts source="../examples/guides/clientGuide.examples.ts#listen_watchLoop" -while (watching) { - const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); - const reason = await sub.closed; - if (reason !== 'remote') break; // 'local' or 'graceful': done - await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen -} -``` - -On a 2025-era connection `listen()` throws a typed error steering to [`subscribeResource()`](#subscribing-to-resource-changes) and `listChanged`. See the -[2026-07-28 support guide › `subscriptions/listen`](./migration/support-2026-07-28.md#subscriptionslisten) for migration-level detail, and [`subscriptions/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/client.ts) for a -runnable example of both watch styles. - -## Handling server-initiated requests - -MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability -when constructing the `Client` and register a request handler: - -```ts source="../examples/guides/clientGuide.examples.ts#capabilities_declaration" -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - capabilities: { - sampling: {}, - elicitation: { form: {} }, - roots: { listChanged: true } - } - } -); -``` - -On 2025-era connections these arrive as server→client JSON-RPC requests. On a 2026-07-28 connection there is no server→client request channel: the server answers `tools/call` / `prompts/get` / `resources/read` with an `input_required` result instead, and the client fulfils -the embedded requests automatically through the same handlers you register below, then retries the call with the collected responses and a byte-exact echo of the server's opaque `requestState`. `callTool()` and its siblings keep returning their plain result: the interactive -rounds happen inside the call, capped at `maxRounds` (default 10), after which the call rejects with a typed `INPUT_REQUIRED_ROUNDS_EXCEEDED` error. Configure or disable this via -`ClientOptions.inputRequired` (`{ autoFulfill?: boolean; maxRounds?: number }`); see [Manual multi-round-trip handling](#manual-multi-round-trip-handling-2026-07-28) for the opt-out flow. Handlers are era-transparent: register once for both delivery paths. - -### Sampling - -> [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers -> should migrate to calling LLM provider APIs directly. - -When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: - -```ts source="../examples/guides/clientGuide.examples.ts#sampling_handler" -client.setRequestHandler('sampling/createMessage', async request => { - const lastMessage = request.params.messages.at(-1); - console.log('Sampling request:', lastMessage); - - // In production, send messages to your LLM here - return { - model: 'my-model', - role: 'assistant' as const, - content: { - type: 'text' as const, - text: 'Response from the model' - } - }; -}); -``` - -### Elicitation - -When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return -the collected data, or `{ action: 'decline' }`: - -```ts source="../examples/guides/clientGuide.examples.ts#elicitation_handler" -client.setRequestHandler('elicitation/create', async request => { - console.log('Server asks:', request.params.message); - - if (request.params.mode === 'form') { - // Present the schema-driven form to the user - console.log('Schema:', request.params.requestedSchema); - return { action: 'accept', content: { confirm: true } }; - } - - return { action: 'decline' }; -}); -``` - -For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode (both the 2025-era push/throw style and the 2026-07-28 `inputRequired` -return), see [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts). - -### Roots - -> [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to -> passing paths via tool parameters, resource URIs, or configuration. - -Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: - -```ts source="../examples/guides/clientGuide.examples.ts#roots_handler" -client.setRequestHandler('roots/list', async () => { - return { - roots: [ - { uri: 'file:///home/user/projects/my-app', name: 'My App' }, - { uri: 'file:///home/user/data', name: 'Data' } - ] - }; -}); -``` - -When the available roots change, notify the server with `client.sendRootsListChanged()`. - -### Manual multi-round-trip handling (2026-07-28) - -Hosts that surface input requests through their own UI loop can take over the rounds themselves. Set `inputRequired: { autoFulfill: false }`. An `input_required` response then surfaces as a typed error unless the call passes `allowInputRequired: true` to receive the raw -result. Retry with top-level `inputResponses` and a byte-exact `requestState` echo: - -```ts source="../examples/guides/clientGuide.examples.ts#inputRequired_manual" -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - capabilities: { elicitation: { form: {} } }, - versionNegotiation: { mode: 'auto' }, - inputRequired: { autoFulfill: false } - } -); -await client.connect(transport); - -const value = (await client.request( - { method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } }, - { allowInputRequired: true } -)) as CallToolResult | InputRequiredResult; - -if (isInputRequiredResult(value)) { - // Collect responses for value.inputRequests from your UI, then retry: - await client.request( - { - method: 'tools/call', - params: { - name: 'deploy', - arguments: { env: 'prod' }, - inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, - requestState: value.requestState // echo byte-exact - } - }, - { allowInputRequired: true } - ); -} -``` - -The manual retry goes through `client.request()` rather than `callTool()`: `inputResponses` and `requestState` are not fields of the typed `CallToolRequest` params. On the explicit-schema `request()` path, wrap the result schema with `withInputRequired()` so both outcomes are typed and validated. For the full loop (including URL-mode elicitation), see [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts). - -## Error handling - -### Tool errors vs protocol errors - -`callTool()` has two error surfaces: the tool can _run but report failure_ via `isError: true` in the result, or the _request itself can fail_ and throw an exception. Always check both: - -```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_toolErrors" -try { - const result = await client.callTool({ - name: 'fetch-data', - arguments: { url: 'https://example.com' } - }); - - // Tool-level error: the tool ran but reported a problem - if (result.isError) { - console.error('Tool error:', result.content); - return; - } - - console.log('Success:', result.content); -} catch (error) { - // Protocol-level error: the request itself failed - if (error instanceof ProtocolError) { - console.error(`Protocol error ${error.code}: ${error.message}`); - } else if (error instanceof SdkError) { - console.error(`SDK error [${error.code}]: ${error.message}`); - } else { - throw error; - } -} -``` - -`ProtocolError` represents JSON-RPC errors from the server (method not found, invalid params, internal error). `SdkError` represents local SDK errors — `REQUEST_TIMEOUT`, `CONNECTION_CLOSED`, `CAPABILITY_NOT_SUPPORTED`, and others. The `SdkErrorCode` enum is the complete vocabulary; the [error mapping table](./migration/upgrade-to-v2.md#sdkerrorcode-enum-complete) in the upgrade guide describes when each -code is raised. - -### Connection lifecycle - -Set `client.onerror` to catch out-of-band transport errors (SSE disconnects, parse errors). Set `client.onclose` to detect when the -connection drops — pending requests are rejected with a `CONNECTION_CLOSED` error: - -```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_lifecycle" -// Out-of-band errors (SSE disconnects, parse errors) -client.onerror = error => { - console.error('Transport error:', error.message); -}; - -// Connection closed (pending requests are rejected with CONNECTION_CLOSED) -client.onclose = () => { - console.log('Connection closed'); -}; -``` - -### Timeouts - -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server (on a 2026-07-28 Streamable HTTP connection the per-request stream is aborted instead, which is the -spec's cancellation signal) and rejects the promise with `SdkErrorCode.RequestTimeout`: - -```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" -try { - const result = await client.callTool( - { name: 'slow-operation', arguments: {} }, - { timeout: 120_000 } // 2 minutes instead of the default 60 seconds - ); - console.log(result.content); -} catch (error) { - if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { - console.error('Request timed out'); - } -} -``` - -### HTTP transport errors - -When an HTTP transport request fails with a non-OK status, the SDK throws `SdkHttpError`, an `SdkError` subclass with typed `data` (`{ status, statusText? }`) and `status`/`statusText` getters, so you can branch on the status without casting. The codes are the `ClientHttp*` members of `SdkErrorCode`: e.g. `CLIENT_HTTP_AUTHENTICATION` (a 401 persisting after re-authentication), `CLIENT_HTTP_FORBIDDEN` (a 403 `insufficient_scope` after the step-up -retry cap), `CLIENT_HTTP_FAILED_TO_OPEN_STREAM`. (Exception: an unexpected response content type throws a plain `SdkError` with code `CLIENT_HTTP_UNEXPECTED_CONTENT`.) - -```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_http" -try { - await client.connect(transport); -} catch (error) { - if (error instanceof SdkHttpError) { - console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`); - } else { - throw error; - } -} -``` - -## Client middleware - -Use `createMiddleware()` and `applyMiddlewares()` to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` -call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: - -```ts source="../examples/guides/clientGuide.examples.ts#middleware_basic" -const authMiddleware = createMiddleware(async (next, input, init) => { - const headers = new Headers(init?.headers); - headers.set('X-Custom-Header', 'my-value'); - return next(input, { ...init, headers }); -}); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - fetch: applyMiddlewares(authMiddleware)(fetch) -}); -``` - -## Trace context propagation - -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When -present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry -context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. - -Attach trace context to a single request via `_meta`: - -```ts source="../examples/guides/clientGuide.examples.ts#traceContext_perRequest" -// Values would normally come from your tracer's active span context. -const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 }, - _meta: { - [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', - [TRACESTATE_META_KEY]: 'vendor1=opaqueValue1' - } -}); -console.log(result.content); -``` - -Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport): - -```ts source="../examples/guides/clientGuide.examples.ts#traceContext_middleware" -const traceContextMiddleware = createMiddleware(async (next, input, init) => { - if (typeof init?.body !== 'string') { - return next(input, init); - } - const message = JSON.parse(init.body) as { - method?: string; - params?: { _meta?: Record<string, unknown>; [key: string]: unknown }; - }; - // Only requests and notifications carry params._meta; skip responses. - if (message.method === undefined) { - return next(input, init); - } - message.params = { - ...message.params, - _meta: { - ...message.params?._meta, - // Replace with values from your tracer's active span context. - [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' - } - }; - return next(input, { ...init, body: JSON.stringify(message) }); -}); - -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - fetch: applyMiddlewares(traceContextMiddleware)(fetch) -}); -``` - -On the server side, handlers can read the incoming trace context from `ctx.mcpReq._meta` — see the [server guide](./server.md#trace-context-propagation). - -## Resumption tokens - -When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: - -```ts source="../examples/guides/clientGuide.examples.ts#resumptionToken_basic" -let lastToken: string | undefined; - -const result = await client.request( - { - method: 'tools/call', - params: { name: 'long-running-operation', arguments: {} } - }, - { - resumptionToken: lastToken, - onresumptiontoken: (token: string) => { - lastToken = token; - // Persist token to survive restarts - } - } -); -console.log(result); -``` - -For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). - -## See also - -- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable client examples -- [Server guide](./server.md) — Building MCP servers with this SDK -- [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives -- [Migration guide](./migration/index.md) — Upgrading from previous SDK versions -- [FAQ](./faq.md) — Frequently asked questions and troubleshooting - -### Additional examples - -| Feature | Description | Example | -| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | -| Subscription streams | Auto-opened and manual `subscriptions/listen` streams (2026-07-28) | [`subscriptions/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/client.ts) | -| Multi-round-trip input | Auto-fulfilled and manual `input_required` flows (2026-07-28) | [`mrtr/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/client.ts) | diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 67ca8c171a..0000000000 --- a/docs/faq.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: FAQ ---- - -# FAQ - -<details> -<summary>Table of Contents</summary> - -- [General](#general) -- [Clients](#clients) -- [Servers](#servers) -- [v1 (legacy)](#v1-legacy) - -</details> - -## General - -### Why do I see `TS2589: Type instantiation is excessively deep and possibly infinite` after upgrading the SDK? - -This TypeScript error can appear when upgrading to newer SDK versions that support Zod v4 (for example, from older `@modelcontextprotocol/sdk` releases to newer `@modelcontextprotocol/client` / `@modelcontextprotocol/server` releases) **and** your project ends up with multiple -`zod` versions in the dependency tree. - -When there are multiple copies or versions of `zod`, TypeScript may try to instantiate very complex, cross-version types and hit its recursion limits, resulting in `TS2589`. This scenario is discussed in GitHub issue -[#1180](https://github.com/modelcontextprotocol/typescript-sdk/issues/1180#event-21236550401). - -To diagnose and fix this: - -- **Inspect your installed `zod` versions**: - - Run `npm ls zod` or `npm explain zod`, `pnpm list zod` or `pnpm why zod`, or `yarn why zod` and check whether more than one version is installed. -- **Align on a single `zod` version**: - - Make sure all packages that depend on `zod` use a compatible version range so that your package manager can hoist a single copy. - - In monorepos, consider declaring `zod` at the workspace root and using compatible ranges in individual packages. -- **Use overrides/resolutions if necessary**: - - With npm, Yarn, or pnpm, you can use `overrides` / `resolutions` to force a single `zod` version if some transitive dependencies pull in a different one. - -Once your project is using a single, compatible `zod` version, the `TS2589` error should no longer occur. - -## Clients - -### How do I enable Web Crypto (`globalThis.crypto`) for client authentication in older Node.js versions? - -The SDK’s OAuth client authentication helpers (for example, those in `packages/client/src/client/authExtensions.ts` that use `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. This is especially important for **client credentials** and **JWT-based** -authentication flows used by MCP clients. - -- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. -- **Node.js v18.x**: `globalThis.crypto` may not be defined by default. In this repository we polyfill it for tests (see `packages/client/vitest.setup.js`), and you should do the same in your app if it is missing – or alternatively, run Node with `--experimental-global-webcrypto` - as per your Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) - -If you run clients on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.js`: - -```typescript -import { webcrypto } from 'node:crypto'; - -if (typeof globalThis.crypto === 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).crypto = webcrypto as unknown as Crypto; -} -``` - -For production use, you can either: - -- Run clients on a Node.js version where `globalThis.crypto` is available by default (recommended), or -- Apply a similar polyfill early in your client's startup code when targeting older Node.js runtimes, so that OAuth client authentication works reliably. - -## Servers - -### Where can I find runnable server examples? - -The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md). - -### Where are the server auth helpers? - -Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). New code should use a dedicated IdP/OAuth library for the AS. Example packages provide a demo with `better-auth`. - -### Why did we remove `server` SSE transport? - -The SSE transport has been deprecated for a long time, and `v2` will not support it on the server side any more. Client side will keep supporting it in order to be able to connect to legacy SSE servers via the `v2` SDK, but serving SSE from `v2` will not be possible. Servers -wanting to switch to `v2` and using SSE should migrate to Streamable HTTP. A frozen v1 copy of the server SSE transport remains available as `@modelcontextprotocol/server-legacy/sse` (deprecated). - -## v1 (legacy) - -### Where do v1 documentation and v1-specific fixes live? - -The v1 API documentation is available at [`https://ts.sdk.modelcontextprotocol.io/`](https://ts.sdk.modelcontextprotocol.io/). The v1 source code and any v1-specific fixes live on the long-lived [`v1.x` branch](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x). V2 API docs are at [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). diff --git a/docs/migration/index.md b/docs/migration/index.md index 89eb5d0a07..18daf3c43a 100644 --- a/docs/migration/index.md +++ b/docs/migration/index.md @@ -50,5 +50,5 @@ the codemod first; the guide is the codemod's companion for what's left. ## See also - [`@modelcontextprotocol/codemod` README](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/README.md) -- [FAQ](../faq.md) +- [Troubleshooting](../troubleshooting.md) - [Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 57b9ff2d14..4b83844ba0 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -38,14 +38,14 @@ below. ## Serving the 2026-07-28 revision -These entry points are documented in full in the server and client guides; this section -contextualizes them as the migration path. +These entry points are documented in full in [Protocol versions](../protocol-versions.md); +this section contextualizes them as the migration path. ### Client side: `versionNegotiation` By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — -see [client.md › Protocol version negotiation](../client.md#protocol-version-negotiation-2026-07-28-revision). +see [Negotiate the era from the client](../protocol-versions.md#negotiate-the-era-from-the-client). ```typescript const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -166,7 +166,7 @@ on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the connection-pinned `serveStdio(() => buildServer())` entry from `@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, and one factory instance is pinned per connection. See -[server.md › Serving the 2026-07-28 draft revision on stdio](../server.md#serving-the-2026-07-28-draft-revision-on-stdio). +[Serve over stdio](../serving/stdio.md). To migrate an existing stdio server, replace `await server.connect(new StdioServerTransport())` with diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 0924aef07d..4eb7aa7e83 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1809,7 +1809,7 @@ where an entry notes its own signature change: - The codemod's [`@mcp-codemod-error`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/README.md) markers point at every site it could not safely rewrite. -- The [FAQ](../faq.md) covers common v2 questions. +- The [Troubleshooting](../troubleshooting.md) page covers common errors and their fixes. - Runnable [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) for every subsystem. - Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues). diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md deleted file mode 100644 index 49511da872..0000000000 --- a/docs/server-quickstart.md +++ /dev/null @@ -1,474 +0,0 @@ ---- -title: Server Quickstart ---- - -# Quickstart: Build a weather server - -In this tutorial, we'll build a simple MCP weather server and connect it to a host. - -## What we'll be building - -We'll build a server that exposes two tools: `get-alerts` and `get-forecast`. Then we'll connect the server to an MCP host (in this case, VS Code with GitHub Copilot). - -## Core MCP Concepts - -MCP servers can provide three main types of capabilities: - -1. **[Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)**: File-like data that can be read by clients (like API responses or file contents) -2. **[Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools)**: Functions that can be called by the LLM (with user approval) -3. **[Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts)**: Pre-written templates that help users accomplish specific tasks - -This tutorial will primarily focus on tools. - -Let's get started with building our weather server! [You can find the complete code for what we'll be building here.](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart) - -## Prerequisites - -This quickstart assumes you have familiarity with: - -- TypeScript -- LLMs like Claude - -Make sure you have Node.js version 20 or higher installed. You can verify your installation: - -```bash -node --version -npm --version -``` - -> [!TIP] -> The MCP SDK also works with **Bun** and **Deno**. This tutorial uses Node.js, but you can substitute `bun` or `deno` commands where appropriate. For HTTP-based servers on Bun or Deno, use `WebStandardStreamableHTTPServerTransport` instead of the Node.js-specific transport — see the [server guide](./server.md) for details. - -## Set up your environment - -First, let's install Node.js and npm if you haven't already. You can download them from [nodejs.org](https://nodejs.org/). - -Now, let's create and set up our project: - -**macOS/Linux:** - -```bash -# Create a new directory for our project -mkdir weather -cd weather - -# Initialize a new npm project -npm init -y - -# Install dependencies -npm install @modelcontextprotocol/server zod -npm install -D @types/node typescript - -# Create our files -mkdir src -touch src/index.ts -``` - -**Windows:** - -```powershell -# Create a new directory for our project -md weather -cd weather - -# Initialize a new npm project -npm init -y - -# Install dependencies -npm install @modelcontextprotocol/server zod -npm install -D @types/node typescript - -# Create our files -md src -new-item src\index.ts -``` - -Update your `package.json` to add `type: "module"` and a build script: - -```json -{ - "type": "module", - "scripts": { - "build": "tsc" - } -} -``` - -Create a `tsconfig.json` in the root of your project: - -```json -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["ESNext"], - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} -``` - -Now let's dive into building your server. - -## Building your server - -### Importing packages and setting up the instance - -Add these to the top of your `src/index.ts`: - -```ts source="../examples/server-quickstart/src/index.ts#prelude" -import { McpServer } from '@modelcontextprotocol/server'; -import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const NWS_API_BASE = 'https://api.weather.gov'; -const USER_AGENT = 'weather-app/1.0'; -``` - -### Helper functions - -Next, let's add our helper functions for querying and formatting the data from the National Weather Service API: - -```ts source="../examples/server-quickstart/src/index.ts#helpers" -// Helper function for making NWS API requests -async function makeNWSRequest<T>(url: string): Promise<T | null> { - const headers = { - 'User-Agent': USER_AGENT, - Accept: 'application/geo+json', - }; - - try { - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return (await response.json()) as T; - } catch (error) { - console.error('Error making NWS request:', error); - return null; - } -} - -interface AlertFeature { - properties: { - event?: string; - areaDesc?: string; - severity?: string; - status?: string; - headline?: string; - }; -} - -// Format alert data -function formatAlert(feature: AlertFeature): string { - const props = feature.properties; - return [ - `Event: ${props.event || 'Unknown'}`, - `Area: ${props.areaDesc || 'Unknown'}`, - `Severity: ${props.severity || 'Unknown'}`, - `Status: ${props.status || 'Unknown'}`, - `Headline: ${props.headline || 'No headline'}`, - '---', - ].join('\n'); -} - -interface ForecastPeriod { - name?: string; - temperature?: number; - temperatureUnit?: string; - windSpeed?: string; - windDirection?: string; - shortForecast?: string; -} - -interface AlertsResponse { - features: AlertFeature[]; -} - -interface PointsResponse { - properties: { - forecast?: string; - }; -} - -interface ForecastResponse { - properties: { - periods: ForecastPeriod[]; - }; -} -``` - -### Registering tools - -Each tool is registered with `server.registerTool()`, which takes the tool name, a configuration object (with description and input schema), and a callback that implements the tool logic. Create the server inside a `createServer()` factory and register both weather tools on it. The serving entry in the next step builds the instance it serves by calling this factory, so keep it cheap and side-effect-free: - -```ts source="../examples/server-quickstart/src/index.ts#registerTools" -// Create a server with both weather tools registered. The serving entry calls -// this factory to build the instance it serves, so keep it cheap and -// side-effect-free. -function createServer(): McpServer { - const server = new McpServer({ - name: 'weather', - version: '1.0.0', - }); - - server.registerTool( - 'get-alerts', - { - title: 'Get Weather Alerts', - description: 'Get weather alerts for a state', - inputSchema: z.object({ - state: z.string().length(2) - .describe('Two-letter state code (e.g. CA, NY)'), - }), - }, - async ({ state }) => { - const stateCode = state.toUpperCase(); - const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; - const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl); - - if (!alertsData) { - return { - content: [{ - type: 'text', - text: 'Failed to retrieve alerts data', - }], - isError: true, - }; - } - - const features = alertsData.features || []; - - if (features.length === 0) { - return { - content: [{ - type: 'text', - text: `No active alerts for ${stateCode}`, - }], - }; - } - - const formattedAlerts = features.map(formatAlert); - - return { - content: [{ - type: 'text', - text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, - }], - }; - }, - ); - - server.registerTool( - 'get-forecast', - { - title: 'Get Weather Forecast', - description: 'Get weather forecast for a location', - inputSchema: z.object({ - latitude: z.number().min(-90).max(90) - .describe('Latitude of the location'), - longitude: z.number().min(-180).max(180) - .describe('Longitude of the location'), - }), - }, - async ({ latitude, longitude }) => { - // Get grid point data - const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; - const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl); - - if (!pointsData) { - return { - content: [{ - type: 'text', - text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, - }], - isError: true, - }; - } - - const forecastUrl = pointsData.properties?.forecast; - if (!forecastUrl) { - return { - content: [{ - type: 'text', - text: 'Failed to get forecast URL from grid point data', - }], - isError: true, - }; - } - - // Get forecast data - const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl); - if (!forecastData) { - return { - content: [{ - type: 'text', - text: 'Failed to retrieve forecast data', - }], - isError: true, - }; - } - - const periods = forecastData.properties?.periods || []; - if (periods.length === 0) { - return { - content: [{ - type: 'text', - text: 'No forecast periods available', - }], - }; - } - - // Format forecast periods - const formattedForecast = periods.map((period: ForecastPeriod) => - [ - `${period.name || 'Unknown'}:`, - `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, - `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, - `${period.shortForecast || 'No forecast available'}`, - '---', - ].join('\n'), - ); - - return { - content: [{ - type: 'text', - text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, - }], - }; - }, - ); - - return server; -} -``` - -### Running the server - -Finally, serve the factory on stdio with `serveStdio`. The entry owns the transport and negotiates the protocol revision with each client, so the same factory serves current hosts (such as VS Code) and clients that speak the 2026-07-28 draft revision; see [Serving the 2026-07-28 draft revision on stdio](./server.md#serving-the-2026-07-28-draft-revision-on-stdio) in the server guide for the options: - -```ts source="../examples/server-quickstart/src/index.ts#main" -void serveStdio(createServer); -console.error('Weather MCP Server running on stdio'); -``` - -> [!IMPORTANT] -> Always use `console.error()` instead of `console.log()` in stdio-based MCP servers. Standard output is reserved for JSON-RPC protocol messages, and writing to it with `console.log()` will corrupt the communication channel. - -Make sure to run `npm run build` to build your server! This is a very important step in getting your server to connect. - -Let's now test your server from an existing MCP host. - -## Testing your server in VS Code - -[VS Code](https://code.visualstudio.com/) with [GitHub Copilot](https://github.com/features/copilot) can discover and invoke MCP tools via agent mode. [Copilot Free](https://github.com/features/copilot/plans) is sufficient to follow along. - -> [!NOTE] -> Servers can connect to any client. We've chosen VS Code here for simplicity, but we also have a guide on [building your own client](./client-quickstart.md) as well as a [list of other clients here](https://modelcontextprotocol.io/clients). - -### Prerequisites - -1. Install [VS Code](https://code.visualstudio.com/) (version 1.99 or later). -2. Install the **GitHub Copilot** extension from the VS Code Extensions marketplace. -3. Sign in to your GitHub account when prompted. - -### Configure the MCP server - -Create a `.vscode/mcp.json` file in your `weather` project root: - -```json -{ - "servers": { - "weather": { - "type": "stdio", - "command": "node", - "args": ["./build/index.js"] - } - } -} -``` - -VS Code may prompt you to trust the MCP server when it detects this file. If prompted, confirm to start the server. - -To verify, run **MCP: List Servers** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`). The `weather` server should show a running status. - -### Use the tools - -1. Open **Copilot Chat** (`Ctrl+Alt+I` / `Ctrl+Cmd+I`). -2. Select **Agent** mode from the mode selector at the top of the chat panel. -3. Click the **Tools** button to confirm `get-alerts` and `get-forecast` appear. -4. Try these prompts: - - "What's the weather in Sacramento?" - - "What are the active weather alerts in Texas?" - -> [!NOTE] -> Since this is the US National Weather Service, the queries will only work for US locations. - -## What's happening under the hood - -When you ask a question: - -1. The client sends your question to the LLM -2. The LLM analyzes the available tools and decides which one(s) to use -3. The client executes the chosen tool(s) through the MCP server -4. The results are sent back to the LLM -5. The LLM formulates a natural language response -6. The response is displayed to you - -## Troubleshooting - -<details> -<summary>VS Code integration issues</summary> - -**Server not appearing or fails to start** - -1. Verify you have VS Code 1.99 or later (`Help > About`) and that GitHub Copilot is installed. -2. Verify the server builds without errors: run `npm run build` in the `weather` directory. -3. Test it manually: run `node build/index.js` — the process should start and wait for input. Press `Ctrl+C` to exit. -4. Check the server logs: in **MCP: List Servers**, select the server and choose **Show Output**. -5. If the `node` command is not found, use the full path to the Node binary. - -**Tools don't appear in Copilot Chat** - -1. Confirm you're in **Agent** mode (not Ask or Edit mode). -2. Run **MCP: Reset Cached Tools** from the Command Palette, then recheck the **Tools** list. - -</details> - -<details> -<summary>Weather API issues</summary> - -**Error: Failed to retrieve grid point data** - -This usually means either: - -1. The coordinates are outside the US -2. The NWS API is having issues -3. You're being rate limited - -Fix: - -- Verify you're using US coordinates -- Add a small delay between requests -- Check the NWS API status page - -**Error: No active alerts for [STATE]** - -This isn't an error - it just means there are no current weather alerts for that state. Try a different state or check during severe weather. - -</details> - -## Next steps - -Now that your server is running locally, here are some ways to go further: - -- [**Server guide**](./server.md) — Add resources, prompts, logging, error handling, and remote transports to your server. -- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable examples covering OAuth, streaming, sessions, and more. -- [**FAQ**](./faq.md) — Troubleshoot common errors (Zod version conflicts, transport issues, etc.). diff --git a/docs/server.md b/docs/server.md deleted file mode 100644 index dee32b4c6f..0000000000 --- a/docs/server.md +++ /dev/null @@ -1,1006 +0,0 @@ ---- -title: Server Guide ---- - -# Building MCP servers - -This guide covers the TypeScript SDK APIs for building MCP servers. For protocol-level concepts — what tools, resources, and prompts are and when to use each — see the [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture). - -Building a server takes three steps: - -1. Create an `McpServer` and register your [tools](#tools), [resources](#resources), and [prompts](#prompts). -2. Create a transport — [Streamable HTTP](#streamable-http) for remote servers or [stdio](#stdio) for local integrations. -3. Connect them with `server.connect(transport)`. - -## Imports - -The examples below use these imports. Adjust based on which features and transport you need: - -```ts source="../examples/guides/serverGuide.examples.ts#imports" -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; - -import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { - createMcpExpressApp, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth -} from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; -import type { CallToolResult, InputRequiredResult, OAuthMetadata, ResourceLink } from '@modelcontextprotocol/server'; -import { - acceptedContent, - completable, - createMcpHandler, - createRequestStateCodec, - inputRequired, - McpServer, - ResourceTemplate, - TRACEPARENT_META_KEY -} from '@modelcontextprotocol/server'; -import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; -``` - -## Transports - -MCP supports two transport mechanisms (see [Transport layer](https://modelcontextprotocol.io/docs/learn/architecture#transport-layer) in the MCP overview). Choose based on deployment model: - -- **Streamable HTTP** — for remote servers accessible over the network. -- **stdio** — for local servers spawned as child processes (Claude Desktop, CLI tools). - -### Streamable HTTP - -Create a `NodeStreamableHTTPServerTransport` and connect it to your server: - -```ts source="../examples/guides/serverGuide.examples.ts#streamableHttp_stateful" -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - -const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() -}); - -await server.connect(transport); -``` - -**Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. - -For a complete server with sessions and the browser-client CORS recipe, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). - -#### Serving the 2026-07-28 draft revision over HTTP - -A hand-wired Streamable HTTP transport speaks the 2025-era protocol it was written for. To serve the 2026-07-28 draft revision, use `createMcpHandler`: it builds one instance from your factory per request and, by default, serves 2025-era traffic stateless from the same factory: - -```ts source="../examples/guides/serverGuide.examples.ts#createMcpHandler_basic" -const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once; the same factory serves both eras - return server; -}); -``` - -`handler.fetch` is a web-standard `(Request) => Promise<Response>`: on Cloudflare Workers, Deno, or Bun, `export default handler` is all the mounting you need. For Express, Fastify, or plain `node:http`, wrap the handler once with `toNodeHandler` from -`@modelcontextprotocol/node`: - -```ts source="../examples/guides/serverGuide.examples.ts#createMcpHandler_node" -createServer(toNodeHandler(handler)).listen(3000); -// Express: app.all('/mcp', toNodeHandler(handler)); -// behind express.json(): const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); -``` - -**Options:** Pass `legacy: 'reject'` to refuse 2025-era requests with the unsupported-protocol-version error (the default, `'stateless'`, serves them per request with no sessions). `onerror` observes out-of-band errors without altering responses. The entry performs no -Origin/Host validation and no token verification itself. Mount [DNS rebinding protection](#dns-rebinding-protection) in front of it, and pass validated auth through `handler.fetch(request, { authInfo })` (or `req.auth` when using `toNodeHandler`). - -To keep an existing sessionful 2025 deployment serving legacy traffic, route with `isLegacyRequest` in front of a strict (`legacy: 'reject'`) handler. See the [2026-07-28 support guide](./migration/support-2026-07-28.md) for the migration patterns; a runnable dual-transport example lives at [`dual-era/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/dual-era/server.ts). - -### stdio - -For local, process-spawned integrations, use `StdioServerTransport`: - -```ts source="../examples/guides/serverGuide.examples.ts#stdio_basic" -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -const transport = new StdioServerTransport(); -await server.connect(transport); -``` - -#### Serving the 2026-07-28 draft revision on stdio - -A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` for -long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: - -```ts source="../examples/guides/serverGuide.examples.ts#serveStdio_basic" -serveStdio(() => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once; the same factory serves both eras - return server; -}); -``` - -Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [2026-07-28 support guide](./migration/support-2026-07-28.md) for details). A runnable -example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.ts`. - -**Options:** `legacy: 'reject'` refuses 2025-era openings with the unsupported-protocol-version error (default `'serve'`). `transport` brings your own `Transport` (for example a `StdioServerTransport` constructed over a socket), and the entry owns it either way. `onerror` -observes out-of-band errors. The returned handle's `close()` tears down the pinned instance and the transport. During era selection the entry may construct and discard a probe instance, so keep factories cheap and side-effect-free. - -## Server instructions - -Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to -the system prompt. Instructions should not duplicate information already in tool descriptions. - -```ts source="../examples/guides/serverGuide.examples.ts#instructions_basic" -const server = new McpServer( - { name: 'db-server', version: '1.0.0' }, - { - instructions: - 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' - } -); -``` - -## Tools - -Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). - -Register a tool with `registerTool`. Provide an `inputSchema` (any Standard Schema library that supports JSON Schema conversion: Zod v4 shown here; ArkType and Valibot also conform) to validate -arguments, and optionally an `outputSchema` for structured return values. - -> On the 2026-07-28 draft serving path, a tool whose `inputSchema` carries an `x-mcp-header` annotation has that argument mirrored into an `Mcp-Param-{Name}` HTTP request header by conforming clients. `createMcpHandler` validates those headers before dispatch and rejects a -> `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32020` (`HeaderMismatch`). `registerTool` warns at registration time when an `x-mcp-header` declaration violates the -> spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" -server.registerTool( - 'calculate-bmi', - { - title: 'BMI Calculator', - description: 'Calculate Body Mass Index', - inputSchema: z.object({ - weightKg: z.number(), - heightM: z.number() - }), - outputSchema: z.object({ bmi: z.number() }) - }, - async ({ weightKg, heightM }) => { - const output = { bmi: weightKg / (heightM * heightM) }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } -); -``` - -### `ResourceLink` outputs - -Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_resourceLink" -server.registerTool( - 'list-files', - { - title: 'List Files', - description: 'Returns files as resource links without embedding content' - }, - async (): Promise<CallToolResult> => { - const links: ResourceLink[] = [ - { - type: 'resource_link', - uri: 'file:///projects/readme.md', - name: 'README', - mimeType: 'text/markdown' - }, - { - type: 'resource_link', - uri: 'file:///projects/config.json', - name: 'Config', - mimeType: 'application/json' - } - ]; - return { content: links }; - } -); -``` - -### Tool annotations - -Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_annotations" -server.registerTool( - 'delete-file', - { - description: 'Delete a file from the project', - inputSchema: z.object({ path: z.string() }), - annotations: { - title: 'Delete File', - destructiveHint: true, - idempotentHint: true - } - }, - async ({ path }): Promise<CallToolResult> => { - // ... perform deletion ... - return { content: [{ type: 'text', text: `Deleted ${path}` }] }; - } -); -``` - -### Icons - -Tools, prompts, resources, and resource templates can advertise `icons` that a client may render in its UI — the same field is also accepted on your server's `Implementation` info. Each icon needs a `src` (a URL or `data:` URI) and may add a `mimeType`, the `sizes` it suits (`"WxH"` strings, or `"any"` for scalable formats like SVG), and a `theme` (`light` or `dark`). Icons are passed straight through to the relevant list response, such as `tools/list`: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_icons" -server.registerTool( - 'generate-chart', - { - title: 'Generate Chart', - description: 'Render a chart from a series of numbers', - inputSchema: z.object({ data: z.array(z.number()) }), - // Icons a client may render in its UI. `src` is required; `mimeType`, - // `sizes`, and `theme` ('light' | 'dark') are optional hints. - icons: [ - { src: 'https://example.com/icons/chart.svg', mimeType: 'image/svg+xml', sizes: ['any'] }, - { src: 'https://example.com/icons/chart-48.png', mimeType: 'image/png', sizes: ['48x48'], theme: 'light' } - ] - }, - async ({ data }): Promise<CallToolResult> => { - // ... render chart ... - return { content: [{ type: 'text', text: `Charted ${data.length} points` }] }; - } -); -``` - -> [!NOTE] -> Clients that render icons must support `image/png` and `image/jpeg`, and should also support `image/svg+xml` and `image/webp`. Pass the same `icons` field to `registerPrompt`, `registerResource`, and the `McpServer` constructor's server-info object to advertise icons for prompts, resources, and the server itself. - -### Error handling - -Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_errorHandling" -server.registerTool( - 'fetch-data', - { - description: 'Fetch data from a URL', - inputSchema: z.object({ url: z.string() }) - }, - async ({ url }): Promise<CallToolResult> => { - try { - const res = await fetch(url); - if (!res.ok) { - return { - content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], - isError: true - }; - } - const text = await res.text(); - return { content: [{ type: 'text', text }] }; - } catch (error) { - return { - content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : String(error)}` }], - isError: true - }; - } - } -); -``` - -If a handler throws instead of returning `isError`, the SDK catches the exception and converts it to `{ isError: true }` automatically — so an explicit try/catch is optional but gives you control over the error message. When `isError` is true, output schema validation is skipped. - -## Resources - -Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike -[tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. - -A static resource at a fixed URI: - -```ts source="../examples/guides/serverGuide.examples.ts#registerResource_static" -server.registerResource( - 'config', - 'config://app', - { - title: 'Application Config', - description: 'Application configuration data', - mimeType: 'text/plain' - }, - async uri => ({ - contents: [{ uri: uri.href, text: 'App configuration here' }] - }) -); -``` - -Dynamic resources use `ResourceTemplate` with URI patterns. The `list` callback lets clients discover available instances: - -```ts source="../examples/guides/serverGuide.examples.ts#registerResource_template" -server.registerResource( - 'user-profile', - new ResourceTemplate('user://{userId}/profile', { - list: async () => ({ - resources: [ - { uri: 'user://123/profile', name: 'Alice' }, - { uri: 'user://456/profile', name: 'Bob' } - ] - }) - }), - { - title: 'User Profile', - description: 'User profile data', - mimeType: 'application/json' - }, - async (uri, { userId }) => ({ - contents: [ - { - uri: uri.href, - text: JSON.stringify({ userId, name: 'Example User' }) - } - ] - }) -); -``` - -> [!IMPORTANT] -> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within -> the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. - -To notify clients when a resource's content changes, see [Change notifications](#change-notifications). - -## Prompts - -Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use -a [tool](#tools) when the LLM should decide when to call it. - -```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_basic" -server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices and potential issues', - argsSchema: z.object({ - code: z.string() - }) - }, - ({ code }) => ({ - messages: [ - { - role: 'user' as const, - content: { - type: 'text' as const, - text: `Please review this code:\n\n${code}` - } - } - ] - }) -); -``` - -## Completions - -Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with `completable()` to provide autocompletion suggestions: - -```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_completion" -server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices', - argsSchema: z.object({ - language: completable(z.string().describe('Programming language'), value => - ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) - ) - }) - }, - ({ language }) => ({ - messages: [ - { - role: 'user' as const, - content: { - type: 'text' as const, - text: `Review this ${language} code for best practices.` - } - } - ] - }) -); -``` - -For resource templates, pass a `complete` callback map to the `ResourceTemplate` constructor instead. - -## Extension capabilities - -A server advertises support for [MCP extensions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#capability-negotiation) through `capabilities.extensions` — a map from extension identifier to that extension's settings object. Declare entries with -`server.server.registerCapabilities()` before connecting: - -```ts source="../examples/guides/serverGuide.examples.ts#extensionCapabilities_register" -server.server.registerCapabilities({ - extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } } -}); -``` - -The map is advertised in the `initialize` result on legacy connections and in the `server/discover` response on 2026-07-28 ones. Identifiers are prefix-qualified per the spec's `_meta` key naming rules (e.g. `com.example/feature-flags`); each value is free-form JSON for -that extension's settings — `{}` means supported with no settings. - -For a runnable pair, see the [`extension-capabilities/` example](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/extension-capabilities/README.md); reading the map client-side is covered in the [client guide](./client.md#extension-capabilities). - -## Cache hints (2026-07-28 draft) - -The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) so clients and intermediaries know how long a response stays fresh and -whether it may be shared (SEP-2549). The SDK fills both fields automatically when serving that revision, defaulting to `ttlMs: 0` and `cacheScope: 'private'` (immediately stale, never shared). Responses to 2025-era requests are never affected. - -To advertise a real cache policy, set `ServerOptions.cacheHints` per operation, and/or `cacheHint` on an individual resource registration: - -```ts source="../examples/guides/serverGuide.examples.ts#cacheHints_basic" -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { - cacheHints: { - // The tool list is the same for every caller and rarely changes: - 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } - } - } -); - -server.registerResource( - 'config', - 'config://app', - { - mimeType: 'text/plain', - // Wins field-by-field over a cacheHints['resources/read'] entry; - // cacheScope stays at the 'private' default here. - cacheHint: { ttlMs: 300_000 } - }, - async uri => ({ - contents: [{ uri: uri.href, text: 'App configuration here' }] - }) -); -``` - -Resolution is per field, most specific author first: values set directly on the handler's result, then the resource's `cacheHint`, then the matching `cacheHints` entry, then the defaults. -Invalid hint values throw a `RangeError` at construction/registration time, and the `cacheHint` key is stripped from the resource's listed metadata (it configures the read result, not the listing). - -Use `cacheScope: 'public'` only for results that are identical for every caller: a `'public'` result may be served to other users by shared caches. Anything derived from the request's authorization context must stay `'private'` (the default). - -## Logging - -> [!WARNING] -> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate -> to stderr logging (STDIO servers) or OpenTelemetry. - -Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). - -Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from `ServerContext`) inside any handler: - -```ts source="../examples/guides/serverGuide.examples.ts#logging_capability" -const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); -``` - -Then log from any handler: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_logging" -server.registerTool( - 'fetch-data', - { - description: 'Fetch data from an API', - inputSchema: z.object({ url: z.string() }) - }, - async ({ url }, ctx): Promise<CallToolResult> => { - await ctx.mcpReq.log('info', `Fetching ${url}`); - const res = await fetch(url); - await ctx.mcpReq.log('debug', `Response status: ${res.status}`); - const text = await res.text(); - return { content: [{ type: 'text', text }] }; - } -); -``` - -On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the request's `io.modelcontextprotocol/logLevel` `_meta` key. When the client did not set one, the call is a silent no-op (the spec forbids sending `notifications/message` without the opt-in). On -2025-era connections the session level set via `logging/setLevel` applies as before. See [2026-07-28 support guide › per-request `logLevel`](./migration/support-2026-07-28.md#ctxmcpreqlog-and-the-per-request-loglevel). - -## Progress - -Progress notifications let a tool report incremental status updates during long-running operations (see [Progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) in the MCP specification). - -If the client includes a `progressToken` in the request `_meta`, send `notifications/progress` via `ctx.mcpReq.notify()` (from `BaseContext`): - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_progress" -server.registerTool( - 'process-files', - { - description: 'Process files with progress updates', - inputSchema: z.object({ files: z.array(z.string()) }) - }, - async ({ files }, ctx): Promise<CallToolResult> => { - const progressToken = ctx.mcpReq._meta?.progressToken; - - for (let i = 0; i < files.length; i++) { - // ... process files[i] ... - - if (progressToken !== undefined) { - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: i + 1, - total: files.length, - message: `Processed ${files[i]}` - } - }); - } - } - - return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; - } -); -``` - -`progress` must increase on each call. `total` and `message` are optional. If the client does not provide a `progressToken`, skip the notification. - -## Change notifications - -Servers can signal that their tool, prompt, or resource lists changed, or that a specific resource's content changed, so clients can refresh. - -**List changes** are emitted automatically: registering, enabling, disabling, updating, or removing a tool, prompt, or resource sends the matching `notifications/*/list_changed` (`McpServer` advertises the corresponding `listChanged: true` capability on registration; -declare it up front only when using the low-level `Server`). You can also send them explicitly with `sendToolListChanged()`, `sendPromptListChanged()`, and `sendResourceListChanged()`. - -**Per-resource updates** (2025-era connections) require hand-wiring; `registerResource` has no subscribe option. Declare `resources: { subscribe: true }`, register the `resources/subscribe`/`resources/unsubscribe` handlers on the underlying low-level server, and push -`sendResourceUpdated()` when the data changes: - -```ts source="../examples/guides/serverGuide.examples.ts#subscriptions_legacy" -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { capabilities: { resources: { subscribe: true, listChanged: true } } } -); - -const subscriptions = new Set<string>(); -server.server.setRequestHandler('resources/subscribe', async request => { - subscriptions.add(request.params.uri); - return {}; -}); -server.server.setRequestHandler('resources/unsubscribe', async request => { - subscriptions.delete(request.params.uri); - return {}; -}); - -// When the underlying data changes: -async function onConfigChanged() { - if (subscriptions.has('config://app')) { - await server.server.sendResourceUpdated({ uri: 'config://app' }); - } -} -``` - -**On the 2026-07-28 revision** clients receive change notifications only on a `subscriptions/listen` stream they open, and the serving entries handle that method themselves (nothing to register). Over HTTP, publish through the handler's typed -`notify` facade; each call reaches every open subscription that opted in: - -```ts source="../examples/guides/serverGuide.examples.ts#subscriptions_notify" -const handler = createMcpHandler(() => buildServer()); - -// When the underlying data changes: -handler.notify.resourceUpdated('config://app'); -handler.notify.toolsChanged(); -``` - -The default in-process `InMemoryServerEventBus` covers single-process deployments; multi-process deployments supply their own -`ServerEventBus` via the `bus` option. On stdio, `serveStdio` pins one instance per connection and routes its ordinary `send*ListChanged()` calls onto open subscriptions automatically. Per-resource updates need one change on a 2026 connection: the subscription bookkeeping lives at the entry (the client's listen filter), so the hand-wired `resources/subscribe` handlers above never run. Publish -`sendResourceUpdated()` unconditionally when the data changes and let the entry deliver it only to subscriptions that listed the URI. - -On the 2026-07-28 revision delivery is capability-gated per type: the entry honors `resourceSubscriptions` only when the server advertises `resources: { subscribe: true }`, and each list-changed type only with the matching `listChanged` capability (on 2025-era connections -the SDK gates sends on the presence of the corresponding capability). Clients subscribe to exact resource URIs. - -See [`subscriptions/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/server.ts) for a runnable dual-transport example, and the -[2026-07-28 support guide › `subscriptions/listen`](./migration/support-2026-07-28.md#subscriptionslisten) for migration-level detail. - -## Trace context propagation - -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When -present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are -exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. - -Read the caller's trace context from `ctx.mcpReq._meta` in a handler: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_traceContext" -server.registerTool( - 'traced-operation', - { - description: 'Operation that participates in distributed tracing', - inputSchema: z.object({ query: z.string() }) - }, - async ({ query }, ctx): Promise<CallToolResult> => { - // e.g. '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' - const traceparent = ctx.mcpReq._meta?.[TRACEPARENT_META_KEY]; - if (typeof traceparent === 'string') { - // Continue the caller's trace, e.g. start a child span with your - // OpenTelemetry tracer using this trace context. - } - - return { content: [{ type: 'text', text: `Results for ${query}` }] }; - } -); -``` - -To propagate context onward (for example on a server-initiated sampling request, or back on a response), set the same keys in the outgoing `_meta`. See the [client guide](./client.md#trace-context-propagation) for injecting trace context on the client side. - -## Server-initiated requests - -MCP is bidirectional: servers can request input _from_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). On 2025-era connections the server pushes a JSON-RPC request to the client (the sections below). On the 2026-07-28 revision there is no server→client request channel: the handler **returns** an `input_required` result carrying the embedded requests, -and the client retries the call with the responses. - -On a connection pinned to the 2026-07-28 draft revision (served via `serveStdio` or `createMcpHandler`), the push-style channels below throw an `SdkError` with -code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before anything reaches the wire (see the [2026-07-28 support guide](./migration/support-2026-07-28.md)). - -### Requesting input on 2026-07-28: `input_required` - -On the 2026-07-28 revision a `tools/call`, `prompts/get`, or `resources/read` handler requests client input by returning `inputRequired(...)`. The result names one or more -embedded requests, built with `inputRequired.elicit(...)` (form elicitation), `inputRequired.elicitUrl(...)` (URL elicitation), `inputRequired.createMessage(...)` (sampling), or `inputRequired.listRoots()`. Write the handler **write-once**: on every entry, first read what has already arrived via `acceptedContent(ctx.mcpReq.inputResponses, key)`, and only ask for what is still missing: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_inputRequired" -server.registerTool( - 'deploy', - { - description: 'Deploy after user confirmation', - inputSchema: z.object({ env: z.string() }) - }, - async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (confirmed?.confirm !== true) { - return inputRequired({ - inputRequests: { - confirm: inputRequired.elicit({ - message: `Deploy to ${env}?`, - requestedSchema: { - type: 'object', - properties: { confirm: { type: 'boolean' } }, - required: ['confirm'] - } - }) - } - }); - } - return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; - } -); -``` - -Every `input_required` result must carry at least one of `inputRequests` or `requestState`: the builder throws a `TypeError` otherwise, and the seam re-checks the rule for hand-built results. Each embedded request is checked against the capabilities the client declared on -the request's `_meta` envelope; a missing capability rejects the call with `-32021` before anything reaches the wire. The responses in `ctx.mcpReq.inputResponses` come from the client; treat them as untrusted input. - -On 2025-era connections you don't need to branch: the SDK's legacy shim (on by default) fulfils `input_required` returns by issuing real elicitation/sampling/roots requests over the session, so handlers stay write-once. Knobs and limits are described in [the legacy shim section of the 2026-07-28 support guide](./migration/support-2026-07-28.md#legacy-shim-for-input_required). - -For the full multi-step pattern (confirmation, then URL-mode sign-in), see [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts). - -#### Carrying state across rounds: `requestState` - -The 2026-07-28 serving entries are per-request: nothing survives between rounds on the server. To remember where a multi-step flow stands, return an opaque `requestState` string alongside (or instead of) `inputRequests`; the client echoes it back byte-for-byte on the retry -and the handler reads it back with the typed `ctx.mcpReq.requestState<State>()` accessor. - -> [!IMPORTANT] -> `requestState` round-trips through the client and comes back as **attacker-controlled input**. State that influences authorization, resource access, or business logic must be integrity-protected; the SDK applies no protection of its own. Use -> `createRequestStateCodec`, an HMAC-SHA256 codec whose `verify` drops directly into the `ServerOptions.requestState` hook, which runs before the handler and answers tampered or expired state with a -> wire-level `-32602` (frozen message `"Invalid or expired requestState"`). The codec is signed, not encrypted. Do not put secrets in the payload. - -```ts source="../examples/guides/serverGuide.examples.ts#requestState_codec" -const stateCodec = createRequestStateCodec<{ step: string }>({ - key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share across instances in a fleet - ttlSeconds: 600 -}); - -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } -); -``` - -Inside a handler, mint state on the way out and read it back on re-entry. The `requestState.verify` hook has already run by then, and the accessor returns its decoded payload (or the raw string when no hook is configured): - -```ts source="../examples/guides/serverGuide.examples.ts#requestState_mintDecode" -server.registerTool( - 'wipe-cache', - { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, - async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { - const state = ctx.mcpReq.requestState<{ step: string }>(); - - if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (confirmed?.confirm !== true) { - return inputRequired({ - inputRequests: { - confirm: inputRequired.elicit({ - message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } - }) - } - }); - } - // Mint only what the response above already proved: the user confirmed. - return inputRequired({ - inputRequests: { - scope: inputRequired.elicit({ - message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } - }) - }, - requestState: await stateCodec.mint({ step: 'confirmed' }) - }); - } - - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; - } -); -``` - -Mint state that records what earlier rounds already proved, never an outcome that has not happened yet. The codec makes the token tamper-proof, which means it is bearer proof of whatever you put in it: a token minted as `{ step: 'signed-in' }` before the user signs in grants that step to anyone who echoes it. - -See [`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) for the worked end-to-end flow, and the -[2026-07-28 support guide › Replacing per-session state](./migration/support-2026-07-28.md#replacing-per-session-state-requeststate) for porting session-keyed code. - -### Sampling - -> [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional on 2025-era connections during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to -> calling LLM provider APIs directly from your server. - -> [!NOTE] -> `ctx.mcpReq.requestSampling` is the 2025-era push channel and **throws a typed error on a 2026-07-28-era request**. On that revision, return `inputRequired({ inputRequests: { id: inputRequired.createMessage({ … }) } })` instead; see -> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). - -Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling -when a tool needs the model to generate or transform text mid-execution. - -Call `ctx.mcpReq.requestSampling(params)` (from `ServerContext`) inside a tool handler: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_sampling" -server.registerTool( - 'summarize', - { - description: 'Summarize text using the client LLM', - inputSchema: z.object({ text: z.string() }) - }, - async ({ text }, ctx): Promise<CallToolResult> => { - const response = await ctx.mcpReq.requestSampling({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - return { - content: [ - { - type: 'text', - text: `Model (${response.model}): ${JSON.stringify(response.content)}` - } - ] - }; - } -); -``` - -For a full runnable example, see [`sampling/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). - -### Elicitation - -Elicitation lets a tool handler request direct input from the user — form fields, confirmations, or a redirect to a URL (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). It supports two modes: - -- **Form** (`mode: 'form'`) — collects non-sensitive data via a schema-driven form. -- **URL** (`mode: 'url'`) — opens a browser URL for sensitive data or secure flows (API keys, payments, OAuth). - -> [!IMPORTANT] -> Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. - -> [!NOTE] -> `ctx.mcpReq.elicitInput` is the 2025-era push channel and **throws a typed error on a 2026-07-28-era request**. Return `inputRequired.elicit(...)` (form) or `inputRequired.elicitUrl(...)` (URL) via `inputRequired({ inputRequests: { … } })` instead; see -> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). The throw-style `UrlElicitationRequiredError` (`-32042`) also fails loudly toward 2026-era requests. - -Call `ctx.mcpReq.elicitInput(params)` (from `ServerContext`) inside a tool handler: - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_elicitation" -server.registerTool( - 'collect-feedback', - { - description: 'Collect user feedback via a form', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise<CallToolResult> => { - const result = await ctx.mcpReq.elicitInput({ - mode: 'form', - message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } - }); - if (result.action === 'accept') { - return { - content: [ - { - type: 'text', - text: `Thanks! ${JSON.stringify(result.content)}` - } - ] - }; - } - return { content: [{ type: 'text', text: 'Feedback declined.' }] }; - } -); -``` - -For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/server.ts) (form + URL mode, both protocol eras) and -[`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) (the secure `requestState` round-trip pattern). - -### Roots - -> [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional on 2025-era connections during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to -> passing paths via tool parameters, resource URIs, or configuration. - -> [!NOTE] -> `server.server.listRoots()` **throws a typed error on a 2026-07-28-era instance**. Return `inputRequired({ inputRequests: { roots: inputRequired.listRoots() } })` and read the response from `ctx.mcpReq.inputResponses` on re-entry; see -> [Requesting input on 2026-07-28](#requesting-input-on-2026-07-28-input_required). - -Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call `server.server.listRoots()` (requires the client to declare the `roots` capability): - -```ts source="../examples/guides/serverGuide.examples.ts#registerTool_roots" -server.registerTool( - 'list-workspace-files', - { - description: 'List files across all workspace roots', - inputSchema: z.object({}) - }, - async (_args, _ctx): Promise<CallToolResult> => { - const { roots } = await server.server.listRoots(); - const summary = roots.map(r => `${r.name ?? r.uri}: ${r.uri}`).join('\n'); - return { content: [{ type: 'text', text: summary }] }; - } -); -``` - -## Shutdown - -For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: - -```ts source="../examples/guides/serverGuide.examples.ts#shutdown_statefulHttp" -// Capture the http.Server so it can be closed on shutdown -const httpServer = app.listen(3000); - -process.on('SIGINT', async () => { - httpServer.close(); - - for (const [sessionId, transport] of transports) { - await transport.close(); - transports.delete(sessionId); - } - - process.exit(0); -}); -``` - -Calling `transport.close()` closes SSE streams and rejects any pending outbound requests. In-flight tool handlers are not automatically drained — they are terminated when the process exits. - -For stdio servers, `server.close()` is sufficient: - -```ts source="../examples/guides/serverGuide.examples.ts#shutdown_stdio" -process.on('SIGINT', async () => { - await server.close(); - process.exit(0); -}); -``` - -For a complete multi-session server with shutdown handling, see [`repl/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/server.ts). - -## Deployment - -### DNS rebinding protection - -Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, -since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** - -The recommended approach is to use `createMcpExpressApp()` (from `@modelcontextprotocol/express`) or `createMcpHonoApp()` (from -`@modelcontextprotocol/hono`), which enable Host header validation by default: - -```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_basic" -// Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) -const app = createMcpExpressApp(); - -// DNS rebinding protection also auto-enabled for localhost -const appLocal = createMcpExpressApp({ host: 'localhost' }); - -// No automatic protection when binding to all interfaces -const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); -``` - -When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: - -```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_allowedHosts" -const app = createMcpExpressApp({ - host: '0.0.0.0', - allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] -}); -``` - -`createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). - -The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there -is no option that disables Origin validation for a localhost-class bind. Requests without an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework -middleware (`originValidation`, `localhostOriginValidation`) can also be mounted explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. - -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/middleware/hostHeaderValidation.ts) -middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. - -### Authorization (OAuth resource server) - -HTTP servers can require OAuth bearer tokens (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). The SDK treats your server as an OAuth _resource server_: it verifies tokens issued by an authorization -server; it does not issue them. Token verification, the `WWW-Authenticate` challenge, and the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document come from `@modelcontextprotocol/express`: - -```ts source="../examples/guides/serverGuide.examples.ts#auth_resourceServer" -const mcpServerUrl = new URL('https://api.example.com/mcp'); - -// Verify tokens however your deployment requires: JWT verification, -// RFC 7662 introspection, a call to your IdP. -const verifier: OAuthTokenVerifier = { - async verifyAccessToken(token) { - const payload = await verifyJwt(token); - return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; - } -}; - -// Public deployment: allow-list the public host (see DNS rebinding protection). -const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); - -// Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) and mirrors the -// authorization server's metadata, so clients can discover your AS. -app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); - -// 401/403 responses carry `WWW-Authenticate: Bearer …` with `resource_metadata` -// pointing at the document above. That challenge is what starts the client -// SDK's OAuth flow. -const auth = requireBearerAuth({ - verifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -const node = toNodeHandler(createMcpHandler(buildServer)); -app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); -``` - -`requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`; `toNodeHandler` forwards it so tool handlers read it as `ctx.http.authInfo` (and `createMcpHandler` factories as `ctx.authInfo`). A missing or invalid token gets `401 invalid_token`, as does a token whose `expiresAt` is unset or in the past. A valid token missing one of `requiredScopes` gets `403 insufficient_scope`; the challenge's `scope` field is what clients use for scope step-up (SEP-2350). - -Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) live in `@modelcontextprotocol/server-legacy/auth` as a frozen v1 copy; new code should use a dedicated IdP or OAuth library for the AS (see the [FAQ](./faq.md#where-are-the-server-auth-helpers)). - -For runnable examples, see [`bearer-auth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts) (minimal static verifier) and -[`oauth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/server.ts) (full discovery flow against a demo authorization server). - -## See also - -- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable server examples -- [Client guide](./client.md) — Building MCP clients with this SDK -- [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives -- [Migration guide](./migration/index.md) — Upgrading from previous SDK versions -- [FAQ](./faq.md) — Frequently asked questions and troubleshooting - -### Additional examples - -| Feature | Description | Example | -| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | -| Dual-era serving | One factory serving 2025 + 2026-07-28 over HTTP and stdio | [`dual-era/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/dual-era/server.ts) | -| Change notifications | Publish `subscriptions/listen` change events over HTTP and stdio | [`subscriptions/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/subscriptions/server.ts) | -| OAuth resource server | Bearer-token verification, `WWW-Authenticate` challenge, RFC 9728 metadata | [`bearer-auth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/bearer-auth/server.ts), [`oauth/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/server.ts) | diff --git a/examples/README.md b/examples/README.md index dee74f7735..bbd80564a5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -69,7 +69,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | Directory | What it is | Why not in CI | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | -| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| [`guides/`](./guides/README.md) | Per-page snippet companions synced into the `docs/` guide pages | Typecheck-only; not a runnable pair. | | `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | | `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. | @@ -173,5 +173,5 @@ For scenarios where local in-memory state must be maintained on specific nodes, ## Backwards compatibility (Streamable HTTP ↔ legacy SSE) -A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try -`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern. +A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows [Fall back to SSE for servers that predate Streamable HTTP](../docs/clients/connect.md#fall-back-to-sse-for-servers-that-predate-streamable-http) in the client docs — try +`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the `connect_sseFallback` snippet in `guides/clients/connect.examples.ts` is the complete pattern. diff --git a/examples/client-quickstart/README.md b/examples/client-quickstart/README.md index 5e590eccf4..eac91d05dc 100644 --- a/examples/client-quickstart/README.md +++ b/examples/client-quickstart/README.md @@ -1,5 +1,5 @@ # client-quickstart -Source for the [Client Quickstart](../../docs/client-quickstart.md) tutorial: an LLM-powered chatbot that connects to an MCP server over stdio and calls its tools. The tutorial walks through `src/index.ts` end to end. +An LLM-powered chatbot that connects to an MCP server over stdio and calls its tools (`src/index.ts`). It was the source for the retired client-quickstart tutorial; the current getting-started tutorial is [Build your first client](../../docs/get-started/first-client.md). -The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself, use the standalone manifests from the tutorial. +The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the client yourself outside the monorepo, copy `src/index.ts` into a standalone project that depends on the published packages. diff --git a/examples/guides/README.md b/examples/guides/README.md index d0ed7dbe93..30975e7c41 100644 --- a/examples/guides/README.md +++ b/examples/guides/README.md @@ -1,3 +1,3 @@ # guides -Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs. +Per-page snippet companions for the `docs/` guide pages: each `<dir>/<page>.examples.ts` is the type-checked source for the code fences in `docs/<dir>/<page>.md`, synced via `pnpm sync:snippets`. Companions that quote output are also run as real programs by `pnpm docs:examples`; the rest opt out with a `// docs: typecheck-only` first line. diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts deleted file mode 100644 index bd065d6e9e..0000000000 --- a/examples/guides/clientGuide.examples.ts +++ /dev/null @@ -1,923 +0,0 @@ -/** - * Type-checked examples for docs/client.md. - * - * Regions are synced into markdown code fences via `pnpm sync:snippets`. - * Each function wraps a single region. The function name matches the region name. - * - * @module - */ - -//#region imports -import type { - AuthProvider, - CallToolResult, - InputRequiredResult, - OAuthClientInformationContext, - OAuthClientInformationMixed, - OAuthClientMetadata, - OAuthClientProvider, - OAuthDiscoveryState, - OAuthTokens -} from '@modelcontextprotocol/client'; -import { - applyMiddlewares, - checkResourceAllowed, - Client, - ClientCredentialsProvider, - createMiddleware, - CrossAppAccessProvider, - discoverAndRequestJwtAuthGrant, - isInputRequiredResult, - IssuerMismatchError, - LOG_LEVEL_META_KEY, - PrivateKeyJwtProvider, - ProtocolError, - resourceUrlFromServerUrl, - SdkError, - SdkErrorCode, - SdkHttpError, - SSEClientTransport, - StreamableHTTPClientTransport, - TRACEPARENT_META_KEY, - TRACESTATE_META_KEY, - UnauthorizedError -} from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -//#endregion imports - -// --------------------------------------------------------------------------- -// Connecting to a server -// --------------------------------------------------------------------------- - -/** Example: Streamable HTTP transport. */ -async function connect_streamableHttp() { - //#region connect_streamableHttp - const client = new Client({ name: 'my-client', version: '1.0.0' }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); - - await client.connect(transport); - //#endregion connect_streamableHttp -} - -/** Example: stdio transport for local process-spawned servers. */ -async function connect_stdio() { - //#region connect_stdio - const client = new Client({ name: 'my-client', version: '1.0.0' }); - - const transport = new StdioClientTransport({ - command: 'node', - args: ['server.js'] - }); - - await client.connect(transport); - //#endregion connect_stdio -} - -/** Example: Try Streamable HTTP, fall back to legacy SSE. */ -async function connect_sseFallback(url: string) { - //#region connect_sseFallback - const baseUrl = new URL(url); - - try { - // Try modern Streamable HTTP transport first - const client = new Client({ name: 'my-client', version: '1.0.0' }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - return { client, transport }; - } catch { - // Fall back to legacy SSE transport - const client = new Client({ name: 'my-client', version: '1.0.0' }); - const transport = new SSEClientTransport(baseUrl); - await client.connect(transport); - return { client, transport }; - } - //#endregion connect_sseFallback -} - -/** Example: Opt into 2026-07-28 protocol version negotiation. */ -async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) { - //#region Client_versionNegotiation - // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake - // against a 2025-only server. - const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(transport); - - client.getProtocolEra(); // 'modern' or 'legacy' - client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' - //#endregion Client_versionNegotiation -} - -/** Example: zero-round-trip connect from a persisted DiscoverResult. */ -async function Client_connect_prior(url: URL) { - //#region Client_connect_prior - // Probe once (here via the 'auto'-mode connect), persist the result … - const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await bootstrap.connect(new StreamableHTTPClientTransport(url)); - const persisted = JSON.stringify(bootstrap.getDiscoverResult()); - - // … then every worker connects with zero round trips. - const worker = new Client({ name: 'worker', version: '1.0.0' }); - await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); - //#endregion Client_connect_prior -} - -// --------------------------------------------------------------------------- -// Disconnecting -// --------------------------------------------------------------------------- - -/** Example: Graceful disconnect for Streamable HTTP. */ -async function disconnect_streamableHttp(client: Client, transport: StreamableHTTPClientTransport) { - //#region disconnect_streamableHttp - await transport.terminateSession(); // notify the server (recommended) - await client.close(); - //#endregion disconnect_streamableHttp -} - -// --------------------------------------------------------------------------- -// Server instructions -// --------------------------------------------------------------------------- - -/** Example: Access server instructions after connecting. */ -async function serverInstructions_basic(client: Client) { - //#region serverInstructions_basic - const instructions = client.getInstructions(); - - const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); - - console.log(systemPrompt); - //#endregion serverInstructions_basic -} - -// --------------------------------------------------------------------------- -// Extension capabilities -// --------------------------------------------------------------------------- - -/** Example: Read the negotiated extension capabilities after connecting. */ -function extensionCapabilities_read(client: Client) { - //#region extensionCapabilities_read - const extensions = client.getServerCapabilities()?.extensions ?? {}; - - if ('com.example/feature-flags' in extensions) { - // Advertised on this connection; the entry's value is its settings object. - } - //#endregion extensionCapabilities_read -} - -// --------------------------------------------------------------------------- -// Authentication -// --------------------------------------------------------------------------- - -/** Example: Minimal AuthProvider for bearer auth with externally-managed tokens. */ -async function auth_tokenProvider(getStoredToken: () => Promise<string>) { - //#region auth_tokenProvider - const authProvider: AuthProvider = { token: async () => getStoredToken() }; - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); - //#endregion auth_tokenProvider - return transport; -} - -/** Example: Client credentials auth for service-to-service communication. */ -async function auth_clientCredentials() { - //#region auth_clientCredentials - const authProvider = new ClientCredentialsProvider({ - clientId: 'my-service', - clientSecret: 'my-secret' - }); - - const client = new Client({ name: 'my-client', version: '1.0.0' }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); - - await client.connect(transport); - //#endregion auth_clientCredentials -} - -/** Example: Private key JWT auth. */ -async function auth_privateKeyJwt(pemEncodedKey: string) { - //#region auth_privateKeyJwt - const authProvider = new PrivateKeyJwtProvider({ - clientId: 'my-service', - privateKey: pemEncodedKey, - algorithm: 'RS256' - }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); - //#endregion auth_privateKeyJwt - return transport; -} - -/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */ -async function auth_crossAppAccess(getIdToken: () => Promise<string>) { - //#region auth_crossAppAccess - const authProvider = new CrossAppAccessProvider({ - assertion: async ctx => { - // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn - const result = await discoverAndRequestJwtAuthGrant({ - idpUrl: 'https://idp.example.com', - audience: ctx.authorizationServerUrl, - resource: ctx.resourceUrl, - idToken: await getIdToken(), - clientId: 'my-idp-client', - clientSecret: 'my-idp-secret', - scope: ctx.scope, - fetchFn: ctx.fetchFn - }); - return result.jwtAuthGrant; - }, - clientId: 'my-mcp-client', - clientSecret: 'my-mcp-secret' - }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); - //#endregion auth_crossAppAccess - return transport; -} - -/** - * Example: Minimal `OAuthClientProvider` for the authorization-code flow. - * Client credentials are stored per authorization-server `issuer` (SEP-2352). - */ -function auth_oauthClientProvider(onRedirect: (url: URL) => void) { - //#region auth_oauthClientProvider - class MyOAuthProvider implements OAuthClientProvider { - // Key DCR-obtained credentials by issuer so a client_id registered with one - // authorization server is never returned for another (SEP-2352). - private creds = new Map<string, OAuthClientInformationMixed>(); - private storedTokens?: OAuthTokens; - private verifier?: string; - private discovery?: OAuthDiscoveryState; - lastState?: string; - - readonly redirectUrl = 'http://localhost:8090/callback'; - readonly clientMetadata: OAuthClientMetadata = { - client_name: 'My MCP Client', - redirect_uris: ['http://localhost:8090/callback'], - // Loopback redirect → the SDK would default this to 'native'; set - // explicitly when the heuristic is wrong for your deployment (SEP-837). - application_type: 'native' - }; - - clientInformation(ctx?: OAuthClientInformationContext) { - return ctx ? this.creds.get(ctx.issuer) : undefined; - } - saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { - if (ctx) this.creds.set(ctx.issuer, info); - } - tokens() { - return this.storedTokens; - } - saveTokens(tokens: OAuthTokens) { - // In production, persist to OS keychain / secure storage — never plain files. - this.storedTokens = tokens; - } - // CSRF binding for the redirect — the SDK puts this on the authorize URL; - // your callback handler compares it before calling `finishAuth`. - state() { - this.lastState = crypto.randomUUID(); - return this.lastState; - } - // Callback-leg AS-binding (SEP-2352): record what discovery resolved before - // the redirect so the SDK can verify the code is exchanged at the same AS. - saveDiscoveryState(state: OAuthDiscoveryState) { - this.discovery = state; - } - discoveryState() { - return this.discovery; - } - redirectToAuthorization(url: URL) { - onRedirect(url); - } - saveCodeVerifier(v: string) { - this.verifier = v; - } - codeVerifier() { - if (!this.verifier) throw new Error('no code verifier'); - return this.verifier; - } - } - - const provider = new MyOAuthProvider(); - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - authProvider: provider - }); - //#endregion auth_oauthClientProvider - - //#region auth_validateResourceURL - class PinnedResourceProvider extends MyOAuthProvider { - async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> { - const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) - if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { - throw new Error(`Refusing resource ${resource} for server ${expected.href}`); - } - return expected; - } - } - //#endregion auth_validateResourceURL - void PinnedResourceProvider; - - return { provider, transport }; -} - -/** Example: Handling the OAuth callback — extract `iss` for RFC 9207 validation. */ -async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastState?: string }, waitForCallback: () => Promise<string>) { - //#region auth_finishAuth - const client = new Client({ name: 'my-client', version: '1.0.0' }); - const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); - try { - await client.connect(transport); - return client; - } catch (error) { - // With version negotiation, the connect-time 401 may surface wrapped as - // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. - const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; - if (!(root instanceof UnauthorizedError)) throw error; - // The transport called redirectToAuthorization(); fall through to the browser callback. - } - - const callbackUrl = await waitForCallback(); - const params = new URL(callbackUrl).searchParams; - - // The SDK does not validate `state` — compare it to the value your provider generated. - if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); - - try { - // Preferred: hand over the whole query — the SDK extracts `code` and - // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived - // `error`/`error_description` text on mismatch. - await transport.finishAuth(params); - } catch (error) { - if (error instanceof IssuerMismatchError) { - // Mix-up attack: do NOT render params.get('error_description') to the user. - throw new Error('Authorization failed: issuer mismatch'); - } - throw error; - } - - // Reconnect on a FRESH transport — a started transport cannot be restarted; - // OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. - await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); - return client; - //#endregion auth_finishAuth -} - -// --------------------------------------------------------------------------- -// Using server features -// --------------------------------------------------------------------------- - -/** Example: List and call tools. */ -async function callTool_basic(client: Client) { - //#region callTool_basic - const { tools } = await client.listTools(); - console.log( - 'Available tools:', - tools.map(t => t.name) - ); - - const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 } - }); - console.log(result.content); - //#endregion callTool_basic -} - -/** Example: Structured tool output. */ -async function callTool_structuredOutput(client: Client) { - //#region callTool_structuredOutput - const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 } - }); - - // Machine-readable output for the client application. SEP-2106: structuredContent is - // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. - if (result.structuredContent !== undefined) { - const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } - if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { - console.log(sc.bmi); - } - } - //#endregion callTool_structuredOutput -} - -/** Example: Track progress of a long-running tool call. */ -async function callTool_progress(client: Client) { - //#region callTool_progress - const result = await client.callTool( - { name: 'long-operation', arguments: {} }, - { - onprogress: ({ progress, total }: { progress: number; total?: number }) => { - console.log(`Progress: ${progress}/${total ?? '?'}`); - }, - resetTimeoutOnProgress: true, - maxTotalTimeout: 600_000 - } - ); - console.log(result.content); - //#endregion callTool_progress -} - -/** Example: List and read resources. */ -async function readResource_basic(client: Client) { - //#region readResource_basic - const { resources } = await client.listResources(); - console.log( - 'Available resources:', - resources.map(r => r.name) - ); - - const { contents } = await client.readResource({ uri: 'config://app' }); - for (const item of contents) { - console.log(item); - } - //#endregion readResource_basic -} - -/** Example: Subscribe to resource changes. */ -async function subscribeResource_basic(client: Client) { - //#region subscribeResource_basic - await client.subscribeResource({ uri: 'config://app' }); - - client.setNotificationHandler('notifications/resources/updated', async notification => { - if (notification.params.uri === 'config://app') { - const { contents } = await client.readResource({ uri: 'config://app' }); - console.log('Config updated:', contents); - } - }); - - // Later: stop receiving updates - await client.unsubscribeResource({ uri: 'config://app' }); - //#endregion subscribeResource_basic -} - -/** Example: List and get prompts. */ -async function getPrompt_basic(client: Client) { - //#region getPrompt_basic - const { prompts } = await client.listPrompts(); - console.log( - 'Available prompts:', - prompts.map(p => p.name) - ); - - const { messages } = await client.getPrompt({ - name: 'review-code', - arguments: { code: 'console.log("hello")' } - }); - console.log(messages); - //#endregion getPrompt_basic -} - -/** Example: Request argument completions. */ -async function complete_basic(client: Client) { - //#region complete_basic - const { completion } = await client.complete({ - ref: { - type: 'ref/prompt', - name: 'review-code' - }, - argument: { - name: 'language', - value: 'type' - } - }); - console.log(completion.values); // e.g. ['typescript'] - //#endregion complete_basic -} - -// --------------------------------------------------------------------------- -// Response caching -// --------------------------------------------------------------------------- - -/** Example: Per-call cache disposition via cacheMode. */ -async function responseCache_basic(client: Client) { - //#region responseCache_basic - const tools = await client.listTools(); // network, then cached for the server's ttlMs - const again = await client.listTools(); // served from cache while still fresh - - await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store - await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write - //#endregion responseCache_basic - void tools; - void again; -} - -// --------------------------------------------------------------------------- -// Notifications -// --------------------------------------------------------------------------- - -/** Example: Handle log messages and list-change notifications. */ -function notificationHandler_basic(client: Client) { - //#region notificationHandler_basic - // Server log messages (sent by the server during request processing) - client.setNotificationHandler('notifications/message', notification => { - const { level, data } = notification.params; - console.log(`[${level}]`, data); - }); - - // Server's resource list changed — re-fetch the list - client.setNotificationHandler('notifications/resources/list_changed', async () => { - const { resources } = await client.listResources(); - console.log('Resources changed:', resources.length); - }); - //#endregion notificationHandler_basic -} - -/** Example: Control server log level. */ -async function setLoggingLevel_basic(client: Client) { - //#region setLoggingLevel_basic - await client.setLoggingLevel('warning'); - //#endregion setLoggingLevel_basic -} - -/** Example: Per-request log-level opt-in on a 2026-07-28 connection. */ -async function logLevelMeta_modern(client: Client) { - //#region logLevelMeta_modern - const result = await client.callTool({ - name: 'fetch-data', - arguments: { url: 'https://example.com' }, - _meta: { [LOG_LEVEL_META_KEY]: 'debug' } - }); - //#endregion logLevelMeta_modern - void result; -} - -/** Example: Automatic list-change tracking via the listChanged option. */ -async function listChanged_basic() { - //#region listChanged_basic - const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - listChanged: { - tools: { - onChanged: (error, tools) => { - if (error) { - console.error('Failed to refresh tools:', error); - return; - } - console.log('Tools updated:', tools); - } - }, - prompts: { - onChanged: (error, prompts) => console.log('Prompts updated:', prompts) - } - } - } - ); - //#endregion listChanged_basic - return client; -} - -/** Example: Open a subscriptions/listen stream explicitly (2026-07-28). */ -async function listen_basic(client: Client) { - //#region listen_basic - client.setNotificationHandler('notifications/tools/list_changed', async () => { - const { tools } = await client.listTools(); - console.log('Tools changed:', tools.length); - }); - client.setNotificationHandler('notifications/resources/updated', async notification => { - console.log('Resource updated:', notification.params.uri); - }); - - const subscription = await client.listen({ - toolsListChanged: true, - resourceSubscriptions: ['config://app'] - }); - console.log('Server honored:', subscription.honoredFilter); - - // Later: tear the stream down - await subscription.close(); - //#endregion listen_basic -} - -/** Example: Watch loop that re-listens on unexpected closes. */ -async function listen_watchLoop(client: Client, watching: boolean) { - //#region listen_watchLoop - while (watching) { - const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); - const reason = await sub.closed; - if (reason !== 'remote') break; // 'local' or 'graceful': done - await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen - } - //#endregion listen_watchLoop -} - -// --------------------------------------------------------------------------- -// Handling server-initiated requests -// --------------------------------------------------------------------------- - -/** Example: Declare client capabilities for sampling, elicitation, and roots. */ -function capabilities_declaration() { - //#region capabilities_declaration - const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - capabilities: { - sampling: {}, - elicitation: { form: {} }, - roots: { listChanged: true } - } - } - ); - //#endregion capabilities_declaration - return client; -} - -/** Example: Handle a sampling request from the server. */ -function sampling_handler(client: Client) { - //#region sampling_handler - client.setRequestHandler('sampling/createMessage', async request => { - const lastMessage = request.params.messages.at(-1); - console.log('Sampling request:', lastMessage); - - // In production, send messages to your LLM here - return { - model: 'my-model', - role: 'assistant' as const, - content: { - type: 'text' as const, - text: 'Response from the model' - } - }; - }); - //#endregion sampling_handler -} - -/** Example: Handle an elicitation request from the server. */ -function elicitation_handler(client: Client) { - //#region elicitation_handler - client.setRequestHandler('elicitation/create', async request => { - console.log('Server asks:', request.params.message); - - if (request.params.mode === 'form') { - // Present the schema-driven form to the user - console.log('Schema:', request.params.requestedSchema); - return { action: 'accept', content: { confirm: true } }; - } - - return { action: 'decline' }; - }); - //#endregion elicitation_handler -} - -/** Example: Expose filesystem roots to the server. */ -function roots_handler(client: Client) { - //#region roots_handler - client.setRequestHandler('roots/list', async () => { - return { - roots: [ - { uri: 'file:///home/user/projects/my-app', name: 'My App' }, - { uri: 'file:///home/user/data', name: 'Data' } - ] - }; - }); - //#endregion roots_handler -} - -/** Example: Manual multi-round-trip handling with autoFulfill disabled (2026-07-28). */ -async function inputRequired_manual(transport: StreamableHTTPClientTransport) { - //#region inputRequired_manual - const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - capabilities: { elicitation: { form: {} } }, - versionNegotiation: { mode: 'auto' }, - inputRequired: { autoFulfill: false } - } - ); - await client.connect(transport); - - const value = (await client.request( - { method: 'tools/call', params: { name: 'deploy', arguments: { env: 'prod' } } }, - { allowInputRequired: true } - )) as CallToolResult | InputRequiredResult; - - if (isInputRequiredResult(value)) { - // Collect responses for value.inputRequests from your UI, then retry: - await client.request( - { - method: 'tools/call', - params: { - name: 'deploy', - arguments: { env: 'prod' }, - inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, - requestState: value.requestState // echo byte-exact - } - }, - { allowInputRequired: true } - ); - } - //#endregion inputRequired_manual -} - -// --------------------------------------------------------------------------- -// Error handling -// --------------------------------------------------------------------------- - -/** Example: Tool errors vs protocol errors. */ -async function errorHandling_toolErrors(client: Client) { - //#region errorHandling_toolErrors - try { - const result = await client.callTool({ - name: 'fetch-data', - arguments: { url: 'https://example.com' } - }); - - // Tool-level error: the tool ran but reported a problem - if (result.isError) { - console.error('Tool error:', result.content); - return; - } - - console.log('Success:', result.content); - } catch (error) { - // Protocol-level error: the request itself failed - if (error instanceof ProtocolError) { - console.error(`Protocol error ${error.code}: ${error.message}`); - } else if (error instanceof SdkError) { - console.error(`SDK error [${error.code}]: ${error.message}`); - } else { - throw error; - } - } - //#endregion errorHandling_toolErrors -} - -/** Example: Connection lifecycle callbacks. */ -function errorHandling_lifecycle(client: Client) { - //#region errorHandling_lifecycle - // Out-of-band errors (SSE disconnects, parse errors) - client.onerror = error => { - console.error('Transport error:', error.message); - }; - - // Connection closed (pending requests are rejected with CONNECTION_CLOSED) - client.onclose = () => { - console.log('Connection closed'); - }; - //#endregion errorHandling_lifecycle -} - -/** Example: Custom timeouts. */ -async function errorHandling_timeout(client: Client) { - //#region errorHandling_timeout - try { - const result = await client.callTool( - { name: 'slow-operation', arguments: {} }, - { timeout: 120_000 } // 2 minutes instead of the default 60 seconds - ); - console.log(result.content); - } catch (error) { - if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { - console.error('Request timed out'); - } - } - //#endregion errorHandling_timeout -} - -/** Example: Typed HTTP transport errors. */ -async function errorHandling_http(client: Client, transport: StreamableHTTPClientTransport) { - //#region errorHandling_http - try { - await client.connect(transport); - } catch (error) { - if (error instanceof SdkHttpError) { - console.error(`HTTP ${error.status} (${error.statusText ?? ''}) [${error.code}]`); - } else { - throw error; - } - } - //#endregion errorHandling_http -} - -// --------------------------------------------------------------------------- -// Advanced patterns -// --------------------------------------------------------------------------- - -/** Example: Client middleware that adds a custom header. */ -async function middleware_basic() { - //#region middleware_basic - const authMiddleware = createMiddleware(async (next, input, init) => { - const headers = new Headers(init?.headers); - headers.set('X-Custom-Header', 'my-value'); - return next(input, { ...init, headers }); - }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - fetch: applyMiddlewares(authMiddleware)(fetch) - }); - //#endregion middleware_basic - return transport; -} - -/** Example: Attach W3C Trace Context to a single request via `_meta`. */ -async function traceContext_perRequest(client: Client) { - //#region traceContext_perRequest - // Values would normally come from your tracer's active span context. - const result = await client.callTool({ - name: 'calculate-bmi', - arguments: { weightKg: 70, heightM: 1.75 }, - _meta: { - [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', - [TRACESTATE_META_KEY]: 'vendor1=opaqueValue1' - } - }); - console.log(result.content); - //#endregion traceContext_perRequest -} - -/** Example: Client middleware that injects trace context into every outgoing request. */ -async function traceContext_middleware() { - //#region traceContext_middleware - const traceContextMiddleware = createMiddleware(async (next, input, init) => { - if (typeof init?.body !== 'string') { - return next(input, init); - } - const message = JSON.parse(init.body) as { - method?: string; - params?: { _meta?: Record<string, unknown>; [key: string]: unknown }; - }; - // Only requests and notifications carry params._meta; skip responses. - if (message.method === undefined) { - return next(input, init); - } - message.params = { - ...message.params, - _meta: { - ...message.params?._meta, - // Replace with values from your tracer's active span context. - [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' - } - }; - return next(input, { ...init, body: JSON.stringify(message) }); - }); - - const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { - fetch: applyMiddlewares(traceContextMiddleware)(fetch) - }); - //#endregion traceContext_middleware - return transport; -} - -/** Example: Track resumption tokens for SSE reconnection. */ -async function resumptionToken_basic(client: Client) { - //#region resumptionToken_basic - let lastToken: string | undefined; - - const result = await client.request( - { - method: 'tools/call', - params: { name: 'long-running-operation', arguments: {} } - }, - { - resumptionToken: lastToken, - onresumptiontoken: (token: string) => { - lastToken = token; - // Persist token to survive restarts - } - } - ); - console.log(result); - //#endregion resumptionToken_basic -} - -// Suppress unused-function warnings (functions exist solely for type-checking) -void connect_streamableHttp; -void connect_stdio; -void connect_sseFallback; -void Client_versionNegotiation; -void disconnect_streamableHttp; -void serverInstructions_basic; -void extensionCapabilities_read; -void auth_tokenProvider; -void auth_clientCredentials; -void auth_privateKeyJwt; -void auth_crossAppAccess; -void callTool_basic; -void callTool_structuredOutput; -void callTool_progress; -void readResource_basic; -void subscribeResource_basic; -void getPrompt_basic; -void complete_basic; -void responseCache_basic; -void notificationHandler_basic; -void setLoggingLevel_basic; -void logLevelMeta_modern; -void listChanged_basic; -void listen_basic; -void listen_watchLoop; -void capabilities_declaration; -void sampling_handler; -void elicitation_handler; -void roots_handler; -void inputRequired_manual; -void errorHandling_toolErrors; -void errorHandling_lifecycle; -void errorHandling_timeout; -void errorHandling_http; -void middleware_basic; -void traceContext_perRequest; -void traceContext_middleware; -void resumptionToken_basic; diff --git a/examples/guides/serverGuide.examples.ts b/examples/guides/serverGuide.examples.ts deleted file mode 100644 index a0ffb9cf93..0000000000 --- a/examples/guides/serverGuide.examples.ts +++ /dev/null @@ -1,886 +0,0 @@ -/** - * Type-checked examples for docs/server.md. - * - * Regions are synced into markdown code fences via `pnpm sync:snippets`. - * Each function wraps a single region. The function name matches the region name. - * - * @module - */ - -//#region imports -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; - -import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { - createMcpExpressApp, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth -} from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; -import type { CallToolResult, InputRequiredResult, OAuthMetadata, ResourceLink } from '@modelcontextprotocol/server'; -import { - acceptedContent, - completable, - createMcpHandler, - createRequestStateCodec, - inputRequired, - McpServer, - ResourceTemplate, - TRACEPARENT_META_KEY -} from '@modelcontextprotocol/server'; -import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; -//#endregion imports - -// --------------------------------------------------------------------------- -// Server instructions -// --------------------------------------------------------------------------- - -/** Example: McpServer with instructions for LLM guidance. */ -function instructions_basic() { - //#region instructions_basic - const server = new McpServer( - { name: 'db-server', version: '1.0.0' }, - { - instructions: - 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' - } - ); - //#endregion instructions_basic - return server; -} - -// --------------------------------------------------------------------------- -// Tools, resources, and prompts -// --------------------------------------------------------------------------- - -/** Example: Registering a tool with inputSchema, outputSchema, and structuredContent. */ -function registerTool_basic(server: McpServer) { - //#region registerTool_basic - server.registerTool( - 'calculate-bmi', - { - title: 'BMI Calculator', - description: 'Calculate Body Mass Index', - inputSchema: z.object({ - weightKg: z.number(), - heightM: z.number() - }), - outputSchema: z.object({ bmi: z.number() }) - }, - async ({ weightKg, heightM }) => { - const output = { bmi: weightKg / (heightM * heightM) }; - return { - content: [{ type: 'text', text: JSON.stringify(output) }], - structuredContent: output - }; - } - ); - //#endregion registerTool_basic -} - -/** Example: Tool returning resource_link content items. */ -function registerTool_resourceLink(server: McpServer) { - //#region registerTool_resourceLink - server.registerTool( - 'list-files', - { - title: 'List Files', - description: 'Returns files as resource links without embedding content' - }, - async (): Promise<CallToolResult> => { - const links: ResourceLink[] = [ - { - type: 'resource_link', - uri: 'file:///projects/readme.md', - name: 'README', - mimeType: 'text/markdown' - }, - { - type: 'resource_link', - uri: 'file:///projects/config.json', - name: 'Config', - mimeType: 'application/json' - } - ]; - return { content: links }; - } - ); - //#endregion registerTool_resourceLink -} - -/** Example: Tool with explicit error handling using isError. */ -function registerTool_errorHandling(server: McpServer) { - //#region registerTool_errorHandling - server.registerTool( - 'fetch-data', - { - description: 'Fetch data from a URL', - inputSchema: z.object({ url: z.string() }) - }, - async ({ url }): Promise<CallToolResult> => { - try { - const res = await fetch(url); - if (!res.ok) { - return { - content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], - isError: true - }; - } - const text = await res.text(); - return { content: [{ type: 'text', text }] }; - } catch (error) { - return { - content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : String(error)}` }], - isError: true - }; - } - } - ); - //#endregion registerTool_errorHandling -} - -/** Example: Tool with annotations hinting at behavior. */ -function registerTool_annotations(server: McpServer) { - //#region registerTool_annotations - server.registerTool( - 'delete-file', - { - description: 'Delete a file from the project', - inputSchema: z.object({ path: z.string() }), - annotations: { - title: 'Delete File', - destructiveHint: true, - idempotentHint: true - } - }, - async ({ path }): Promise<CallToolResult> => { - // ... perform deletion ... - return { content: [{ type: 'text', text: `Deleted ${path}` }] }; - } - ); - //#endregion registerTool_annotations -} - -/** Example: Advertising icons a client can render in its UI for a tool. */ -function registerTool_icons(server: McpServer) { - //#region registerTool_icons - server.registerTool( - 'generate-chart', - { - title: 'Generate Chart', - description: 'Render a chart from a series of numbers', - inputSchema: z.object({ data: z.array(z.number()) }), - // Icons a client may render in its UI. `src` is required; `mimeType`, - // `sizes`, and `theme` ('light' | 'dark') are optional hints. - icons: [ - { src: 'https://example.com/icons/chart.svg', mimeType: 'image/svg+xml', sizes: ['any'] }, - { src: 'https://example.com/icons/chart-48.png', mimeType: 'image/png', sizes: ['48x48'], theme: 'light' } - ] - }, - async ({ data }): Promise<CallToolResult> => { - // ... render chart ... - return { content: [{ type: 'text', text: `Charted ${data.length} points` }] }; - } - ); - //#endregion registerTool_icons -} - -/** Example: Registering a static resource at a fixed URI. */ -function registerResource_static(server: McpServer) { - //#region registerResource_static - server.registerResource( - 'config', - 'config://app', - { - title: 'Application Config', - description: 'Application configuration data', - mimeType: 'text/plain' - }, - async uri => ({ - contents: [{ uri: uri.href, text: 'App configuration here' }] - }) - ); - //#endregion registerResource_static -} - -/** Example: Dynamic resource with ResourceTemplate and listing. */ -function registerResource_template(server: McpServer) { - //#region registerResource_template - server.registerResource( - 'user-profile', - new ResourceTemplate('user://{userId}/profile', { - list: async () => ({ - resources: [ - { uri: 'user://123/profile', name: 'Alice' }, - { uri: 'user://456/profile', name: 'Bob' } - ] - }) - }), - { - title: 'User Profile', - description: 'User profile data', - mimeType: 'application/json' - }, - async (uri, { userId }) => ({ - contents: [ - { - uri: uri.href, - text: JSON.stringify({ userId, name: 'Example User' }) - } - ] - }) - ); - //#endregion registerResource_template -} - -/** Example: Registering a prompt with argsSchema. */ -function registerPrompt_basic(server: McpServer) { - //#region registerPrompt_basic - server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices and potential issues', - argsSchema: z.object({ - code: z.string() - }) - }, - ({ code }) => ({ - messages: [ - { - role: 'user' as const, - content: { - type: 'text' as const, - text: `Please review this code:\n\n${code}` - } - } - ] - }) - ); - //#endregion registerPrompt_basic -} - -/** Example: Prompt with completable argsSchema for autocompletion. */ -function registerPrompt_completion(server: McpServer) { - //#region registerPrompt_completion - server.registerPrompt( - 'review-code', - { - title: 'Code Review', - description: 'Review code for best practices', - argsSchema: z.object({ - language: completable(z.string().describe('Programming language'), value => - ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) - ) - }) - }, - ({ language }) => ({ - messages: [ - { - role: 'user' as const, - content: { - type: 'text' as const, - text: `Review this ${language} code for best practices.` - } - } - ] - }) - ); - //#endregion registerPrompt_completion -} - -// --------------------------------------------------------------------------- -// Extension capabilities -// --------------------------------------------------------------------------- - -/** Example: Declare an extension capability with its settings. */ -function extensionCapabilities_register(server: McpServer) { - //#region extensionCapabilities_register - server.server.registerCapabilities({ - extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } } - }); - //#endregion extensionCapabilities_register -} - -// --------------------------------------------------------------------------- -// Cache hints -// --------------------------------------------------------------------------- - -/** Example: cache hints via ServerOptions.cacheHints and a per-resource cacheHint. */ -function cacheHints_basic() { - //#region cacheHints_basic - const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { - cacheHints: { - // The tool list is the same for every caller and rarely changes: - 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } - } - } - ); - - server.registerResource( - 'config', - 'config://app', - { - mimeType: 'text/plain', - // Wins field-by-field over a cacheHints['resources/read'] entry; - // cacheScope stays at the 'private' default here. - cacheHint: { ttlMs: 300_000 } - }, - async uri => ({ - contents: [{ uri: uri.href, text: 'App configuration here' }] - }) - ); - //#endregion cacheHints_basic - return server; -} - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - -/** Example: Server with logging capability + tool that logs progress messages. */ -function registerTool_logging() { - //#region logging_capability - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - //#endregion logging_capability - - //#region registerTool_logging - server.registerTool( - 'fetch-data', - { - description: 'Fetch data from an API', - inputSchema: z.object({ url: z.string() }) - }, - async ({ url }, ctx): Promise<CallToolResult> => { - await ctx.mcpReq.log('info', `Fetching ${url}`); - const res = await fetch(url); - await ctx.mcpReq.log('debug', `Response status: ${res.status}`); - const text = await res.text(); - return { content: [{ type: 'text', text }] }; - } - ); - //#endregion registerTool_logging - return server; -} - -// --------------------------------------------------------------------------- -// Progress -// --------------------------------------------------------------------------- - -/** Example: Tool that sends progress notifications during a long-running operation. */ -function registerTool_progress(server: McpServer) { - //#region registerTool_progress - server.registerTool( - 'process-files', - { - description: 'Process files with progress updates', - inputSchema: z.object({ files: z.array(z.string()) }) - }, - async ({ files }, ctx): Promise<CallToolResult> => { - const progressToken = ctx.mcpReq._meta?.progressToken; - - for (let i = 0; i < files.length; i++) { - // ... process files[i] ... - - if (progressToken !== undefined) { - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: i + 1, - total: files.length, - message: `Processed ${files[i]}` - } - }); - } - } - - return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; - } - ); - //#endregion registerTool_progress -} - -/** Example: Tool that reads W3C Trace Context from request `_meta`. */ -function registerTool_traceContext(server: McpServer) { - //#region registerTool_traceContext - server.registerTool( - 'traced-operation', - { - description: 'Operation that participates in distributed tracing', - inputSchema: z.object({ query: z.string() }) - }, - async ({ query }, ctx): Promise<CallToolResult> => { - // e.g. '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' - const traceparent = ctx.mcpReq._meta?.[TRACEPARENT_META_KEY]; - if (typeof traceparent === 'string') { - // Continue the caller's trace, e.g. start a child span with your - // OpenTelemetry tracer using this trace context. - } - - return { content: [{ type: 'text', text: `Results for ${query}` }] }; - } - ); - //#endregion registerTool_traceContext -} - -// --------------------------------------------------------------------------- -// Change notifications -// --------------------------------------------------------------------------- - -/** Example: hand-wired resources/subscribe handlers + sendResourceUpdated (2025-era). */ -function subscriptions_legacy() { - //#region subscriptions_legacy - const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { capabilities: { resources: { subscribe: true, listChanged: true } } } - ); - - const subscriptions = new Set<string>(); - server.server.setRequestHandler('resources/subscribe', async request => { - subscriptions.add(request.params.uri); - return {}; - }); - server.server.setRequestHandler('resources/unsubscribe', async request => { - subscriptions.delete(request.params.uri); - return {}; - }); - - // When the underlying data changes: - async function onConfigChanged() { - if (subscriptions.has('config://app')) { - await server.server.sendResourceUpdated({ uri: 'config://app' }); - } - } - //#endregion subscriptions_legacy - return onConfigChanged; -} - -/** Example: publishing change events through the createMcpHandler notify facade (2026-07-28). */ -function subscriptions_notify(buildServer: () => McpServer) { - //#region subscriptions_notify - const handler = createMcpHandler(() => buildServer()); - - // When the underlying data changes: - handler.notify.resourceUpdated('config://app'); - handler.notify.toolsChanged(); - //#endregion subscriptions_notify - return handler; -} - -// --------------------------------------------------------------------------- -// Server-initiated requests -// --------------------------------------------------------------------------- - -/** Example: Tool that uses sampling to request an LLM completion from the client. */ -function registerTool_sampling(server: McpServer) { - //#region registerTool_sampling - server.registerTool( - 'summarize', - { - description: 'Summarize text using the client LLM', - inputSchema: z.object({ text: z.string() }) - }, - async ({ text }, ctx): Promise<CallToolResult> => { - const response = await ctx.mcpReq.requestSampling({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - return { - content: [ - { - type: 'text', - text: `Model (${response.model}): ${JSON.stringify(response.content)}` - } - ] - }; - } - ); - //#endregion registerTool_sampling -} - -/** Example: Tool that uses form elicitation to collect user input. */ -function registerTool_elicitation(server: McpServer) { - //#region registerTool_elicitation - server.registerTool( - 'collect-feedback', - { - description: 'Collect user feedback via a form', - inputSchema: z.object({}) - }, - async (_args, ctx): Promise<CallToolResult> => { - const result = await ctx.mcpReq.elicitInput({ - mode: 'form', - message: 'Please share your feedback:', - requestedSchema: { - type: 'object', - properties: { - rating: { - type: 'number', - title: 'Rating (1\u20135)', - minimum: 1, - maximum: 5 - }, - comment: { type: 'string', title: 'Comment' } - }, - required: ['rating'] - } - }); - if (result.action === 'accept') { - return { - content: [ - { - type: 'text', - text: `Thanks! ${JSON.stringify(result.content)}` - } - ] - }; - } - return { content: [{ type: 'text', text: 'Feedback declined.' }] }; - } - ); - //#endregion registerTool_elicitation -} - -/** Example: Tool that requests the client's filesystem roots. */ -function registerTool_roots(server: McpServer) { - //#region registerTool_roots - server.registerTool( - 'list-workspace-files', - { - description: 'List files across all workspace roots', - inputSchema: z.object({}) - }, - async (_args, _ctx): Promise<CallToolResult> => { - const { roots } = await server.server.listRoots(); - const summary = roots.map(r => `${r.name ?? r.uri}: ${r.uri}`).join('\n'); - return { content: [{ type: 'text', text: summary }] }; - } - ); - //#endregion registerTool_roots -} - -/** Example: write-once tool requesting input via an input_required return (2026-07-28). */ -function registerTool_inputRequired(server: McpServer) { - //#region registerTool_inputRequired - server.registerTool( - 'deploy', - { - description: 'Deploy after user confirmation', - inputSchema: z.object({ env: z.string() }) - }, - async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (confirmed?.confirm !== true) { - return inputRequired({ - inputRequests: { - confirm: inputRequired.elicit({ - message: `Deploy to ${env}?`, - requestedSchema: { - type: 'object', - properties: { confirm: { type: 'boolean' } }, - required: ['confirm'] - } - }) - } - }); - } - return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; - } - ); - //#endregion registerTool_inputRequired -} - -/** Example: HMAC-protected requestState via createRequestStateCodec + the verify hook. */ -function requestState_codec() { - //#region requestState_codec - const stateCodec = createRequestStateCodec<{ step: string }>({ - key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share across instances in a fleet - ttlSeconds: 600 - }); - - const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } - ); - //#endregion requestState_codec - - //#region requestState_mintDecode - server.registerTool( - 'wipe-cache', - { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, - async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => { - const state = ctx.mcpReq.requestState<{ step: string }>(); - - if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (confirmed?.confirm !== true) { - return inputRequired({ - inputRequests: { - confirm: inputRequired.elicit({ - message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } - }) - } - }); - } - // Mint only what the response above already proved: the user confirmed. - return inputRequired({ - inputRequests: { - scope: inputRequired.elicit({ - message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } - }) - }, - requestState: await stateCodec.mint({ step: 'confirmed' }) - }); - } - - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; - } - ); - //#endregion requestState_mintDecode - return server; -} - -// --------------------------------------------------------------------------- -// Transports -// --------------------------------------------------------------------------- - -/** Example: Stateful Streamable HTTP transport with session management. */ -async function streamableHttp_stateful() { - //#region streamableHttp_stateful - const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - - const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() - }); - - await server.connect(transport); - //#endregion streamableHttp_stateful -} - -/** Example: Stateless Streamable HTTP transport (no session persistence). */ -async function streamableHttp_stateless() { - //#region streamableHttp_stateless - const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - - const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - - await server.connect(transport); - //#endregion streamableHttp_stateless -} - -/** Example: Streamable HTTP with JSON response mode (no SSE). */ -async function streamableHttp_jsonResponse() { - //#region streamableHttp_jsonResponse - const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - - const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true - }); - - await server.connect(transport); - //#endregion streamableHttp_jsonResponse -} - -/** Example: stdio transport for local process-spawned integrations. */ -async function stdio_basic() { - //#region stdio_basic - const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - const transport = new StdioServerTransport(); - await server.connect(transport); - //#endregion stdio_basic -} - -/** Example: serveStdio serving both protocol eras on stdio from one factory. */ -function serveStdio_basic() { - //#region serveStdio_basic - serveStdio(() => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once; the same factory serves both eras - return server; - }); - //#endregion serveStdio_basic -} - -/** Example: createMcpHandler serving both protocol eras over HTTP from one factory. */ -function createMcpHandler_basic() { - //#region createMcpHandler_basic - const handler = createMcpHandler(() => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once; the same factory serves both eras - return server; - }); - //#endregion createMcpHandler_basic - return handler; -} - -/** Example: mounting an McpHttpHandler on node:http via toNodeHandler. */ -function createMcpHandler_node(handler: ReturnType<typeof createMcpHandler>) { - //#region createMcpHandler_node - createServer(toNodeHandler(handler)).listen(3000); - // Express: app.all('/mcp', toNodeHandler(handler)); - // behind express.json(): const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); - //#endregion createMcpHandler_node -} - -// --------------------------------------------------------------------------- -// Shutdown -// --------------------------------------------------------------------------- - -/** Example: Graceful shutdown for a stateful multi-session HTTP server. */ -function shutdown_statefulHttp(app: ReturnType<typeof createMcpExpressApp>, transports: Map<string, NodeStreamableHTTPServerTransport>) { - //#region shutdown_statefulHttp - // Capture the http.Server so it can be closed on shutdown - const httpServer = app.listen(3000); - - process.on('SIGINT', async () => { - httpServer.close(); - - for (const [sessionId, transport] of transports) { - await transport.close(); - transports.delete(sessionId); - } - - process.exit(0); - }); - //#endregion shutdown_statefulHttp -} - -/** Example: Graceful shutdown for a stdio server. */ -function shutdown_stdio(server: McpServer) { - //#region shutdown_stdio - process.on('SIGINT', async () => { - await server.close(); - process.exit(0); - }); - //#endregion shutdown_stdio -} - -// --------------------------------------------------------------------------- -// DNS rebinding protection -// --------------------------------------------------------------------------- - -/** Example: createMcpExpressApp with different host bindings. */ -function dnsRebinding_basic() { - //#region dnsRebinding_basic - // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) - const app = createMcpExpressApp(); - - // DNS rebinding protection also auto-enabled for localhost - const appLocal = createMcpExpressApp({ host: 'localhost' }); - - // No automatic protection when binding to all interfaces - const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); - //#endregion dnsRebinding_basic - return { app, appLocal, appOpen }; -} - -/** Example: createMcpExpressApp with allowedHosts for non-localhost binding. */ -function dnsRebinding_allowedHosts() { - //#region dnsRebinding_allowedHosts - const app = createMcpExpressApp({ - host: '0.0.0.0', - allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] - }); - //#endregion dnsRebinding_allowedHosts - return app; -} - -// --------------------------------------------------------------------------- -// Authorization (OAuth resource server) -// --------------------------------------------------------------------------- - -/** Example: protecting an HTTP server as an OAuth resource server. */ -function auth_resourceServer( - verifyJwt: (token: string) => Promise<{ sub: string; scopes: string[]; exp: number }>, - oauthMetadata: OAuthMetadata, - buildServer: () => McpServer -) { - //#region auth_resourceServer - const mcpServerUrl = new URL('https://api.example.com/mcp'); - - // Verify tokens however your deployment requires: JWT verification, - // RFC 7662 introspection, a call to your IdP. - const verifier: OAuthTokenVerifier = { - async verifyAccessToken(token) { - const payload = await verifyJwt(token); - return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; - } - }; - - // Public deployment: allow-list the public host (see DNS rebinding protection). - const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); - - // Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) and mirrors the - // authorization server's metadata, so clients can discover your AS. - app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); - - // 401/403 responses carry `WWW-Authenticate: Bearer …` with `resource_metadata` - // pointing at the document above. That challenge is what starts the client - // SDK's OAuth flow. - const auth = requireBearerAuth({ - verifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - }); - - const node = toNodeHandler(createMcpHandler(buildServer)); - app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); - //#endregion auth_resourceServer - return app; -} - -// Suppress unused-function warnings (functions exist solely for type-checking) -void instructions_basic; -void registerTool_basic; -void registerTool_resourceLink; -void registerTool_errorHandling; -void registerTool_annotations; -void registerTool_icons; -void registerTool_logging; -void registerTool_progress; -void registerTool_traceContext; -void registerTool_sampling; -void registerTool_elicitation; -void registerTool_roots; -void registerTool_inputRequired; -void requestState_codec; -void registerResource_static; -void registerResource_template; -void registerPrompt_basic; -void registerPrompt_completion; -void extensionCapabilities_register; -void cacheHints_basic; -void subscriptions_legacy; -void subscriptions_notify; -void streamableHttp_stateful; -void streamableHttp_stateless; -void streamableHttp_jsonResponse; -void stdio_basic; -void serveStdio_basic; -void createMcpHandler_basic; -void createMcpHandler_node; -void shutdown_statefulHttp; -void shutdown_stdio; -void dnsRebinding_basic; -void dnsRebinding_allowedHosts; -void auth_resourceServer; diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index 5910ed00e0..37bd1525a9 100644 --- a/examples/oauth-client-credentials/README.md +++ b/examples/oauth-client-credentials/README.md @@ -38,4 +38,4 @@ const authProvider = new PrivateKeyJwtProvider({ }); ``` -The full snippet lives in the client guide (`docs/client.md` → `auth_privateKeyJwt`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`. +The full snippet lives in [Machine auth › Sign with a private key instead of a secret](../../docs/clients/machine-auth.md#sign-with-a-private-key-instead-of-a-secret) (`guides/clients/machine-auth.examples.ts` → `privateKeyJwt_provider`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`. diff --git a/examples/server-quickstart/README.md b/examples/server-quickstart/README.md index 9e19c1e77f..32f6a6c7a4 100644 --- a/examples/server-quickstart/README.md +++ b/examples/server-quickstart/README.md @@ -1,5 +1,5 @@ # server-quickstart -Source for the [Server Quickstart](../../docs/server-quickstart.md) tutorial: a stdio weather server exposing `get-alerts` and `get-forecast` tools. The tutorial walks through `src/index.ts` end to end. +A stdio weather server exposing `get-alerts` and `get-forecast` tools (`src/index.ts`). It was the source for the retired server-quickstart tutorial; the current getting-started tutorial is [Build your first server](../../docs/get-started/first-server.md). -The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the server yourself, use the standalone manifests from the tutorial. +The `package.json` and `tsconfig.json` here are monorepo-internal (`workspace:`/`catalog:` protocols; typecheck-only in CI). To build the server yourself outside the monorepo, copy `src/index.ts` into a standalone project that depends on the published packages. diff --git a/packages/client/README.md b/packages/client/README.md index 3248ec9859..580c631b4b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -21,6 +21,6 @@ TypeScript ≥6.0 no longer auto-includes `@types/*` — add `"types": ["node"]` ## Documentation - **[Repository README](https://github.com/modelcontextprotocol/typescript-sdk#readme)** — overview, package layout, examples -- **[Client guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/client.md)** +- **[Client guide](https://ts.sdk.modelcontextprotocol.io/v2/clients/connect)** — connecting, calling tools, OAuth, and middleware - **[API reference](https://ts.sdk.modelcontextprotocol.io/v2/)** - **[MCP specification](https://modelcontextprotocol.io)** diff --git a/packages/server/README.md b/packages/server/README.md index 7f46a411df..62386bdcce 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -24,6 +24,7 @@ Optional framework adapters: [`@modelcontextprotocol/express`](https://www.npmjs ## Documentation - **[Repository README](https://github.com/modelcontextprotocol/typescript-sdk#readme)** — overview, package layout, examples -- **[Server guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/server.md)** +- **[Server guide](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools)** — tools, resources, prompts, and the rest of the server surface +- **[Serving guide](https://ts.sdk.modelcontextprotocol.io/v2/serving/http)** — stdio, HTTP, the framework adapters, sessions, and authorization - **[API reference](https://ts.sdk.modelcontextprotocol.io/v2/)** - **[MCP specification](https://modelcontextprotocol.io)** diff --git a/packages/server/src/server/requestStateCodec.ts b/packages/server/src/server/requestStateCodec.ts index 9fe499b369..37c1f857f4 100644 --- a/packages/server/src/server/requestStateCodec.ts +++ b/packages/server/src/server/requestStateCodec.ts @@ -147,7 +147,7 @@ export function createRequestStateCodec<T = unknown>(options: RequestStateCodecO if (subtle === undefined) { throw new TypeError( 'createRequestStateCodec requires the Web Crypto API (globalThis.crypto.subtle); ' + - 'see https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/faq.md for the Node.js polyfill instructions' + 'see https://ts.sdk.modelcontextprotocol.io/v2/troubleshooting for the Node.js polyfill instructions' ); } From ab189bbf8a7904e598ef410a2df7098022682636 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 16:05:27 +0000 Subject: [PATCH 16/27] docs: widen the doc column so typical code blocks fit without scrolling --- docs/.vitepress/theme/custom.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 2f03d12673..6883a9a8d0 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -6,6 +6,24 @@ * light, white on slate dark. We map that onto VitePress brand variables. */ +/* ------------------------------------------------------------------ layout */ + +/* + * The default theme caps the doc column at 688px (and the page at 1440px), + * which forces horizontal scrolling on most of our ~100-column code blocks. + * Let the layout breathe and the column grow so typical snippets fit; + * genuinely long lines still scroll inside their own block. + */ +:root { + --vp-layout-max-width: 1680px; +} + +/* !important: the default rule is a scoped component style ([data-v-…]), which + otherwise out-specifies any override written here. */ +.VPDoc.has-aside .content-container { + max-width: 960px !important; +} + /* ---------------------------------------------------------------- branding */ :root { From a78b3b76a0a6736eb22862438460867e635764a2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 16:52:05 +0000 Subject: [PATCH 17/27] docs: keep the internal behavior-surface-pins note off the published site --- docs/.vitepress/config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8f94309f62..80be1dc307 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -24,7 +24,7 @@ export default defineConfig({ title: 'MCP TypeScript SDK', description: 'The TypeScript SDK implementation of the Model Context Protocol specification.', base: '/v2/', - srcExclude: ['v1/**', '_meta/**'], + srcExclude: ['v1/**', '_meta/**', 'behavior-surface-pins.md'], sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io/v2/' }, markdown: { config(md) { From 241ec7f282086b89a2ca18a8457541e97fb861d7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 16:52:54 +0000 Subject: [PATCH 18/27] docs: drop the README-landing generator superseded by the landing page --- package.json | 1 - scripts/build-docs-index.ts | 32 -------------------------------- 2 files changed, 33 deletions(-) delete mode 100644 scripts/build-docs-index.ts diff --git a/package.json b/package.json index bbb593c297..71424cb8f3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "run:examples": "tsx scripts/examples/run-examples.ts", "docs:examples": "tsx scripts/run-guide-examples.ts", "docs:api": "typedoc", - "docs:index": "tsx scripts/build-docs-index.ts", "docs:dev": "vitepress dev docs", "docs:build": "pnpm docs:api && vitepress build docs", "docs:multi": "bash scripts/build-docs-site.sh", diff --git a/scripts/build-docs-index.ts b/scripts/build-docs-index.ts deleted file mode 100644 index 2aac179da9..0000000000 --- a/scripts/build-docs-index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Generate docs/index.md (the V2 site's landing page) from the repository README. - * - * The docs site lands on the README — the same model as the V1 site — so the README - * stays the single source of truth and the landing page can never drift from it. - * Links that only resolve on GitHub are rewritten for the site: - * - `docs/<page>.md` -> `./<page>.md` (index.md sits next to the guide pages) - * - other repo paths -> the GitHub blob URL - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const GITHUB = 'https://github.com/modelcontextprotocol/typescript-sdk/blob/main'; - -let markdown = readFileSync(join(repoRoot, 'README.md'), 'utf8'); - -markdown = markdown - // The README's "API docs" link points at the old typedoc site; on the landing it is the - // in-site API Reference section. - .replaceAll('](https://modelcontextprotocol.github.io/typescript-sdk/)', '](./api/index.md)') - .replaceAll('](docs/', '](./') - .replaceAll('](packages/', `](${GITHUB}/packages/`) - .replaceAll('](examples/', `](${GITHUB}/examples/`) - .replaceAll('](LICENSE)', `](${GITHUB}/LICENSE)`) - .replaceAll('](CONTRIBUTING.md)', `](${GITHUB}/CONTRIBUTING.md)`) - .replaceAll('](SECURITY.md)', `](${GITHUB}/SECURITY.md)`) - .replaceAll('](CODE_OF_CONDUCT.md)', `](${GITHUB}/CODE_OF_CONDUCT.md)`); - -writeFileSync(join(repoRoot, 'docs', 'index.md'), markdown); -console.log('docs/index.md generated from README.md'); From 6e26502d8122b78dda6d87b5b52be47eca2c4ead Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 16:56:44 +0000 Subject: [PATCH 19/27] docs(migration): repoint two links added on main to the new guide pages --- docs/migration/upgrade-to-v2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 4eb7aa7e83..e428c7505d 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1145,7 +1145,7 @@ version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto with or without a pin) the connect-time 401 currently surfaces wrapped as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at `error.data.cause` — unwrap before the check, as shown in the -[client guide's authentication section](../client.md#authentication). +[client OAuth guide](../clients/oauth.md). #### `auth()` options are now `AuthOptions` @@ -1777,7 +1777,7 @@ where an entry notes its own signature change: - `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options — including resumability: the per-request `resumptionToken` / `onresumptiontoken` request options carry over from v1 unchanged - ([Resumption tokens](../client.md#resumption-tokens) in the client guide). + ([Resume a dropped stream](../serving/sessions-state-scaling.md#resume-a-dropped-stream)). - `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). - The **`Transport` interface contract** — `start` / `send` / `close`, `onmessage` / From 0289512ae9a42a20941729398ec9d721f065d172 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 17:03:44 +0000 Subject: [PATCH 20/27] docs: refresh the v2 status banner and add a v2 feedback issue template The README's status block now says beta, asks for feedback through a new v2-labeled issue form, and points the doc links at the documentation site. The PR-restriction warning is reworded without the stale date. --- .github/ISSUE_TEMPLATE/v2-feedback.yml | 53 ++++++++++++++++++++++++++ README.md | 12 +++--- 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/v2-feedback.yml diff --git a/.github/ISSUE_TEMPLATE/v2-feedback.yml b/.github/ISSUE_TEMPLATE/v2-feedback.yml new file mode 100644 index 0000000000..0a91acbf70 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v2-feedback.yml @@ -0,0 +1,53 @@ +name: v2 feedback +description: Bugs, API friction, or docs gaps in v2 of the SDK +title: '[v2] ' +labels: ['v2'] +body: + - type: markdown + attributes: + value: | + Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in beta. + + Docs: https://ts.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://ts.sdk.modelcontextprotocol.io/v2/migration/ + - type: textarea + id: what + attributes: + label: What happened? + description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: false + - type: textarea + id: repro + attributes: + label: Code to reproduce + description: The smallest snippet or repository that shows it. For docs feedback, link the page instead. + render: TypeScript + validations: + required: false + - type: input + id: version + attributes: + label: SDK version + description: The published version (`npm ls @modelcontextprotocol/server @modelcontextprotocol/client`) or commit. + validations: + required: false + - type: dropdown + id: area + attributes: + label: Area + options: + - Server + - Client + - Transports + - Auth + - Documentation + - Migration / codemod + - Other + validations: + required: false diff --git a/README.md b/README.md index 663a9a8e38..6dbdeca800 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ <!-- prettier-ignore --> > [!IMPORTANT] -> **This is the `main` branch which contains v2 of the SDK (currently in development, pre-alpha).** +> **This is the `main` branch — v2 of the SDK, now in beta** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), implementing the [2026-07-28 MCP spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/). > -> We anticipate a stable v2 release in Q3 2026 along with the [updated MCP spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/). Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. +> **Have feedback? Please [open a v2 issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml)** — it is the most useful thing you can do for the SDK right now. The [v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/) starts with a ten-minute server tutorial. > -> For v1 documentation, see the [V1 API docs](https://ts.sdk.modelcontextprotocol.io/). For v2 API docs, see [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). +> We expect a stable release alongside the full release of the 2026-07-28 spec on July 28, 2026. Until then, **v1.x remains the supported release for production**; it keeps receiving bug fixes and security updates for at least 6 months after v2 ships. v1 documentation: [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/) · v2: [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). <!-- prettier-ignore --> > [!WARNING] -> **We're temporarily restricting PRs to contributors only to manage reviewer capacity while implementation work for the [new spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) is ongoing** +> **We're limiting pull requests to contributors while we land the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) implementation.** > -> Please continue to submit issues as your main source of feedback. We anticipate reopening once we have a stable release for the new spec, currently slated to launch on July 28, 2026. +> [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes. [![NPM Version - Server](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fserver?label=%40modelcontextprotocol%2Fserver)](https://www.npmjs.com/package/@modelcontextprotocol/server) [![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver) @@ -144,7 +144,7 @@ For runnable, end-to-end examples beyond the tutorials, see: - [Build a client](docs/get-started/first-client.md) — your first MCP client, step by step - [Documentation site](https://ts.sdk.modelcontextprotocol.io/v2/) — the full guides: tools, resources, prompts, serving over HTTP and stdio, clients, OAuth, and migration - [Troubleshooting](docs/troubleshooting.md) — common errors and their fixes -- [API docs](https://modelcontextprotocol.github.io/typescript-sdk/) +- [API reference](https://ts.sdk.modelcontextprotocol.io/v2/api/) - [MCP documentation](https://modelcontextprotocol.io/docs) - [MCP specification](https://modelcontextprotocol.io/specification/latest) From 67692562251dda94435b41ca2ae11c97f530ab4e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 17:16:17 +0000 Subject: [PATCH 21/27] docs: add an examples page to the get-started section --- docs/.vitepress/config.mts | 3 ++- docs/get-started/examples.md | 9 +++++++++ examples/README.md | 12 ++++++------ 3 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 docs/get-started/examples.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 80be1dc307..f99c9bcb8f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -58,7 +58,8 @@ export default defineConfig({ { text: 'Build a server', link: '/get-started/first-server' }, { text: 'Plug into a real host', link: '/get-started/real-host' }, { text: 'Build a client', link: '/get-started/first-client' }, - { text: 'Packages', link: '/get-started/packages' } + { text: 'Packages', link: '/get-started/packages' }, + { text: 'Examples', link: '/get-started/examples' } ] }, { diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md new file mode 100644 index 0000000000..2baa75d948 --- /dev/null +++ b/docs/get-started/examples.md @@ -0,0 +1,9 @@ +--- +shape: reference +--- + +# Examples + +Looking for code that shows how to do something specific? Browse the [example library](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — every example is a complete client–server pair that CI runs on every pull request, so they stay correct as the SDK changes. Or [suggest an addition](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml). + +[`cli-client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/cli-client) and [`todos-server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/todos-server) are the reference pair — a full LLM chat host and the server it drives — if you want to see everything working together in one place. diff --git a/examples/README.md b/examples/README.md index bbd80564a5..ea6f518018 100644 --- a/examples/README.md +++ b/examples/README.md @@ -66,12 +66,12 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] ## Excluded -| Directory | What it is | Why not in CI | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | -| [`guides/`](./guides/README.md) | Per-page snippet companions synced into the `docs/` guide pages | Typecheck-only; not a runnable pair. | -| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | -| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. | +| Directory | What it is | Why not in CI | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`guides/`](./guides/README.md) | Per-page snippet companions synced into the `docs/` guide pages | Not a client/server story pair; typechecked and executed by `pnpm docs:examples`. | +| `server-quickstart/`, `client-quickstart/` | Standalone starter projects (the original quickstart sources) | External network / API key; typecheck-only. | +| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. | ## Multi-node deployment patterns From 0bf12c645012117a8b4a00c6d8bc74815233b9f0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 17:16:17 +0000 Subject: [PATCH 22/27] chore(examples): format the guide companions and relax doc-snippet lint rules The guide companions are documentation snippets: filenames mirror their page slugs and every region must stay self-contained when rendered, so the example-app style rules do not apply to them. Formatting the files re-syncs the doc fences that mirror their regions. --- docs/clients/caching.md | 5 +---- docs/clients/calling.md | 5 ++++- docs/clients/roots.md | 5 +---- docs/get-started/first-client.md | 11 +++------- docs/servers/elicitation.md | 5 +---- docs/servers/input-required.md | 5 +---- docs/serving/authorization.md | 7 ++++++- docs/serving/express.md | 8 +++----- docs/serving/fastify.md | 8 +++----- docs/serving/hono.md | 8 +++----- docs/serving/web-standard.md | 11 ++++------ examples/eslint.config.mjs | 20 +++++++++++++++++++ examples/guides/clients/caching.examples.ts | 5 +---- examples/guides/clients/calling.examples.ts | 5 ++++- .../guides/clients/middleware.examples.ts | 8 +++----- examples/guides/clients/roots.examples.ts | 5 +---- .../get-started/firstClient.examples.ts | 11 +++------- examples/guides/get-started/src/index.ts | 11 +++------- .../guides/servers/elicitation.examples.ts | 5 +---- .../guides/servers/input-required.examples.ts | 10 ++-------- .../guides/serving/authorization.examples.ts | 7 ++++++- examples/guides/serving/express.examples.ts | 8 +++----- examples/guides/serving/fastify.examples.ts | 8 +++----- examples/guides/serving/hono.examples.ts | 8 +++----- .../guides/serving/webStandard.examples.ts | 11 ++++------ 25 files changed, 87 insertions(+), 113 deletions(-) diff --git a/docs/clients/caching.md b/docs/clients/caching.md index d15871bdb8..7616255742 100644 --- a/docs/clients/caching.md +++ b/docs/clients/caching.md @@ -71,10 +71,7 @@ Every method on the `ResponseCacheStore` interface may return a promise, so a Re When one shared store serves several principals, set `cachePartition` to a stable identity of the authorization context — the auth subject, for example. ```ts source="../../examples/guides/clients/caching.examples.ts#cachePartition_perUser" -const client = new Client( - { name: 'gateway', version: '1.0.0' }, - { responseCacheStore: sharedStore, cachePartition: userId } -); +const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId }); ``` `'private'`-scoped entries are stored under that partition and never read across it; `'public'`-scoped entries stay shared within the server's namespace. diff --git a/docs/clients/calling.md b/docs/clients/calling.md index c4ed292912..bb3864d20a 100644 --- a/docs/clients/calling.md +++ b/docs/clients/calling.md @@ -36,7 +36,10 @@ Pass a `cursor` — a page's `nextCursor` your application held on to — and `l ```ts source="../../examples/guides/clients/calling.examples.ts#listTools_onePage" const page = await client.listTools({ cursor: heldCursor }); -console.log(page.tools.map(tool => tool.name), page.nextCursor); +console.log( + page.tools.map(tool => tool.name), + page.nextCursor +); ``` The `orders` server hands out its three tools two per page, and `heldCursor` names the second page — one tool, nothing left to follow: diff --git a/docs/clients/roots.md b/docs/clients/roots.md index 48cf5ca62d..d725241d86 100644 --- a/docs/clients/roots.md +++ b/docs/clients/roots.md @@ -20,10 +20,7 @@ Send the path a call should act on as a tool argument ([Tools](../servers/tools. ```ts source="../../examples/guides/clients/roots.examples.ts#roots_capability" import { Client } from '@modelcontextprotocol/client'; -const client = new Client( - { name: 'workspace-client', version: '1.0.0' }, - { capabilities: { roots: { listChanged: true } } } -); +const client = new Client({ name: 'workspace-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); ``` Declare the capability before registering the handler: without it, `setRequestHandler('roots/list', …)` throws. diff --git a/docs/get-started/first-client.md b/docs/get-started/first-client.md index 7a61c2e0bb..8a980c51b2 100644 --- a/docs/get-started/first-client.md +++ b/docs/get-started/first-client.md @@ -89,14 +89,9 @@ The rejection is an ordinary `isError: true` result, so a model reads the messag The weather server registers no **resources** yet — a resource is data a client reads by URI, where a tool is an action it invokes. In `src/index.ts`, register one above the `return server` line. ```ts source="../../examples/guides/get-started/firstClient.examples.ts#firstClient_registerResource" -server.registerResource( - 'about', - 'weather://about', - { title: 'About this server', mimeType: 'text/plain' }, - async uri => ({ - contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] - }) -); +server.registerResource('about', 'weather://about', { title: 'About this server', mimeType: 'text/plain' }, async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] +})); ``` The read handler returns `contents` — a list, because one read can return several text or binary parts. [Resources](../servers/resources.md) covers templates, binary contents, and subscriptions. diff --git a/docs/servers/elicitation.md b/docs/servers/elicitation.md index cf8fc0694d..a4063512d7 100644 --- a/docs/servers/elicitation.md +++ b/docs/servers/elicitation.md @@ -51,10 +51,7 @@ On a 2026-07-28 connection `elicitInput` throws — a handler returns the reques The answer comes from the connected client's `elicitation/create` handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — [Handle requests from the server](../clients/server-requests.md) covers the client side in full. ```ts source="../../examples/guides/servers/elicitation.examples.ts#Client_elicitationHandler" -const client = new Client( - { name: 'feedback-host', version: '1.0.0' }, - { capabilities: { elicitation: { form: {}, url: {} } } } -); +const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } }); client.setRequestHandler('elicitation/create', async request => { if (request.params.mode === 'url') { diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 700acfecbf..6f731504f9 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -222,10 +222,7 @@ const stateCodec = createRequestStateCodec<{ step: string }>({ ttlSeconds: 600 }); -const server = new McpServer( - { name: 'releases', version: '1.0.0' }, - { requestState: { verify: stateCodec.verify } } -); +const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestState: { verify: stateCodec.verify } }); ``` With the hook in place, the accessor hands the handler `verify`'s decoded payload, and tampered or expired state never reaches the handler at all. Retrying `wipe-cache` with `requestState: 'tampered'` answers a wire-level protocol error: diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 374e3cf22d..a6163e197f 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -11,7 +11,12 @@ Your MCP server is an OAuth **resource server**: it verifies access tokens that ```ts source="../../examples/guides/serving/authorization.examples.ts#requireBearerAuth_basic" import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; diff --git a/docs/serving/express.md b/docs/serving/express.md index 36a61a2ab7..1d04189a97 100644 --- a/docs/serving/express.md +++ b/docs/serving/express.md @@ -19,11 +19,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/docs/serving/fastify.md b/docs/serving/fastify.md index 72ae18b1fa..1643ec4d48 100644 --- a/docs/serving/fastify.md +++ b/docs/serving/fastify.md @@ -19,11 +19,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/docs/serving/hono.md b/docs/serving/hono.md index ad02a3945e..a2adfde9c8 100644 --- a/docs/serving/hono.md +++ b/docs/serving/hono.md @@ -19,11 +19,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/docs/serving/web-standard.md b/docs/serving/web-standard.md index 4a8f9215dc..87d7439eb3 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -17,11 +17,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); @@ -40,8 +38,7 @@ import { hostHeaderValidationResponse, originValidationResponse } from '@modelco const guarded = { async fetch(request: Request): Promise<Response> { const rejected = - hostHeaderValidationResponse(request, ['api.example.com']) ?? - originValidationResponse(request, ['app.example.com']); + hostHeaderValidationResponse(request, ['api.example.com']) ?? originValidationResponse(request, ['app.example.com']); return rejected ?? handler.fetch(request); } }; diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs index 1d4848ec63..b42478deee 100644 --- a/examples/eslint.config.mjs +++ b/examples/eslint.config.mjs @@ -42,5 +42,25 @@ export default [ } ] } + }, + { + // Guide companions are documentation snippets: filenames mirror their page slugs, + // every //#region must stay self-contained when rendered on its page, and snippet + // style follows the docs register rather than the example-app style rules. + // The import restrictions above still apply. + files: ['guides/**/*.ts'], + rules: { + 'unicorn/filename-case': 'off', + 'unicorn/switch-case-braces': 'off', + 'unicorn/numeric-separators-style': 'off', + 'unicorn/import-style': 'off', + 'unicorn/no-await-expression-member': 'off', + 'unicorn/prefer-response-static-json': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + 'simple-import-sort/imports': 'off', + 'import/no-duplicates': 'off', + 'unicorn/require-array-join-separator': 'off', + 'unicorn/explicit-length-check': 'off' + } } ]; diff --git a/examples/guides/clients/caching.examples.ts b/examples/guides/clients/caching.examples.ts index a93e529ef7..2b4d7b3157 100644 --- a/examples/guides/clients/caching.examples.ts +++ b/examples/guides/clients/caching.examples.ts @@ -126,10 +126,7 @@ function responseCacheStore_shared() { function cachePartition_perUser(sharedStore: ResponseCacheStore, userId: string) { //#region cachePartition_perUser - const client = new Client( - { name: 'gateway', version: '1.0.0' }, - { responseCacheStore: sharedStore, cachePartition: userId } - ); + const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId }); //#endregion cachePartition_perUser return client; } diff --git a/examples/guides/clients/calling.examples.ts b/examples/guides/clients/calling.examples.ts index b7aea737e0..a4526caeb2 100644 --- a/examples/guides/clients/calling.examples.ts +++ b/examples/guides/clients/calling.examples.ts @@ -145,7 +145,10 @@ const heldCursor = 'page-2'; // "Let the SDK walk the pages" — one raw page, the output the page quotes. //#region listTools_onePage const page = await client.listTools({ cursor: heldCursor }); -console.log(page.tools.map(tool => tool.name), page.nextCursor); +console.log( + page.tools.map(tool => tool.name), + page.nextCursor +); //#endregion listTools_onePage // "Read structured output" — the narrowed `structuredContent` the page quotes. diff --git a/examples/guides/clients/middleware.examples.ts b/examples/guides/clients/middleware.examples.ts index aaa769041c..96bfdba035 100644 --- a/examples/guides/clients/middleware.examples.ts +++ b/examples/guides/clients/middleware.examples.ts @@ -105,11 +105,9 @@ const observeStatus = createMiddleware(async (next, input, init) => { const handler = createMcpHandler(() => { const server = new McpServer({ name: 'reports', version: '1.0.0' }); - server.registerTool( - 'ping', - { description: 'Reply with pong', inputSchema: z.object({ tag: z.string() }) }, - async ({ tag }) => ({ content: [{ type: 'text', text: `pong ${tag}` }] }) - ); + server.registerTool('ping', { description: 'Reply with pong', inputSchema: z.object({ tag: z.string() }) }, async ({ tag }) => ({ + content: [{ type: 'text', text: `pong ${tag}` }] + })); return server; }); type AnyFetch = (url: string | URL, init?: RequestInit) => Promise<Response>; diff --git a/examples/guides/clients/roots.examples.ts b/examples/guides/clients/roots.examples.ts index 9e36f72955..9df3640d1e 100644 --- a/examples/guides/clients/roots.examples.ts +++ b/examples/guides/clients/roots.examples.ts @@ -15,10 +15,7 @@ //#region roots_capability import { Client } from '@modelcontextprotocol/client'; -const client = new Client( - { name: 'workspace-client', version: '1.0.0' }, - { capabilities: { roots: { listChanged: true } } } -); +const client = new Client({ name: 'workspace-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); //#endregion roots_capability //#region roots_listHandler diff --git a/examples/guides/get-started/firstClient.examples.ts b/examples/guides/get-started/firstClient.examples.ts index 801f853113..4317ab4eb4 100644 --- a/examples/guides/get-started/firstClient.examples.ts +++ b/examples/guides/get-started/firstClient.examples.ts @@ -105,14 +105,9 @@ console.log(rejectedBlock.text); function firstClient_registerResource(server: McpServer) { //#region firstClient_registerResource - server.registerResource( - 'about', - 'weather://about', - { title: 'About this server', mimeType: 'text/plain' }, - async uri => ({ - contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] - }) - ); + server.registerResource('about', 'weather://about', { title: 'About this server', mimeType: 'text/plain' }, async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] + })); //#endregion firstClient_registerResource } void firstClient_registerResource; diff --git a/examples/guides/get-started/src/index.ts b/examples/guides/get-started/src/index.ts index 7fadfacd66..0673fee9c2 100644 --- a/examples/guides/get-started/src/index.ts +++ b/examples/guides/get-started/src/index.ts @@ -51,14 +51,9 @@ function createServer(): McpServer { ); // Added by docs/get-started/first-client.md ("Add a resource and read it"). - server.registerResource( - 'about', - 'weather://about', - { title: 'About this server', mimeType: 'text/plain' }, - async uri => ({ - contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] - }) - ); + server.registerResource('about', 'weather://about', { title: 'About this server', mimeType: 'text/plain' }, async uri => ({ + contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] + })); return server; } diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index a37be55ccb..f76ecff471 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -112,10 +112,7 @@ const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client // The client-side handler the page shows once (the full client story lives in // docs-v2/clients/server-requests.md). //#region Client_elicitationHandler -const client = new Client( - { name: 'feedback-host', version: '1.0.0' }, - { capabilities: { elicitation: { form: {}, url: {} } } } -); +const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } }); client.setRequestHandler('elicitation/create', async request => { if (request.params.mode === 'url') { diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts index 117181438e..bef7a1d403 100644 --- a/examples/guides/servers/input-required.examples.ts +++ b/examples/guides/servers/input-required.examples.ts @@ -31,10 +31,7 @@ const stateCodec = createRequestStateCodec<{ step: string }>({ ttlSeconds: 600 }); -const server = new McpServer( - { name: 'releases', version: '1.0.0' }, - { requestState: { verify: stateCodec.verify } } -); +const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestState: { verify: stateCodec.verify } }); //#endregion requestState_codec // "Return `input_required` instead of pushing a request" @@ -190,10 +187,7 @@ export function inputRequired_kinds(): InputRequiredResult { const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); -const client = new Client( - { name: 'input-required-docs-harness', version: '1.0.0' }, - { capabilities: { elicitation: { form: {} } } } -); +const client = new Client({ name: 'input-required-docs-harness', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); const answers: Record<string, Record<string, string | boolean>> = { 'Deploy to prod?': { confirm: true }, diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index 9502168958..5f8f8096eb 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -13,7 +13,12 @@ */ //#region requireBearerAuth_basic import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; diff --git a/examples/guides/serving/express.examples.ts b/examples/guides/serving/express.examples.ts index a8c048ca0f..b3676aedae 100644 --- a/examples/guides/serving/express.examples.ts +++ b/examples/guides/serving/express.examples.ts @@ -24,11 +24,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/examples/guides/serving/fastify.examples.ts b/examples/guides/serving/fastify.examples.ts index abe75f2aa3..64a55516f1 100644 --- a/examples/guides/serving/fastify.examples.ts +++ b/examples/guides/serving/fastify.examples.ts @@ -25,11 +25,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/examples/guides/serving/hono.examples.ts b/examples/guides/serving/hono.examples.ts index 77ecb118fa..5a08279586 100644 --- a/examples/guides/serving/hono.examples.ts +++ b/examples/guides/serving/hono.examples.ts @@ -23,11 +23,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); diff --git a/examples/guides/serving/webStandard.examples.ts b/examples/guides/serving/webStandard.examples.ts index cc4172b6dd..537372e32c 100644 --- a/examples/guides/serving/webStandard.examples.ts +++ b/examples/guides/serving/webStandard.examples.ts @@ -22,11 +22,9 @@ import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); - server.registerTool( - 'add-note', - { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, - async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) - ); + server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: `Saved: ${text}` }] + })); return server; }); @@ -39,8 +37,7 @@ import { hostHeaderValidationResponse, originValidationResponse } from '@modelco const guarded = { async fetch(request: Request): Promise<Response> { const rejected = - hostHeaderValidationResponse(request, ['api.example.com']) ?? - originValidationResponse(request, ['app.example.com']); + hostHeaderValidationResponse(request, ['api.example.com']) ?? originValidationResponse(request, ['app.example.com']); return rejected ?? handler.fetch(request); } }; From a53fc0296c6e2b48891747f828624ac8daccdbea Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 17:16:17 +0000 Subject: [PATCH 23/27] test: kill the whole process group when a guide example times out --- scripts/run-guide-examples.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/run-guide-examples.ts b/scripts/run-guide-examples.ts index bfd2a4d04d..0c449db8da 100644 --- a/scripts/run-guide-examples.ts +++ b/scripts/run-guide-examples.ts @@ -53,13 +53,23 @@ function runOne(file: string): Promise<FileResult> { return new Promise(resolvePromise => { // stdin is 'ignore' so a companion that (incorrectly) reads stdin sees // EOF immediately instead of hanging until the timeout. - const child = spawn(TSX, [file], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }); + // detached: the child leads its own process group, so a timeout kills the whole + // tree (tsx re-spawns node; killing only the wrapper would orphan the real run). + const child = spawn(TSX, [file], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); let output = ''; child.stdout.on('data', d => (output += String(d))); child.stderr.on('data', d => (output += String(d))); const finish = (ok: boolean, detail: string): void => resolvePromise({ file, ok, durationMs: Date.now() - started, detail }); const timer = setTimeout(() => { - child.kill('SIGKILL'); + if (child.pid !== undefined) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } else { + child.kill('SIGKILL'); + } finish(false, `timed out after ${TIMEOUT_MS / 1000}s (hung — possible unclosed handle)\n${output}`); }, TIMEOUT_MS); child.on('close', code => { From fd77c1893a33fb88bf64269d7d7df85cda76a230 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 18:04:46 +0000 Subject: [PATCH 24/27] chore(examples): map the server validator subpaths for typecheck CI typechecks the workspace without building the packages first, so the examples package resolves @modelcontextprotocol/* imports through its tsconfig paths; the validator subpaths used by the schema-libraries guide companion were missing from that map and only resolved locally via built dists. --- examples/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/tsconfig.json b/examples/tsconfig.json index da5a2125a3..1dcbec6899 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -9,6 +9,8 @@ "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], + "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], From e6f330ff2320bfb56a011241a617cb6af751eeaf Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 20:08:09 +0000 Subject: [PATCH 25/27] docs(serving): distinguish a missing session header from an unknown session The sessions routing example returned 404 for both an unknown Mcp-Session-Id and a request that lacked the header entirely; the latter is a malformed request and gets 400, matching examples/legacy-routing. --- docs/_meta/_TREE.md | 95 ------------------- docs/serving/sessions-state-scaling.md | 10 +- .../sessions-state-scaling.examples.ts | 8 +- 3 files changed, 15 insertions(+), 98 deletions(-) delete mode 100644 docs/_meta/_TREE.md diff --git a/docs/_meta/_TREE.md b/docs/_meta/_TREE.md deleted file mode 100644 index 3d1d4e25fa..0000000000 --- a/docs/_meta/_TREE.md +++ /dev/null @@ -1,95 +0,0 @@ -# docs-v2 draft — full tree (45 pages) - -This is the Phase 2 docs draft: 3 CALIBRATION pages (Felix-approved voice exemplars), 39 fully WRITTEN pages (prose complete, every `ts` fence backed by a typechecking companion in `examples/guides/`), and 3 VERBATIM-COPY migration files, on the approved 45-page structure. -Every WRITTEN page is reviewable as final prose: judge it against `_meta/CONVENTIONS.md` (REGISTER R1–R15) with the three CALIBRATION pages (`index.md`, `get-started/first-server.md`, `servers/tools.md`) as the voice baseline, and check structure against this file plus the page's H2 set. -Format: `path | shape | status | scope`. Sections appear in nav order. Status values: CALIBRATION (locked exemplar), WRITTEN (fully written this tranche), VERBATIM-COPY (byte-identical copy, untouched). - -## Top level - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `index.md` | landing | CALIBRATION | What MCP is (3 sentences) · one server snippet · four doors | - -## get-started/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `get-started/first-server.md` | tutorial | CALIBRATION | Setup once → one tool → run → see it answer | -| `get-started/real-host.md` | tutorial | WRITTEN | Plug your server into Claude Code / VS Code / Cursor | -| `get-started/first-client.md` | tutorial | WRITTEN | Connect, list, call, read, close — neutral, no vendor SDK | -| `get-started/packages.md` | explanation | WRITTEN | Which of the 10 packages, why subpaths exist | - -## servers/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `servers/tools.md` | how-to | CALIBRATION | Register, the schema payoff, structured output | -| `servers/resources.md` | how-to | WRITTEN | Static + templated resources, list callbacks | -| `servers/prompts.md` | how-to | WRITTEN | Register prompts, message construction | -| `servers/completion.md` | how-to | WRITTEN | Autocomplete a schema field | -| `servers/logging-progress-cancellation.md` | how-to | WRITTEN | The ctx every handler receives: logging, progress, cancellation | -| `servers/elicitation.md` | how-to | WRITTEN | Ask the user (form mode, URL mode) | -| `servers/sampling.md` | how-to | WRITTEN | Ask the model — SUNSET-FRAMED (SEP-2577), banner at top, migration target first | -| `servers/input-required.md` | how-to | WRITTEN | Handle input_required (multi-round-trip requests) | -| `servers/notifications.md` | how-to | WRITTEN | Notify clients of changes | -| `servers/errors.md` | how-to | WRITTEN | isError vs McpError vs thrown; protocol error-code table at the bottom (allowed carve-out) | - -## serving/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `serving/stdio.md` | how-to | WRITTEN | serveStdio and the console.error gotcha | -| `serving/http.md` | how-to | WRITTEN | createMcpHandler; the per-request factory model lives HERE (recipes link back) | -| `serving/express.md` | how-to | WRITTEN | Express recipe — self-contained, install one-liner at top, one back-link to http.md | -| `serving/hono.md` | how-to | WRITTEN | Hono recipe — same shape as express.md | -| `serving/fastify.md` | how-to | WRITTEN | Fastify recipe — same shape as express.md | -| `serving/web-standard.md` | how-to | WRITTEN | Web-standard runtimes (Workers etc.) recipe — same shape as express.md | -| `serving/sessions-state-scaling.md` | how-to | WRITTEN | Sessions, Resumability, Multi-node — stateless ruling first, two sentences | -| `serving/authorization.md` | how-to | WRITTEN | Bearer auth, PRM metadata, per-tool scopes. Opens with the one-line auth router | -| `serving/legacy-clients.md` | how-to | WRITTEN | The legacy: option; where SSE went | - -## clients/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `clients/connect.md` | how-to | WRITTEN | Client + transports, what you can ask after connect | -| `clients/calling.md` | how-to | WRITTEN | The verbs; auto-aggregating pagination | -| `clients/server-requests.md` | how-to | WRITTEN | Sampling/elicitation handlers; era unification told once via one cross-link | -| `clients/roots.md` | how-to | WRITTEN | Provide roots — SUNSET-FRAMED (SEP-2577), banner at top | -| `clients/subscriptions.md` | how-to | WRITTEN | listen filters vs legacy subscribe | -| `clients/oauth.md` | how-to | WRITTEN | User-facing authorization-code flow. Opens with the one-line auth router | -| `clients/machine-auth.md` | how-to | WRITTEN | Client credentials, private-key JWT, cross-app access | -| `clients/middleware.md` | how-to | WRITTEN | Compose request/response middleware | -| `clients/caching.md` | how-to | WRITTEN | Client store + server cache hints, presented as one feature | - -## Top level - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `protocol-versions.md` | explanation | WRITTEN | Eras — THE single quarantine page; the behavior matrix MOVES here from the support guide | - -## advanced/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `advanced/low-level-server.md` | explanation | WRITTEN | Rebuild the Tools example by hand on Server; McpServer-vs-Server decision criteria | -| `advanced/custom-methods.md` | how-to | WRITTEN | Vendor-prefixed methods, extension capabilities | -| `advanced/schema-libraries.md` | how-to | WRITTEN | Valibot/ArkType, JSON-Schema-in, pluggable validators | -| `advanced/custom-transports.md` | how-to | WRITTEN | Implement the Transport interface | -| `advanced/wire-schemas.md` | how-to | WRITTEN | @modelcontextprotocol/core for gateways/proxies (raw wire schemas) | -| `advanced/gateway.md` | how-to | WRITTEN | Zero-round-trip reconnect with a prior discover result | - -## Top level - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `testing.md` | how-to | WRITTEN | In-memory linked pair + handler.fetch — no sockets | -| `troubleshooting.md` | reference | WRITTEN | Verbatim error message as each heading; seeded from faq.md; pruning rule stated | - -## migration/ - -| path | shape | status | scope | -| --- | --- | --- | --- | -| `migration/index.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/index.md` — untouched per the approved tree | -| `migration/upgrade-to-v2.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/upgrade-to-v2.md` — untouched per the approved tree | -| `migration/support-2026-07-28.md` | reference | VERBATIM-COPY | Byte-identical copy of `docs/migration/support-2026-07-28.md` — untouched per the approved tree | diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index 8519ea0bb4..0bbffe450a 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -45,7 +45,13 @@ const route = async (req: Request, res: Response) => { await transport.handleRequest(req, res, req.body); return; } - res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); + if (sessionId) { + // Unknown session id: the client should start a new session. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); + return; + } + // No session header on a non-initialize request: the request is malformed. + res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Session ID required' }, id: null }); }; app.post('/mcp', route); @@ -53,7 +59,7 @@ app.get('/mcp', route); app.delete('/mcp', route); ``` -The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session. +The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing. ::: tip On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. diff --git a/examples/guides/serving/sessions-state-scaling.examples.ts b/examples/guides/serving/sessions-state-scaling.examples.ts index 33c76b5eb9..9d9ef5ba41 100644 --- a/examples/guides/serving/sessions-state-scaling.examples.ts +++ b/examples/guides/serving/sessions-state-scaling.examples.ts @@ -57,7 +57,13 @@ function sessions_routing(app: Express, buildServer: () => McpServer) { await transport.handleRequest(req, res, req.body); return; } - res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); + if (sessionId) { + // Unknown session id: the client should start a new session. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); + return; + } + // No session header on a non-initialize request: the request is malformed. + res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Session ID required' }, id: null }); }; app.post('/mcp', route); From a401b35cb0c2de44bb30cd95a4cf0088d193559f Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 20:08:09 +0000 Subject: [PATCH 26/27] docs: point the landing page's API-reference link at the reference --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 2aa7741c4e..34b839a3e6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ Any MCP host that launches this program lists and calls `get-forecast`; the SDK - Coming from v1 (`@modelcontextprotocol/sdk`) → **[Upgrade](./migration/index.md)** - Drop MCP into the app you already run → **[Express](./serving/express.md)** · **[Hono](./serving/hono.md)** · **[Fastify](./serving/fastify.md)** · **[Workers](./serving/web-standard.md)** -For exact signatures, go to the [API reference](https://ts.sdk.modelcontextprotocol.io/v2/). +For exact signatures, go to the [API reference](/api/). ## Recap From 4aa95544f70b9414457a8a6b32e1700ec4f27b39 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <fweinberger@anthropic.com> Date: Tue, 30 Jun 2026 20:08:09 +0000 Subject: [PATCH 27/27] docs: scrub stale drafting references from the companions and meta docs The guide companions and the contributor conventions still referenced the docs-v2/ working directory the pages were drafted in; the page tree index duplicated (and already contradicted) the sidebar, so it is removed; the root CLAUDE.md entry for examples/guides described the retired snippet collections. --- CLAUDE.md | 2 +- docs/_meta/CONVENTIONS.md | 23 +++++++++---------- .../guides/advanced/wire-schemas.examples.ts | 2 +- examples/guides/clients/caching.examples.ts | 6 ++--- examples/guides/clients/connect.examples.ts | 2 +- .../guides/clients/machine-auth.examples.ts | 2 +- .../get-started/firstClient.examples.ts | 2 +- .../get-started/firstServer.examples.ts | 2 +- .../guides/get-started/packages.examples.ts | 2 +- .../guides/get-started/realHost.examples.ts | 2 +- examples/guides/index.examples.ts | 4 ++-- .../guides/servers/elicitation.examples.ts | 4 ++-- examples/guides/servers/errors.examples.ts | 2 +- examples/guides/servers/prompts.examples.ts | 2 +- examples/guides/servers/sampling.examples.ts | 2 +- examples/guides/servers/tools.examples.ts | 2 +- examples/guides/serving/express.examples.ts | 4 ++-- examples/guides/serving/fastify.examples.ts | 4 ++-- examples/guides/serving/hono.examples.ts | 4 ++-- .../guides/serving/webStandard.examples.ts | 4 ++-- examples/guides/testing.examples.ts | 2 +- 21 files changed, 39 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f3ae8c1ce3..4682dc362f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ basket. See `examples/README.md` for the full story matrix. - `examples/shared/` — `@mcp-examples/shared` package. Root export is args-only (`parseExampleArgs`, `check`, `siblingPath`); the demo OAuth provider and `InMemoryEventStore` live at the `@mcp-examples/shared/auth` subpath so non-auth stories don't eagerly evaluate better-auth/express/better-sqlite3. Stories import only this plumbing and inline the SDK transport setup themselves — see `examples/CONTRIBUTING.md`. - `scripts/examples/` — runner (`run-examples.ts`) -- `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` +- `examples/guides/` — per-page snippet companions for the `docs/` guide pages (one `<section>/<page>.examples.ts` per page); fences sync via `pnpm sync:snippets`, and the runnable ones are executed in CI by `pnpm docs:examples` ## Message Flow (Bidirectional Protocol) diff --git a/docs/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md index 85fbc6ee56..5dc509dbab 100644 --- a/docs/_meta/CONVENTIONS.md +++ b/docs/_meta/CONVENTIONS.md @@ -1,4 +1,4 @@ -# docs-v2 CONVENTIONS +# Docs page conventions Single source of truth for form. Three blocks: REGISTER (voice), SCAFFOLD FORMAT (page shape), WIRING (snippet mechanics, verified in this worktree). Every writer @@ -123,7 +123,7 @@ labeled H3 subsections after it (Felix ruling). ## WIRING -How a code fence in a docs-v2 page is wired to a typechecked example file. All of this +How a code fence in a docs page is wired to a typechecked example file. All of this was verified live in this worktree on 2026-06-29 (see steps below); the mechanics are implemented by `scripts/sync-snippets.ts`. @@ -156,7 +156,7 @@ run, and `--check` reports drift if it does not already match the region byte-fo Real, working example (this exact fence is live in this file and is verified by `pnpm sync:snippets --check`; this file lives in `_meta/`, one level deeper than a -top-level page, hence the extra `../` — a page at `docs-v2/<page>.md` uses one `../`): +top-level page, hence the extra `../` — a page at `docs/<page>.md` uses one `../`): ````md ```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_basic" @@ -182,24 +182,23 @@ Notes on the exact form (regex `MARKDOWN_LABELED_FENCE_PATTERN` in sync-snippets The path in `source="..."` is resolved RELATIVE TO THE MARKDOWN FILE's own directory (`resolve(dirname(mdFile), examplePath)` in the script). It is NOT relative to the repo -root and NOT relative to `docs-v2/`. +root and NOT relative to `docs/`. From the standard locations, the prefixes are: | page location | companion example location | `source=` prefix | |--------------------------------|------------------------------------------|------------------| -| `docs-v2/<page>.md` | `examples/guides/<file>.examples.ts` | `../examples/guides/` | -| `docs-v2/<section>/<page>.md` | `examples/guides/<section>/<file>.examples.ts` | `../../examples/guides/<section>/` | +| `docs/<page>.md` | `examples/guides/<file>.examples.ts` | `../examples/guides/` | +| `docs/<section>/<page>.md` | `examples/guides/<section>/<file>.examples.ts` | `../../examples/guides/<section>/` | -So a page at `docs-v2/get-started/first-server.md` whose companion is +So a page at `docs/get-started/first-server.md` whose companion is `examples/guides/get-started/firstServer.examples.ts` uses: ``` source="../../examples/guides/get-started/firstServer.examples.ts#<region>" ``` -The sync script scans `docs/**/*.md` AND `docs-v2/**/*.md` (the docs-v2 glob was added -during prep, 2026-06-29). +The sync script scans `docs/**/*.md`. ### 4. How example files are typechecked @@ -231,12 +230,12 @@ pnpm sync:snippets --check ``` NEVER run bare `pnpm sync:snippets` — it rewrites every fenced block in `docs/` and -`docs-v2/` in place, and concurrent mutating runs from parallel agents race and clobber +`docs/` in place, and concurrent mutating runs from parallel agents race and clobber each other. Hand-write the fence body to match the region exactly; `--check` confirms. Verified outputs from prep (2026-06-29): -- With a stale `source=` fence present in `docs-v2/`, `--check` exits 1 with - `1 file(s) out of sync: .../docs-v2/_THROWAWAY-proof.md (1 snippet(s))`. +- With a stale `source=` fence present in `docs/`, `--check` exits 1 with + `1 file(s) out of sync: .../docs/_THROWAWAY-proof.md (1 snippet(s))`. - With no drift, `--check` exits 0 with `All snippets are up to date`. ### 6. Other verified ground truth diff --git a/examples/guides/advanced/wire-schemas.examples.ts b/examples/guides/advanced/wire-schemas.examples.ts index 0676329234..7aed718567 100644 --- a/examples/guides/advanced/wire-schemas.examples.ts +++ b/examples/guides/advanced/wire-schemas.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/advanced/wire-schemas.md`. + * Companion example for `docs/advanced/wire-schemas.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: it is the gateway path diff --git a/examples/guides/clients/caching.examples.ts b/examples/guides/clients/caching.examples.ts index 2b4d7b3157..27101b5223 100644 --- a/examples/guides/clients/caching.examples.ts +++ b/examples/guides/clients/caching.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/clients/caching.md`. + * Runnable, type-checked companion for `docs/clients/caching.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). @@ -55,7 +55,7 @@ const handler = createMcpHandler(() => { // --------------------------------------------------------------------------- // Harness (not shown on the page). The transport's `fetch` is routed into -// `handler.fetch` — [Test a server](docs-v2/testing.md) wiring — and counts +// `handler.fetch` — [Test a server](docs/testing.md) wiring — and counts // every JSON-RPC request that reaches the server. // --------------------------------------------------------------------------- @@ -73,7 +73,7 @@ const transport = new StreamableHTTPClientTransport(new URL('http://caching.exam const client = new Client( { name: 'caching-docs-harness', version: '1.0.0' }, - // Cache hints ride the 2026-07-28 revision — see docs-v2/protocol-versions.md. + // Cache hints ride the 2026-07-28 revision — see docs/protocol-versions.md. { versionNegotiation: { mode: 'auto' } } ); diff --git a/examples/guides/clients/connect.examples.ts b/examples/guides/clients/connect.examples.ts index d277f5e357..91933ec58f 100644 --- a/examples/guides/clients/connect.examples.ts +++ b/examples/guides/clients/connect.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/clients/connect.md`. + * Runnable, type-checked companion for `docs/clients/connect.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). diff --git a/examples/guides/clients/machine-auth.examples.ts b/examples/guides/clients/machine-auth.examples.ts index 799944511b..373c5bdc04 100644 --- a/examples/guides/clients/machine-auth.examples.ts +++ b/examples/guides/clients/machine-auth.examples.ts @@ -1,6 +1,6 @@ // docs: typecheck-only /** - * Type-checked companion for `docs-v2/clients/machine-auth.md`. + * Type-checked companion for `docs/clients/machine-auth.md`. * * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). Every flow diff --git a/examples/guides/get-started/firstClient.examples.ts b/examples/guides/get-started/firstClient.examples.ts index 4317ab4eb4..a4be4f7f1b 100644 --- a/examples/guides/get-started/firstClient.examples.ts +++ b/examples/guides/get-started/firstClient.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/get-started/first-client.md`. + * Runnable, type-checked companion for `docs/get-started/first-client.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The diff --git a/examples/guides/get-started/firstServer.examples.ts b/examples/guides/get-started/firstServer.examples.ts index aacaa66288..8525edab85 100644 --- a/examples/guides/get-started/firstServer.examples.ts +++ b/examples/guides/get-started/firstServer.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/get-started/first-server.md`. + * Runnable, type-checked companion for `docs/get-started/first-server.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions diff --git a/examples/guides/get-started/packages.examples.ts b/examples/guides/get-started/packages.examples.ts index 2c4af81c0c..39665f979e 100644 --- a/examples/guides/get-started/packages.examples.ts +++ b/examples/guides/get-started/packages.examples.ts @@ -1,6 +1,6 @@ // docs: typecheck-only /** - * Type-checked companion for `docs-v2/get-started/packages.md`. + * Type-checked companion for `docs/get-started/packages.md`. * * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The page is diff --git a/examples/guides/get-started/realHost.examples.ts b/examples/guides/get-started/realHost.examples.ts index 4e2b220e2d..efdaf98126 100644 --- a/examples/guides/get-started/realHost.examples.ts +++ b/examples/guides/get-started/realHost.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/get-started/real-host.md`. + * Runnable, type-checked companion for `docs/get-started/real-host.md`. * * The page registers an existing server in MCP hosts; its one `ts` fence is * the tail of the `src/index.ts` built in `first-server.md` — the entry every diff --git a/examples/guides/index.examples.ts b/examples/guides/index.examples.ts index 2d3244655b..3b77f10936 100644 --- a/examples/guides/index.examples.ts +++ b/examples/guides/index.examples.ts @@ -1,9 +1,9 @@ /** - * Type-checked companion for `docs-v2/index.md` (the landing page). + * Type-checked companion for `docs/index.md` (the landing page). * * The single region below is the landing hero: a complete MCP server in one * block. Imports live inside the region so the rendered block stands alone. - * Synced into the page by `pnpm sync:snippets`; typecheck-only, never executed. + * Synced into the page by `pnpm sync:snippets`; executed by `pnpm docs:examples` like every runnable companion. * * @module */ diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index f76ecff471..da63ba1ec4 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/servers/elicitation.md`. + * Companion example for `docs/servers/elicitation.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: the harness below the @@ -110,7 +110,7 @@ server.registerTool( const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); // The client-side handler the page shows once (the full client story lives in -// docs-v2/clients/server-requests.md). +// docs/clients/server-requests.md). //#region Client_elicitationHandler const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } }); diff --git a/examples/guides/servers/errors.examples.ts b/examples/guides/servers/errors.examples.ts index 639286e2b3..339d35da8d 100644 --- a/examples/guides/servers/errors.examples.ts +++ b/examples/guides/servers/errors.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/servers/errors.md`. + * Companion example for `docs/servers/errors.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: the harness below the diff --git a/examples/guides/servers/prompts.examples.ts b/examples/guides/servers/prompts.examples.ts index 118aeba8b8..c7fcd84664 100644 --- a/examples/guides/servers/prompts.examples.ts +++ b/examples/guides/servers/prompts.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/servers/prompts.md`. + * Companion example for `docs/servers/prompts.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: the harness below the diff --git a/examples/guides/servers/sampling.examples.ts b/examples/guides/servers/sampling.examples.ts index 2d54c2d975..07061ec1b1 100644 --- a/examples/guides/servers/sampling.examples.ts +++ b/examples/guides/servers/sampling.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/servers/sampling.md`. + * Companion example for `docs/servers/sampling.md`. * * The `ts` fence on that page is synced from the `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: the harness below the diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index e610e7404c..90548250da 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/servers/tools.md`. + * Companion example for `docs/servers/tools.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: the harness below the diff --git a/examples/guides/serving/express.examples.ts b/examples/guides/serving/express.examples.ts index b3676aedae..ba5eb69aad 100644 --- a/examples/guides/serving/express.examples.ts +++ b/examples/guides/serving/express.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/serving/express.md`. + * Runnable, type-checked companion for `docs/serving/express.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The @@ -41,7 +41,7 @@ const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.exa // `verifier` stands in for your deployment's token verification (JWT // validation, RFC 7662 introspection, a call to your IdP). The page points at -// docs-v2/serving/authorization.md for the real thing. +// docs/serving/authorization.md for the real thing. const verifier: OAuthTokenVerifier = { async verifyAccessToken(token) { return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; diff --git a/examples/guides/serving/fastify.examples.ts b/examples/guides/serving/fastify.examples.ts index 64a55516f1..966d205d3d 100644 --- a/examples/guides/serving/fastify.examples.ts +++ b/examples/guides/serving/fastify.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/serving/fastify.md`. + * Runnable, type-checked companion for `docs/serving/fastify.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The @@ -42,7 +42,7 @@ const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.exa // `verifyToken` stands in for your deployment's token verification (JWT // validation, RFC 7662 introspection, a call to your IdP). The page points at -// docs-v2/serving/authorization.md for the real thing. +// docs/serving/authorization.md for the real thing. async function verifyToken(authorization: string | undefined): Promise<AuthInfo> { const token = authorization?.replace(/^Bearer /, '') ?? ''; return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; diff --git a/examples/guides/serving/hono.examples.ts b/examples/guides/serving/hono.examples.ts index 5a08279586..1e8f394028 100644 --- a/examples/guides/serving/hono.examples.ts +++ b/examples/guides/serving/hono.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/serving/hono.md`. + * Runnable, type-checked companion for `docs/serving/hono.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The harness @@ -41,7 +41,7 @@ const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.exampl // `verifyToken` stands in for your deployment's token verification (JWT // validation, RFC 7662 introspection, a call to your IdP). The page points at -// docs-v2/serving/authorization.md for the real thing. +// docs/serving/authorization.md for the real thing. async function verifyToken(request: Request): Promise<AuthInfo> { const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''; return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; diff --git a/examples/guides/serving/webStandard.examples.ts b/examples/guides/serving/webStandard.examples.ts index 537372e32c..507c3e7cad 100644 --- a/examples/guides/serving/webStandard.examples.ts +++ b/examples/guides/serving/webStandard.examples.ts @@ -1,5 +1,5 @@ /** - * Runnable, type-checked companion for `docs-v2/serving/web-standard.md`. + * Runnable, type-checked companion for `docs/serving/web-standard.md`. * * Each `//#region` block is synced byte-for-byte into that page's code fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). On a @@ -45,7 +45,7 @@ const guarded = { // `verifyToken` stands in for your deployment's token verification (JWT // validation, RFC 7662 introspection, a call to your IdP). The page points at -// docs-v2/serving/authorization.md for the real thing. +// docs/serving/authorization.md for the real thing. async function verifyToken(request: Request): Promise<AuthInfo> { const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? ''; return { token, clientId: 'docs-harness', scopes: ['mcp'], expiresAt: Date.now() / 1000 + 3600 }; diff --git a/examples/guides/testing.examples.ts b/examples/guides/testing.examples.ts index 9f1e87af51..b313d861bb 100644 --- a/examples/guides/testing.examples.ts +++ b/examples/guides/testing.examples.ts @@ -1,5 +1,5 @@ /** - * Companion example for `docs-v2/testing.md`. + * Companion example for `docs/testing.md`. * * Every `ts` fence on that page is synced from a `//#region` in this file * (`pnpm sync:snippets --check`). The file also runs: it is the no-socket