docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
54 changed files with 11884 additions and 4971 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
249
docs/_meta/CONVENTIONS.md
Normal file
249
docs/_meta/CONVENTIONS.md
Normal file
|
|
@ -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: <tutorial|how-to|explanation|reference>
|
||||
---
|
||||
# <Title>
|
||||
|
||||
<!-- 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`.
|
||||
131
docs/advanced/custom-methods.md
Normal file
131
docs/advanced/custom-methods.md
Normal file
|
|
@ -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.
|
||||
203
docs/advanced/custom-transports.md
Normal file
203
docs/advanced/custom-transports.md
Normal file
|
|
@ -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.
|
||||
160
docs/advanced/gateway.md
Normal file
160
docs/advanced/gateway.md
Normal file
|
|
@ -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.
|
||||
161
docs/advanced/low-level-server.md
Normal file
161
docs/advanced/low-level-server.md
Normal file
|
|
@ -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.
|
||||
164
docs/advanced/schema-libraries.md
Normal file
164
docs/advanced/schema-libraries.md
Normal file
|
|
@ -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.
|
||||
158
docs/advanced/wire-schemas.md
Normal file
158
docs/advanced/wire-schemas.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
1065
docs/client.md
1065
docs/client.md
File diff suppressed because it is too large
Load diff
104
docs/clients/caching.md
Normal file
104
docs/clients/caching.md
Normal file
|
|
@ -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.
|
||||
178
docs/clients/calling.md
Normal file
178
docs/clients/calling.md
Normal file
|
|
@ -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.
|
||||
106
docs/clients/connect.md
Normal file
106
docs/clients/connect.md
Normal file
|
|
@ -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.
|
||||
106
docs/clients/machine-auth.md
Normal file
106
docs/clients/machine-auth.md
Normal file
|
|
@ -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).
|
||||
133
docs/clients/middleware.md
Normal file
133
docs/clients/middleware.md
Normal file
|
|
@ -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.
|
||||
170
docs/clients/oauth.md
Normal file
170
docs/clients/oauth.md
Normal file
|
|
@ -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.
|
||||
86
docs/clients/roots.md
Normal file
86
docs/clients/roots.md
Normal file
|
|
@ -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.
|
||||
114
docs/clients/server-requests.md
Normal file
114
docs/clients/server-requests.md
Normal file
|
|
@ -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.
|
||||
137
docs/clients/subscriptions.md
Normal file
137
docs/clients/subscriptions.md
Normal file
|
|
@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue