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/.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/.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/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 `
/.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/README.md b/README.md index 3d8d5a84f0..6dbdeca800 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ > [!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/). > [!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) @@ -129,22 +129,22 @@ 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 -- [API docs](https://modelcontextprotocol.github.io/typescript-sdk/) +- [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 reference](https://ts.sdk.modelcontextprotocol.io/v2/api/) - [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 f824e09855..f99c9bcb8f 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/**', 'behavior-surface-pins.md'], + srcExclude: ['v1/**', '_meta/**', 'behavior-surface-pins.md'], sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io/v2/' }, markdown: { config(md) { @@ -44,26 +44,82 @@ 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: 'Examples', link: '/get-started/examples' } ] }, { - 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: [ @@ -72,10 +128,6 @@ export default defineConfig({ { text: '2026-07-28 protocol support', link: '/migration/support-2026-07-28' } ] }, - { - text: 'FAQ', - items: [{ text: 'FAQ', link: '/faq' }] - }, { text: 'API Reference', collapsed: true, 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 { diff --git a/docs/_meta/CONVENTIONS.md b/docs/_meta/CONVENTIONS.md new file mode 100644 index 0000000000..5dc509dbab --- /dev/null +++ b/docs/_meta/CONVENTIONS.md @@ -0,0 +1,249 @@ +# 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 +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. + 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. + 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/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 --> + + ## <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 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 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) + +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/<page>.md` uses one `../`): + +````md +```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; +}); +``` +```` + +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/`. + +From the standard locations, the prefixes are: + +| page location | companion example location | `source=` prefix | +|--------------------------------|------------------------------------------|------------------| +| `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/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`. + +### 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/` 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/`, `--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 + +- `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/advanced/custom-methods.md b/docs/advanced/custom-methods.md new file mode 100644 index 0000000000..4104cfd039 --- /dev/null +++ b/docs/advanced/custom-methods.md @@ -0,0 +1,131 @@ +--- +shape: how-to +--- + +# 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 + +`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'; + +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}`) }; +}); +``` + +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 + +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 + +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 + +`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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..242d55937e --- /dev/null +++ b/docs/advanced/custom-transports.md @@ -0,0 +1,203 @@ +--- +shape: how-to +--- +# Custom transports + +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 + +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 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?.(); + } +} +``` + +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 + +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 + +`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 + +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 + +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 + +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 + +`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 + +- 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 new file mode 100644 index 0000000000..6da8289e28 --- /dev/null +++ b/docs/advanced/gateway.md @@ -0,0 +1,160 @@ +--- +shape: how-to +--- +# Gateways and worker fleets + +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 + +`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('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) }); +``` + +`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 + +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 + +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 + +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 + +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 + +`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 + +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 + +- `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 new file mode 100644 index 0000000000..106abe05d3 --- /dev/null +++ b/docs/advanced/low-level-server.md @@ -0,0 +1,161 @@ +--- +shape: explanation +--- + +# Low-level Server + +`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 + +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', description: 'Substring to match against product names' } }, + required: ['query'] + } + } + ] +})); +``` + +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 + +`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 + +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 + +`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` + +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 + +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 + +- `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 new file mode 100644 index 0000000000..18bfd7b3bc --- /dev/null +++ b/docs/advanced/schema-libraries.md @@ -0,0 +1,164 @@ +--- +shape: how-to +--- +# Schema libraries + +## Register a tool with an ArkType schema + +`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: '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') }] + }) +); +``` + +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 + +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 + +`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 + +`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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..d9a535627c --- /dev/null +++ b/docs/advanced/wire-schemas.md @@ -0,0 +1,158 @@ +--- +shape: how-to +--- +# Wire schemas + +`@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 + +`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'; + +// 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); +``` + +`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' + } +] +``` + +::: 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 + +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 + +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 + +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 + +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 + +`@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 + +- `@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/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/clients/caching.md b/docs/clients/caching.md new file mode 100644 index 0000000000..7616255742 --- /dev/null +++ b/docs/clients/caching.md @@ -0,0 +1,104 @@ +--- +shape: how-to +--- +# Cache responses + +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 + +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 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 + +await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store +await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write +``` + +`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 + +`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 + +`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 + +`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 + +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 + +`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 + +- 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 new file mode 100644 index 0000000000..bb3864d20a --- /dev/null +++ b/docs/clients/calling.md @@ -0,0 +1,178 @@ +--- +shape: how-to +--- +# Call tools, read resources, get prompts + +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 + +`listTools` returns the tools the server advertises; `callTool` invokes one by name with a plain `arguments` object. + +```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: 'lookup-order', arguments: { id: 'A-1041' } }); +console.log(result.content); +``` + +`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 + +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 + +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 + +`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 + +`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 + +`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 + +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 + +- `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 new file mode 100644 index 0000000000..4fb6b72f33 --- /dev/null +++ b/docs/clients/connect.md @@ -0,0 +1,106 @@ +--- +shape: how-to +--- +# Connect to a server + +A **client** holds one connection to one server: construct a `Client`, pick a **transport**, and `connect()`. + +## Create a client and connect over HTTP + +`Client` takes a name and a version; `StreamableHTTPClientTransport` takes the server's MCP endpoint URL. + +```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' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + +await client.connect(transport); +``` + +`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 + +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 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; +} +``` + +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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..34d24f8450 --- /dev/null +++ b/docs/clients/machine-auth.md @@ -0,0 +1,106 @@ +--- +shape: how-to +--- +# Authenticate without a user + +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 + +`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 source="../../examples/guides/clients/machine-auth.examples.ts#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); +``` + +`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 + +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 + +`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 + +**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 + +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 + +- 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 new file mode 100644 index 0000000000..2904850fea --- /dev/null +++ b/docs/clients/middleware.md @@ -0,0 +1,133 @@ +--- +shape: how-to +--- +# Compose client middleware + +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 + +`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 source="../../examples/guides/clients/middleware.examples.ts#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) +}); +``` + +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 + +`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 + +`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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..ed972919e1 --- /dev/null +++ b/docs/clients/oauth.md @@ -0,0 +1,170 @@ +--- +shape: how-to +--- +# Authenticate a user with OAuth + +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 + +Pass an **`OAuthClientProvider`** as the transport's `authProvider` — it, and every other symbol on this page, comes from `@modelcontextprotocol/client`. + +```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 +}); + +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. +} +``` + +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 + +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 + +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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..d725241d86 --- /dev/null +++ b/docs/clients/roots.md @@ -0,0 +1,86 @@ +--- +shape: how-to +--- +# Provide roots + +::: warning Deprecated — SEP-2577 +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). +::: + +## Migrate away first + +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 + +`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 + +`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' } +]; + +client.setRequestHandler('roots/list', async () => { + return { roots }; +}); +``` + +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 + +`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 + +- 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 new file mode 100644 index 0000000000..8d3c75a45b --- /dev/null +++ b/docs/clients/server-requests.md @@ -0,0 +1,114 @@ +--- +shape: how-to +--- +# Handle requests from the server + +## Declare what your client can do + +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 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: {}, url: {} } + } + } +); +``` + +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 + +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 + +::: 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 + +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 + +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 + +- 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 new file mode 100644 index 0000000000..e651cd2a16 --- /dev/null +++ b/docs/clients/subscriptions.md @@ -0,0 +1,137 @@ +--- +shape: how-to +--- + +# Subscribe to 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 + +`listen` takes a **filter** naming the notification types you want. Register a handler for each type with `setNotificationHandler` before you open the stream. + +```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 subscription = await client.listen({ + toolsListChanged: true, + resourceSubscriptions: ['config://app'] +}); +console.log('Server honored:', subscription.honoredFilter); +``` + +`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 + +`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 + +`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 + +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 + +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 + +- `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/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/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/docs/get-started/first-client.md b/docs/get-started/first-client.md new file mode 100644 index 0000000000..8a980c51b2 --- /dev/null +++ b/docs/get-started/first-client.md @@ -0,0 +1,157 @@ +--- +shape: tutorial +--- + +# Build your first client + +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 + +In the weather project, add the client package — it ships separately from `@modelcontextprotocol/server`. + +```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({ + command: 'npx', + args: ['tsx', 'src/index.ts'] +}); + +await client.connect(transport); +``` + +`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 + +`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 + +`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 + +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 + +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 + +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 + +- 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/first-server.md b/docs/get-started/first-server.md new file mode 100644 index 0000000000..989013dbea --- /dev/null +++ b/docs/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/get-started/packages.md b/docs/get-started/packages.md new file mode 100644 index 0000000000..b4eea130ac --- /dev/null +++ b/docs/get-started/packages.md @@ -0,0 +1,81 @@ +--- +shape: explanation +--- + +# Packages and subpath exports + +The SDK is published as nine npm packages. Most projects install exactly one of them. + +## Start from one package + +Everything in [Build your first server](./first-server.md) came from a single install, `@modelcontextprotocol/server` — through two import paths. + +```ts source="../../examples/guides/get-started/packages.examples.ts#packages_serverEntryPoints" +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +``` + +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 + +Install the package for the side of the protocol you are building. + +```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. + +## 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 + +`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 + +`@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 + +`@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 + +- `@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 new file mode 100644 index 0000000000..877a1dc8c8 --- /dev/null +++ b/docs/get-started/real-host.md @@ -0,0 +1,136 @@ +--- +shape: tutorial +--- + +# Plug into a real host + +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 + +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 source="../../examples/guides/get-started/realHost.examples.ts#realHost_serve" +void serveStdio(createServer); +console.error('weather MCP server running on stdio'); +``` + +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 + +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 + +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 + +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 + +Every MCP host launches a stdio server from the same command and arguments. Only where you put them differs. + +### Claude Code + +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 + +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 + +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 + +- 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/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..34b839a3e6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,50 @@ +--- +status: calibration +shape: landing +--- + +# MCP TypeScript SDK + +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'; +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. + +## 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](/api/). + +## 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/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..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` / @@ -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/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000000..b3adcaef01 --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,182 @@ +--- +shape: explanation +--- + +# Protocol versions + +## Name the two eras + +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 + +`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' } }); + +await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); + +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 +``` + +## Pin an era + +`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 + +`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 + +`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 + +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 + +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 + +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 + +- 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/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/docs/servers/completion.md b/docs/servers/completion.md new file mode 100644 index 0000000000..b9f0b5222d --- /dev/null +++ b/docs/servers/completion.md @@ -0,0 +1,168 @@ +--- +shape: how-to +--- +# Completion + +**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` + +`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', + { + 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.` } + } + ] + }) +); +``` + +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 + +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 + +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 + +[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 + +`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 + +- `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 new file mode 100644 index 0000000000..a4063512d7 --- /dev/null +++ b/docs/servers/elicitation.md @@ -0,0 +1,185 @@ +--- +shape: how-to +--- +# 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 + +**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: '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)}` }] }; + } +); +``` + +`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 + +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 + +**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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..b2201ce05d --- /dev/null +++ b/docs/servers/errors.md @@ -0,0 +1,209 @@ +--- +shape: how-to +--- +# Errors + +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` + +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( + '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 }] }; + } +); +``` + +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 + +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 + +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 + +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 + +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 + +`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 + +- `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 new file mode 100644 index 0000000000..6f731504f9 --- /dev/null +++ b/docs/servers/input-required.md @@ -0,0 +1,251 @@ +--- +shape: how-to +--- +# input_required + +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 + +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 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}` }] }; + } +); +``` + +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 + +`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 + +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 + +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` + +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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..bfc61db1bf --- /dev/null +++ b/docs/servers/logging-progress-cancellation.md @@ -0,0 +1,205 @@ +--- +shape: how-to +--- +# Logging, progress, and cancellation + +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 + +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]}` } + }); + } + } + + 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' } ] +``` + +## Skip progress when the client did not ask + +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 + +::: 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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..42bc96415c --- /dev/null +++ b/docs/servers/notifications.md @@ -0,0 +1,111 @@ +--- +shape: how-to +--- + +# Notifications + +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 + +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(); +``` + +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 + +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 + +`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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..141b1b2f09 --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,205 @@ +--- +shape: how-to +--- +# 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 + +`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().describe('The code to review') + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Review this code:\n\n${code}` } + } + ] + }) +); +``` + +`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 + +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 + +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 + +`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 + +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 + +- `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 new file mode 100644 index 0000000000..7082723d22 --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,222 @@ +--- +shape: how-to +--- + +# 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 + +`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: 'log_level=info\nregion=eu-west-1' }] + }) +); +``` + +`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 + +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 + +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 + +`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 + +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 + +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 + +- `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 new file mode 100644 index 0000000000..86f97ae569 --- /dev/null +++ b/docs/servers/sampling.md @@ -0,0 +1,80 @@ +--- +shape: how-to +--- +# Sampling + +::: warning Deprecated — SEP-2577 +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). +::: + +## Replace sampling with a direct provider call + +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 + +`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: `Summarize in one sentence:\n\n${text}` } }], + maxTokens: 500 + }); + return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; + } +); +``` + +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 + +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 + +`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 + +- 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/docs/servers/tools.md b/docs/servers/tools.md new file mode 100644 index 0000000000..554669c144 --- /dev/null +++ b/docs/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/docs/serving/authorization.md b/docs/serving/authorization.md new file mode 100644 index 0000000000..a6163e197f --- /dev/null +++ b/docs/serving/authorization.md @@ -0,0 +1,113 @@ +--- +shape: how-to +--- +# Require authorization + +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 + +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'; + +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)); +``` + +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 + +`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 + +`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 + +`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 + +`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 + +- `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 new file mode 100644 index 0000000000..1d04189a97 --- /dev/null +++ b/docs/serving/express.md @@ -0,0 +1,90 @@ +--- +shape: how-to +--- +# Serve with Express + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express +``` + +## Mount the handler + +`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 * 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)); +``` + +`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 + +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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..1643ec4d48 --- /dev/null +++ b/docs/serving/fastify.md @@ -0,0 +1,90 @@ +--- +shape: how-to +--- +# Serve with Fastify + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/fastify @modelcontextprotocol/node fastify +``` + +## Mount the handler + +`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('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)); +``` + +`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 + +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 + +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 + +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 + +- 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 new file mode 100644 index 0000000000..a2adfde9c8 --- /dev/null +++ b/docs/serving/hono.md @@ -0,0 +1,89 @@ +--- +shape: how-to +--- +# Serve with Hono + +```sh +npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono +``` + +## Mount the handler + +`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('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; +``` + +`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 + +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 + +`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 + +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 + +- 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 new file mode 100644 index 0000000000..228712d2f9 --- /dev/null +++ b/docs/serving/http.md @@ -0,0 +1,129 @@ +--- +shape: how-to +--- + +# Serve over HTTP + +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 + +`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( + 'add-note', + { + description: 'Save a note', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) + ); + return server; +}); +``` + +`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 + +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 + +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 + +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 + +`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 + +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 + +`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 + +- `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 new file mode 100644 index 0000000000..62980e01cc --- /dev/null +++ b/docs/serving/legacy-clients.md @@ -0,0 +1,102 @@ +--- +shape: how-to +--- +# Support legacy clients + +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 + +[`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 buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' }); + +const strict = createMcpHandler(buildServer, { legacy: 'reject' }); +``` + +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 + +[`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 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 + +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 + +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 + +- 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 new file mode 100644 index 0000000000..0bbffe450a --- /dev/null +++ b/docs/serving/sessions-state-scaling.md @@ -0,0 +1,107 @@ +--- +shape: how-to +--- +# Sessions, state, and scaling + +`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. + +## 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. + +```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() +}); +``` + +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; + } + 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); +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; 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. +::: + +## 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. + +`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. +::: + +## 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. + +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 + +- `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 new file mode 100644 index 0000000000..2855626eca --- /dev/null +++ b/docs/serving/stdio.md @@ -0,0 +1,79 @@ +--- +shape: how-to +--- +# Serve over stdio + +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 + +`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'; + +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; +}); +``` + +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 + +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 + +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 + +`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 + +- `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 new file mode 100644 index 0000000000..87d7439eb3 --- /dev/null +++ b/docs/serving/web-standard.md @@ -0,0 +1,88 @@ +--- +shape: how-to +--- +# Serve on web-standard runtimes + +```sh +npm install @modelcontextprotocol/server +``` + +## Mount the handler + +`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('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; +``` + +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 + +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'; + +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 + +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 + +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 + +- 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. +- 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/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..8a302d5ee0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,134 @@ +--- +shape: how-to +--- +# Test a server + +Drive your server through a real `Client`, in-process — no port, no socket, no mock transport. + +## Serve the handler in-process + +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 transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)) +}); +``` + +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 + +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 + +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 + +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 + +`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 + +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 + +- `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 new file mode 100644 index 0000000000..7871217abb --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,159 @@ +--- +shape: reference +--- +# Troubleshooting + +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` + +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 + return server; +}); +``` + +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` + +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` + +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` + +`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` + +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'` + +`@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 + +- 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/README.md b/examples/README.md index dee74f7735..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) | Snippet collections synced into `docs/server.md` and `docs/client.md` | 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 @@ -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/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/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/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..7aed718567 --- /dev/null +++ b/examples/guides/advanced/wire-schemas.examples.ts @@ -0,0 +1,104 @@ +/** + * 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 + * 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'); +} 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/clients/caching.examples.ts b/examples/guides/clients/caching.examples.ts new file mode 100644 index 0000000000..27101b5223 --- /dev/null +++ b/examples/guides/clients/caching.examples.ts @@ -0,0 +1,145 @@ +/** + * 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). + * + * 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/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/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..a4526caeb2 --- /dev/null +++ b/examples/guides/clients/calling.examples.ts @@ -0,0 +1,205 @@ +/** + * 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..91933ec58f --- /dev/null +++ b/examples/guides/clients/connect.examples.ts @@ -0,0 +1,116 @@ +/** + * 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). + * + * 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..373c5bdc04 --- /dev/null +++ b/examples/guides/clients/machine-auth.examples.ts @@ -0,0 +1,89 @@ +// docs: typecheck-only +/** + * 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 + * 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..96bfdba035 --- /dev/null +++ b/examples/guides/clients/middleware.examples.ts @@ -0,0 +1,136 @@ +/** + * 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..9df3640d1e --- /dev/null +++ b/examples/guides/clients/roots.examples.ts @@ -0,0 +1,67 @@ +/** + * 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; diff --git a/examples/guides/get-started/firstClient.examples.ts b/examples/guides/get-started/firstClient.examples.ts new file mode 100644 index 0000000000..a4be4f7f1b --- /dev/null +++ b/examples/guides/get-started/firstClient.examples.ts @@ -0,0 +1,146 @@ +/** + * 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 + * 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/firstServer.examples.ts b/examples/guides/get-started/firstServer.examples.ts new file mode 100644 index 0000000000..8525edab85 --- /dev/null +++ b/examples/guides/get-started/firstServer.examples.ts @@ -0,0 +1,93 @@ +/** + * 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 + * 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/get-started/packages.examples.ts b/examples/guides/get-started/packages.examples.ts new file mode 100644 index 0000000000..39665f979e --- /dev/null +++ b/examples/guides/get-started/packages.examples.ts @@ -0,0 +1,28 @@ +// docs: typecheck-only +/** + * 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 + * 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..efdaf98126 --- /dev/null +++ b/examples/guides/get-started/realHost.examples.ts @@ -0,0 +1,95 @@ +/** + * 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 + * 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..0673fee9c2 --- /dev/null +++ b/examples/guides/get-started/src/index.ts @@ -0,0 +1,62 @@ +/** + * 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'); diff --git a/examples/guides/index.examples.ts b/examples/guides/index.examples.ts new file mode 100644 index 0000000000..3b77f10936 --- /dev/null +++ b/examples/guides/index.examples.ts @@ -0,0 +1,32 @@ +/** + * 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`; executed by `pnpm docs:examples` like every runnable companion. + * + * @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/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/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/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..da63ba1ec4 --- /dev/null +++ b/examples/guides/servers/elicitation.examples.ts @@ -0,0 +1,173 @@ +/** + * 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 + * 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/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..339d35da8d --- /dev/null +++ b/examples/guides/servers/errors.examples.ts @@ -0,0 +1,132 @@ +/** + * 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 + * 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..bef7a1d403 --- /dev/null +++ b/examples/guides/servers/input-required.examples.ts @@ -0,0 +1,243 @@ +/** + * 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..c7fcd84664 --- /dev/null +++ b/examples/guides/servers/prompts.examples.ts @@ -0,0 +1,163 @@ +/** + * 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 + * 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..07061ec1b1 --- /dev/null +++ b/examples/guides/servers/sampling.examples.ts @@ -0,0 +1,89 @@ +/** + * 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 + * 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(); diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts new file mode 100644 index 0000000000..90548250da --- /dev/null +++ b/examples/guides/servers/tools.examples.ts @@ -0,0 +1,119 @@ +/** + * 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 + * 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(); diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts new file mode 100644 index 0000000000..5f8f8096eb --- /dev/null +++ b/examples/guides/serving/authorization.examples.ts @@ -0,0 +1,84 @@ +// 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..ba5eb69aad --- /dev/null +++ b/examples/guides/serving/express.examples.ts @@ -0,0 +1,92 @@ +/** + * 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 + * `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/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..966d205d3d --- /dev/null +++ b/examples/guides/serving/fastify.examples.ts @@ -0,0 +1,89 @@ +/** + * 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 + * `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/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..1e8f394028 --- /dev/null +++ b/examples/guides/serving/hono.examples.ts @@ -0,0 +1,77 @@ +/** + * 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 + * 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/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..9d9ef5ba41 --- /dev/null +++ b/examples/guides/serving/sessions-state-scaling.examples.ts @@ -0,0 +1,95 @@ +// 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; + } + 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); + 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..507c3e7cad --- /dev/null +++ b/examples/guides/serving/webStandard.examples.ts @@ -0,0 +1,101 @@ +/** + * 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 + * 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/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/guides/testing.examples.ts b/examples/guides/testing.examples.ts new file mode 100644 index 0000000000..b313d861bb --- /dev/null +++ b/examples/guides/testing.examples.ts @@ -0,0 +1,130 @@ +/** + * 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 + * 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 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/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/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/examples/tsconfig.json b/examples/tsconfig.json index 9770b47a55..1dcbec6899 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -9,10 +9,14 @@ "@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"], + "@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 +25,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/package.json b/package.json index 0fbe0fa6ba..71424cb8f3 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,10 @@ "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": "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", 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' ); } 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: 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'); diff --git a/scripts/run-guide-examples.ts b/scripts/run-guide-examples.ts new file mode 100644 index 0000000000..0c449db8da --- /dev/null +++ b/scripts/run-guide-examples.ts @@ -0,0 +1,120 @@ +#!/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. + // 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(() => { + 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 => { + 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();