docs: rebuild the v2 documentation around a task-based page tree (#2397)

This commit is contained in:
Felix Weinberger 2026-06-30 21:18:42 +01:00 • committed by GitHub
commit b153505428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 11884 additions and 4971 deletions

104
docs/clients/caching.md Normal file
View 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
View 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
View 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.

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

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

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