docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
9 changed files with 11884 additions and 4971 deletions
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.
|
||||
Loading…
Reference in a new issue