docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
10 changed files with 11884 additions and 4971 deletions
168
docs/servers/completion.md
Normal file
168
docs/servers/completion.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Completion
|
||||
|
||||
**Completion** is server-side autocomplete for prompt arguments and resource template variables: the client sends the partial value the user has typed so far, your callback returns the matching suggestions.
|
||||
|
||||
## Wrap an argument with `completable`
|
||||
|
||||
`completable` wraps one field of a [prompt's](./prompts.md) `argsSchema` — the schema validates exactly as before, and the second argument suggests values for the field.
|
||||
|
||||
```ts source="../../examples/guides/servers/completion.examples.ts#completable_language"
|
||||
import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const languages = ['typescript', 'javascript', 'python', 'rust', 'go'];
|
||||
|
||||
const server = new McpServer({ name: 'review', version: '1.0.0' });
|
||||
|
||||
server.registerPrompt(
|
||||
'review-code',
|
||||
{
|
||||
description: 'Review code for best practices',
|
||||
argsSchema: z.object({
|
||||
language: completable(z.string().describe('Programming language'), value =>
|
||||
languages.filter(language => language.startsWith(value))
|
||||
)
|
||||
})
|
||||
},
|
||||
({ language }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Review this ${language} code for best practices.` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The first `completable` field also registers the server's `completion/complete` handler and advertises the **completions** capability — nothing to declare. A request for completions of `language` with the partial value `ty` now returns `typescript`.
|
||||
|
||||
Every result quoted on this page comes from `client.complete()` on an in-memory `Client` connected to this server — [Try it from a client](#try-it-from-a-client) shows the call, and [Test a server](../testing.md) shows the wiring.
|
||||
|
||||
## Return suggestions from the complete callback
|
||||
|
||||
The callback receives the value typed so far and returns every match — `string[]`, or a promise of one when the lookup is async. Register a second prompt whose `repo` argument completes from an async list.
|
||||
|
||||
```ts source="../../examples/guides/servers/completion.examples.ts#registerPrompt_async"
|
||||
const branchesByRepo: Record<string, string[]> = {
|
||||
'typescript-sdk': ['main', 'release/1.x', 'release/2.x'],
|
||||
'python-sdk': ['main', 'release/1.x'],
|
||||
inspector: ['main']
|
||||
};
|
||||
|
||||
async function listRepos(): Promise<string[]> {
|
||||
return Object.keys(branchesByRepo);
|
||||
}
|
||||
|
||||
server.registerPrompt(
|
||||
'review-pr',
|
||||
{
|
||||
description: 'Review the open pull requests on one branch',
|
||||
argsSchema: z.object({
|
||||
repo: completable(z.string().describe('Repository name'), async value => {
|
||||
const repos = await listRepos();
|
||||
return repos.filter(repo => repo.startsWith(value));
|
||||
}),
|
||||
branch: completable(z.string().describe('Target branch'), completeBranch)
|
||||
})
|
||||
},
|
||||
({ repo, branch }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Review the open pull requests on ${repo}@${branch}.` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Return the full match list; the SDK truncates `values` to 100 entries and fills in `total` and `hasMore`. Completing `repo` with the value `ty` returns:
|
||||
|
||||
```
|
||||
{ values: [ 'typescript-sdk' ], total: 1, hasMore: false }
|
||||
```
|
||||
|
||||
`branch` points at `completeBranch`, defined in the next section.
|
||||
|
||||
## Use the other arguments for context
|
||||
|
||||
The callback's optional second parameter carries `arguments`: the values the client has already filled in for the prompt's other arguments. Use it to make one field's suggestions depend on another's.
|
||||
|
||||
```ts source="../../examples/guides/servers/completion.examples.ts#completeCallback_context"
|
||||
async function completeBranch(value: string, context?: { arguments?: Record<string, string> }): Promise<string[]> {
|
||||
const repo = context?.arguments?.repo;
|
||||
if (!repo) return [];
|
||||
return (branchesByRepo[repo] ?? []).filter(branch => branch.startsWith(value));
|
||||
}
|
||||
```
|
||||
|
||||
With `repo: 'typescript-sdk'` already filled in, completing `branch` with the value `rel` returns:
|
||||
|
||||
```
|
||||
{ values: [ 'release/1.x', 'release/2.x' ], total: 2, hasMore: false }
|
||||
```
|
||||
|
||||
Clients are not required to send `context` — return an empty list when it is missing, never throw.
|
||||
|
||||
## Complete a resource template variable
|
||||
|
||||
[Resource template](./resources.md) variables complete through the template's `complete` map — one callback per URI variable, with the same `(value, context?)` signature. `completable` is for prompt arguments only.
|
||||
|
||||
```ts source="../../examples/guides/servers/completion.examples.ts#resourceTemplate_complete"
|
||||
server.registerResource(
|
||||
'readme',
|
||||
new ResourceTemplate('repo://{repo}/readme', {
|
||||
list: undefined,
|
||||
complete: {
|
||||
repo: async value => {
|
||||
const repos = await listRepos();
|
||||
return repos.filter(repo => repo.startsWith(value));
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ description: 'The README of one repository' },
|
||||
async (uri, { repo }) => ({
|
||||
contents: [{ uri: uri.href, text: `Repository: ${repo}` }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The completion request targets the template by its URI pattern: `ref: { type: 'ref/resource', uri: 'repo://{repo}/readme' }` with `argument: { name: 'repo', value: 'py' }` returns `python-sdk`.
|
||||
|
||||
## Try it from a client
|
||||
|
||||
`Client.complete()` sends `completion/complete`. Complete `language` on `review-code` with an empty value to get the whole list.
|
||||
|
||||
```ts source="../../examples/guides/servers/completion.examples.ts#complete_client"
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'review-code' },
|
||||
argument: { name: 'language', value: '' }
|
||||
});
|
||||
console.log(result.completion);
|
||||
```
|
||||
|
||||
The server's callback ran once and produced every value:
|
||||
|
||||
```
|
||||
{
|
||||
values: [ 'typescript', 'javascript', 'python', 'rust', 'go' ],
|
||||
total: 5,
|
||||
hasMore: false
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
A host issues the same request as the end user types: MCP Inspector requests completions while you fill in a prompt's arguments and shows the returned `values` as suggestions.
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
||||
- `completable(schema, callback)` attaches autocompletion to one prompt argument; the schema validates exactly as before.
|
||||
- The callback receives the partial value and returns the full match list, synchronously or as a promise; the SDK caps `values` at 100 and sets `total` and `hasMore`.
|
||||
- `context.arguments` carries the prompt's already-filled arguments, so one field's suggestions can depend on another's.
|
||||
- Resource template variables complete through the template's `complete` map, not `completable`.
|
||||
- The first completable registration advertises the `completions` capability; `Client.complete()` sends the request.
|
||||
185
docs/servers/elicitation.md
Normal file
185
docs/servers/elicitation.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Elicitation
|
||||
|
||||
A tool handler asks the end user a question mid-call with `ctx.mcpReq.elicitInput` — the connected client puts the question in front of them and the promise resolves with their answer.
|
||||
|
||||
## Ask for input with a form
|
||||
|
||||
**Form mode** carries a `message` and a `requestedSchema`: a flat JSON Schema of primitive fields the client renders as a form.
|
||||
|
||||
```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitForm"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const server = new McpServer({ name: 'feedback', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'collect-feedback',
|
||||
{
|
||||
description: 'Ask the user how something went',
|
||||
inputSchema: z.object({ topic: z.string() })
|
||||
},
|
||||
async ({ topic }, ctx) => {
|
||||
const result = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: `How was ${topic}?`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 },
|
||||
comment: { type: 'string', title: 'Comment' }
|
||||
},
|
||||
required: ['rating']
|
||||
}
|
||||
});
|
||||
if (result.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
`result.action` records what the end user did — `accept`, `decline`, or `cancel` — and `result.content` carries the submitted fields on accept only. The SDK validates accepted content against `requestedSchema` before `elicitInput` resolves, so the fields you read match the schema you sent.
|
||||
|
||||
::: info
|
||||
On a 2026-07-28 connection `elicitInput` throws — a handler returns the request instead; see [Input required](./input-required.md) and [Protocol versions](../protocol-versions.md).
|
||||
:::
|
||||
|
||||
The answer comes from the connected client's `elicitation/create` handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — [Handle requests from the server](../clients/server-requests.md) covers the client side in full.
|
||||
|
||||
```ts source="../../examples/guides/servers/elicitation.examples.ts#Client_elicitationHandler"
|
||||
const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } });
|
||||
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode === 'url') {
|
||||
// Open request.params.url in the user's browser; answer when they finish.
|
||||
return { action: 'accept' };
|
||||
}
|
||||
// Render request.params.requestedSchema as a form; return what the user typed.
|
||||
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
|
||||
});
|
||||
```
|
||||
|
||||
Call `collect-feedback` and the elicitation round-trips through that handler inside the one tool call.
|
||||
|
||||
```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_collectFeedback"
|
||||
const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } });
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
The handler resumes with the submitted fields and returns:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Recorded: {"rating":5,"comment":"Smooth setup"}'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Handle every action
|
||||
|
||||
Return a distinct result for each `action` so the model knows whether the end user confirmed, refused, or never answered.
|
||||
|
||||
```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitActions"
|
||||
server.registerTool(
|
||||
'delete-dataset',
|
||||
{
|
||||
description: 'Delete a dataset after the user confirms',
|
||||
inputSchema: z.object({ name: z.string() })
|
||||
},
|
||||
async ({ name }, ctx) => {
|
||||
const result = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: `Delete ${name}? This cannot be undone.`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } },
|
||||
required: ['confirm']
|
||||
}
|
||||
});
|
||||
switch (result.action) {
|
||||
case 'accept':
|
||||
if (result.content?.confirm !== true) {
|
||||
return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Deleted ${name}.` }] };
|
||||
case 'decline':
|
||||
return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] };
|
||||
case 'cancel':
|
||||
return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
`result.content` is end-user input: schema-valid, still untrusted — the `accept` branch checks that the box was actually ticked before acting. Decline the form and the tool answers from the `decline` branch:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Declined - nothing deleted.' } ]
|
||||
```
|
||||
|
||||
## Send the end user to a URL
|
||||
|
||||
**URL mode** replaces the form with a browser flow: pass `url` and a unique `elicitationId` instead of `requestedSchema`.
|
||||
|
||||
```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitUrl"
|
||||
server.registerTool(
|
||||
'link-account',
|
||||
{
|
||||
description: 'Link a billing account through a hosted sign-in flow',
|
||||
inputSchema: z.object({ provider: z.string() })
|
||||
},
|
||||
async ({ provider }, ctx) => {
|
||||
const result = await ctx.mcpReq.elicitInput({
|
||||
mode: 'url',
|
||||
message: `Sign in to ${provider} to link your account`,
|
||||
url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`,
|
||||
elicitationId: crypto.randomUUID()
|
||||
});
|
||||
if (result.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Linked ${provider}.` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The client opens the URL and answers once the end user finishes there; whatever the page collects — credentials, payment details, API keys — stays in the browser and never crosses the MCP connection. The handler's `url` branch above accepts, so `link-account` returns:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Linked github.' } ]
|
||||
```
|
||||
|
||||
## Keep secrets out of forms
|
||||
|
||||
Form answers travel back through the client and land in the model's context like any other tool result.
|
||||
|
||||
::: warning
|
||||
Never collect sensitive information — passwords, API keys, payment details — through form elicitation. Use URL mode or an out-of-band flow instead.
|
||||
:::
|
||||
|
||||
## Require the elicitation capability
|
||||
|
||||
Elicitation only works against a client that declared the `elicitation` capability — per mode: `form`, `url` — when it connected. Against a client without it, `elicitInput` throws before anything reaches the wire, and the thrown message comes back as an ordinary `isError` tool result:
|
||||
|
||||
```
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: 'Client does not support form elicitation.' }
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
## Recap
|
||||
|
||||
- `ctx.mcpReq.elicitInput` sends an `elicitation/create` request mid-handler and resolves with the end user's answer.
|
||||
- Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it.
|
||||
- `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept.
|
||||
- URL mode hands the end user a browser flow — use it for anything sensitive.
|
||||
- Calls against a client that never declared the `elicitation` capability fail before reaching the wire.
|
||||
209
docs/servers/errors.md
Normal file
209
docs/servers/errors.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Errors
|
||||
|
||||
A **tool error** is a successful JSON-RPC result with `isError: true` that the model reads and recovers from. A **protocol error** is a JSON-RPC error response the model never sees.
|
||||
|
||||
## Return a tool error with `isError`
|
||||
|
||||
Return `isError: true` from a tool handler to report a failure the model should see.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#registerTool_isError"
|
||||
import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const notes = new Map([['welcome', 'Read tools.md first.']]);
|
||||
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'read-note',
|
||||
{
|
||||
description: 'Read a note by its id',
|
||||
inputSchema: z.object({ id: z.string() })
|
||||
},
|
||||
async ({ id }) => {
|
||||
const note = notes.get(id);
|
||||
if (!note) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return { content: [{ type: 'text', text: note }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring. Call `read-note` with an id that does not exist.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#callTool_isError"
|
||||
const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } });
|
||||
console.log(missing);
|
||||
```
|
||||
|
||||
The `tools/call` response is an ordinary result:
|
||||
|
||||
```
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No note with id "drafts". Known ids: welcome'
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
The model reads the message, sees `welcome` in it, and retries with an id that exists. Put the recovery hint in `text` — it is the only thing the model has to work with.
|
||||
|
||||
## Let a thrown exception become a tool error
|
||||
|
||||
Throw instead: the SDK catches anything a tool handler throws and converts it to the same `isError: true` shape.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#registerTool_throw"
|
||||
server.registerTool(
|
||||
'delete-note',
|
||||
{
|
||||
description: 'Delete a note by its id',
|
||||
inputSchema: z.object({ id: z.string() })
|
||||
},
|
||||
async ({ id }) => {
|
||||
if (!notes.delete(id)) {
|
||||
throw new Error(`Cannot delete "${id}": no such note`);
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Deleted "${id}"` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Call `delete-note` with the same missing id.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#callTool_throw"
|
||||
const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } });
|
||||
console.log(thrown);
|
||||
```
|
||||
|
||||
The exception's `message` becomes the result's `content` text:
|
||||
|
||||
```
|
||||
{
|
||||
content: [ { type: 'text', text: 'Cannot delete "drafts": no such note' } ],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
A throw and an explicit `isError: true` produce the same shape; returning explicitly gives you control over `content`. The SDK skips `outputSchema` validation on any `isError` result.
|
||||
|
||||
## Throw a protocol error
|
||||
|
||||
Resource, prompt, and completion callbacks have no `isError` channel. Throw `ProtocolError(code, message, data?)` when the request itself is wrong.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#registerResource_protocolError"
|
||||
server.registerResource(
|
||||
'note',
|
||||
new ResourceTemplate('note://{id}', { list: undefined }),
|
||||
{ description: 'A note by its id' },
|
||||
async (uri, { id }) => {
|
||||
const noteId = String(id);
|
||||
if (!/^[a-z]+$/.test(noteId)) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`);
|
||||
}
|
||||
const note = notes.get(noteId);
|
||||
if (!note) throw new ResourceNotFoundError(uri.href);
|
||||
return { contents: [{ uri: uri.href, text: note }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
::: info Coming from v1?
|
||||
`ProtocolError` and `ProtocolErrorCode` replace v1's `McpError` and `ErrorCode` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
Read the resource with an id the callback rejects.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#readResource_protocolError"
|
||||
try {
|
||||
await client.readResource({ uri: 'note://42' });
|
||||
} catch (error) {
|
||||
const { code, message } = error as ProtocolError;
|
||||
console.log({ code, message });
|
||||
}
|
||||
```
|
||||
|
||||
`readResource` rejects with a `ProtocolError` carrying the wire fields:
|
||||
|
||||
```
|
||||
{ code: -32602, message: 'Note ids are lowercase letters, got "42"' }
|
||||
```
|
||||
|
||||
On the wire this is a JSON-RPC error response — `{ code, message, data? }` instead of a `result` — and the host's MCP client handles it; the model never sees it. A non-`ProtocolError` exception thrown from one of these callbacks surfaces as `-32603` Internal Error with the exception's message.
|
||||
|
||||
## Choose between tool error and protocol error
|
||||
|
||||
Pick by audience. The model drives `tools/call`, so a failure it can recover from — a missing record, a bad argument, a transient upstream fault — belongs in `isError: true` with a message that names the fix. The host application drives `resources/read`, `prompts/get`, and `completion/complete`, so failures there are protocol errors addressed to the caller's code.
|
||||
|
||||
The handler decides which channel exists:
|
||||
|
||||
- A tool handler produces only tool errors. The SDK converts every exception it throws — including a thrown `ProtocolError` — into an `isError: true` result. `UrlElicitationRequiredError` is the one exception; it propagates as a JSON-RPC error so the host can open the URL — see [Elicitation](./elicitation.md).
|
||||
- A resource, prompt, or completion callback produces only protocol errors. Throw a `ProtocolError`.
|
||||
|
||||
## Use the typed error subclasses
|
||||
|
||||
Each subclass picks the right `ProtocolErrorCode` and packs structured `data` for you. `ResourceNotFoundError` takes the missing URI — the read callback above already throws it for a well-formed id with no note.
|
||||
|
||||
```ts source="../../examples/guides/servers/errors.examples.ts#readResource_notFound"
|
||||
try {
|
||||
await client.readResource({ uri: 'note://archived' });
|
||||
} catch (error) {
|
||||
const { code, message, data } = error as ResourceNotFoundError;
|
||||
console.log({ code, message, data });
|
||||
}
|
||||
```
|
||||
|
||||
The error carries the requested URI in `data` and the code the spec mandates for a `resources/read` miss:
|
||||
|
||||
```
|
||||
{
|
||||
code: -32602,
|
||||
message: 'Resource not found: note://archived',
|
||||
data: { uri: 'note://archived' }
|
||||
}
|
||||
```
|
||||
|
||||
Three more subclasses cover the other structured protocol errors:
|
||||
|
||||
- `UrlElicitationRequiredError(elicitations)` — `-32042`; the only error a tool handler can propagate. See [Elicitation](./elicitation.md).
|
||||
- `UnsupportedProtocolVersionError({ supported, requested })` — `-32022`; `data.supported` lets the peer pick a version and retry.
|
||||
- `MissingRequiredClientCapabilityError({ requiredCapabilities })` — `-32021`; `data.requiredCapabilities` names exactly what the client must declare.
|
||||
|
||||
Match these by `code` and `data` shape, not by `instanceof` — `instanceof` fails across separately bundled copies of the SDK.
|
||||
|
||||
## Look up a protocol error code
|
||||
|
||||
`ProtocolErrorCode` is the complete vocabulary of wire codes the SDK sends and recognizes.
|
||||
|
||||
| Member | Code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `ParseError` | `-32700` | The message was not valid JSON. |
|
||||
| `InvalidRequest` | `-32600` | The message was not a valid JSON-RPC request. |
|
||||
| `MethodNotFound` | `-32601` | No handler is registered for the method. |
|
||||
| `InvalidParams` | `-32602` | The params are wrong — also the code for a `resources/read` miss. |
|
||||
| `InternalError` | `-32603` | The handler threw something other than a `ProtocolError`. |
|
||||
| `ResourceNotFound` | `-32002` | Receive-tolerated only: the SDK answers a `resources/read` miss with `-32602` and never emits `-32002`. Throw `ResourceNotFoundError` instead. |
|
||||
| `MissingRequiredClientCapability` | `-32021` | The request needs a capability the client did not declare. |
|
||||
| `UnsupportedProtocolVersion` | `-32022` | The requested protocol version is unknown to the receiver or unsupported by it. |
|
||||
| `UrlElicitationRequired` | `-32042` | The tool needs the user to visit a URL before it can complete. |
|
||||
|
||||
`-32021` and `-32022` are new in protocol revision 2026-07-28 — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Recap
|
||||
|
||||
- `isError: true` is a successful JSON-RPC result carrying a tool failure the model reads and acts on.
|
||||
- A tool handler that throws produces the same `isError: true` result; the exception's `message` becomes the `content` text.
|
||||
- A tool handler cannot produce a protocol error — only `UrlElicitationRequiredError` escapes.
|
||||
- `ProtocolError` and its subclasses, thrown from resource, prompt, and completion callbacks, become JSON-RPC error responses the model never sees.
|
||||
- `ResourceNotFoundError` and the other subclasses pick the code and pack structured `data`; match them by `code` and `data`, not `instanceof`.
|
||||
- The table above lists every `ProtocolErrorCode` member.
|
||||
251
docs/servers/input-required.md
Normal file
251
docs/servers/input-required.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# input_required
|
||||
|
||||
An **`input_required`** result is how a `tools/call`, `prompts/get`, or `resources/read` handler asks the connected client for input mid-call: the handler returns the embedded requests, the client answers them and retries the call, and the handler runs again with the responses.
|
||||
|
||||
## Return `input_required` instead of pushing a request
|
||||
|
||||
The handler reads what already arrived with `acceptedContent`; while the answer is missing it returns `inputRequired(...)` instead of a tool result.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_inputRequired"
|
||||
server.registerTool(
|
||||
'deploy',
|
||||
{
|
||||
description: 'Deploy after the operator confirms',
|
||||
inputSchema: z.object({ env: z.string() })
|
||||
},
|
||||
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
|
||||
if (confirmed?.confirm !== true) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: `Deploy to ${env}?`,
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Deployed to ${env}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The first round returns `resultType: 'input_required'` carrying the `confirm` request. The client fulfils it and retries `deploy` with the answer in `inputResponses`; on re-entry `acceptedContent` finds it and the handler finishes.
|
||||
|
||||
Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](../testing.md) shows that wiring. Calling `deploy` once produces both rounds:
|
||||
|
||||
```
|
||||
[client] elicitation/create → Deploy to prod?
|
||||
{ content: [ { type: 'text', text: 'Deployed to prod' } ] }
|
||||
```
|
||||
|
||||
`inputRequired(spec)` throws a `TypeError` unless `spec` carries at least one of `inputRequests` or `requestState`. Each embedded request is checked against the capabilities the client declared; a missing capability rejects the call with `-32021` before anything reaches the wire.
|
||||
|
||||
::: info Coming from v1?
|
||||
`ctx.mcpReq.elicitInput` and `ctx.mcpReq.requestSampling` are the 2025-era push channels — they throw on a 2026-07-28 request. See [Elicitation](./elicitation.md) and the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Read the responses on re-entry
|
||||
|
||||
`ctx.mcpReq.inputResponses` comes from the client — treat it as untrusted. Pass a Zod schema as `acceptedContent`'s third argument and the value reaches your handler already validated and typed.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#acceptedContent_schema"
|
||||
server.registerTool(
|
||||
'tag-release',
|
||||
{
|
||||
description: 'Tag a release after the operator confirms',
|
||||
inputSchema: z.object({ tag: z.string() })
|
||||
},
|
||||
async ({ tag }, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
|
||||
if (view.kind === 'elicit' && view.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: 'Tagging cancelled by the operator' }], isError: true };
|
||||
}
|
||||
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() }));
|
||||
if (confirmed?.confirm !== true) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: `Tag ${tag}?`,
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Tagged ${tag}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
`acceptedContent` returns `undefined` for a missing, declined, or cancelled answer alike — re-issuing the request is the right move for all three only when the request is idempotent. `inputResponse` returns a discriminated view (`missing` / `elicit` / `sampling` / `roots`) when you need to tell a refusal from a first entry. A client that declines:
|
||||
|
||||
```
|
||||
[client] elicitation/create → Tag v2.1.0?
|
||||
{
|
||||
content: [ { type: 'text', text: 'Tagging cancelled by the operator' } ],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
## Write the handler write-once
|
||||
|
||||
Write one handler that runs on every round: read each answer first, then request only the keys still missing. `inputRequests` is a map, so one round carries every outstanding request.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#registerTool_writeOnce"
|
||||
server.registerTool(
|
||||
'provision',
|
||||
{ description: 'Provision a database', inputSchema: z.object({}) },
|
||||
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const name = acceptedContent(ctx.mcpReq.inputResponses, 'name', z.object({ name: z.string() }));
|
||||
const region = acceptedContent(ctx.mcpReq.inputResponses, 'region', z.object({ region: z.string() }));
|
||||
if (name === undefined || region === undefined) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
...(name === undefined && {
|
||||
name: inputRequired.elicit({
|
||||
message: 'Database name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
})
|
||||
}),
|
||||
...(region === undefined && {
|
||||
region: inputRequired.elicit({
|
||||
message: 'Which region?',
|
||||
requestedSchema: { type: 'object', properties: { region: { type: 'string' } }, required: ['region'] }
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Provisioned ${name.name} in ${region.region}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Round one finds neither key, so both requests go out together; round two finds both and the handler returns.
|
||||
|
||||
```
|
||||
[client] elicitation/create → Database name?
|
||||
[client] elicitation/create → Which region?
|
||||
{
|
||||
content: [ { type: 'text', text: 'Provisioned analytics in eu-west-1' } ]
|
||||
}
|
||||
```
|
||||
|
||||
`inputResponses` holds only the latest round's answers, and nothing else on the server survives between rounds. A flow whose rounds must run in **sequence** carries what it has learned in `requestState`, below.
|
||||
|
||||
## Pick the embedded request kind
|
||||
|
||||
Each value in `inputRequests` is one embedded request, named by the builder that constructs it: `inputRequired.elicit` (form), `inputRequired.elicitUrl` (out-of-band URL), `inputRequired.createMessage` (sampling), and `inputRequired.listRoots()`.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#inputRequired_kinds"
|
||||
const next = inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: 'Continue?',
|
||||
requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] }
|
||||
}),
|
||||
signin: inputRequired.elicitUrl({ message: 'Sign in to continue', url: 'https://example.com/auth' }),
|
||||
summary: inputRequired.createMessage({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the diff' } }],
|
||||
maxTokens: 200
|
||||
}),
|
||||
roots: inputRequired.listRoots()
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`acceptedContent` only reads accepted form elicitations; read the sampling and roots responses through `inputResponse`, which discriminates all four kinds. [Elicitation](./elicitation.md) covers `requestedSchema` and URL mode in full.
|
||||
|
||||
::: warning
|
||||
Sampling and roots are deprecated as of protocol revision 2026-07-28 (SEP-2577) — see [Sampling](./sampling.md). Reach for the elicitation builders first.
|
||||
:::
|
||||
|
||||
## Carry state across rounds with `requestState`
|
||||
|
||||
To run rounds in sequence, return an opaque `requestState` string alongside the requests. The client echoes it back byte-for-byte on the retry, and `ctx.mcpReq.requestState<State>()` reads its decoded payload on re-entry. Mint it with the codec from the next section.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_mint"
|
||||
server.registerTool(
|
||||
'wipe-cache',
|
||||
{ description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) },
|
||||
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const state = ctx.mcpReq.requestState<{ step: string }>();
|
||||
|
||||
if (state?.step !== 'confirmed') {
|
||||
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
|
||||
if (confirmed?.confirm !== true) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
confirm: inputRequired.elicit({
|
||||
message: 'Really wipe the cache?',
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
// Mint only what the response above already proved: the operator confirmed.
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
scope: inputRequired.elicit({
|
||||
message: 'Which scope?',
|
||||
requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] }
|
||||
})
|
||||
},
|
||||
requestState: await stateCodec.mint({ step: 'confirmed' })
|
||||
});
|
||||
}
|
||||
|
||||
const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope');
|
||||
return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Mint only what earlier rounds already proved. The token is bearer proof of whatever it claims: state minted as `{ step: 'confirmed' }` before the confirmation arrives grants that step to anyone who echoes it. One call drives all three entries:
|
||||
|
||||
```
|
||||
[client] elicitation/create → Really wipe the cache?
|
||||
[client] elicitation/create → Which scope?
|
||||
{ content: [ { type: 'text', text: 'Wiped sessions' } ] }
|
||||
```
|
||||
|
||||
## Protect `requestState` with the codec
|
||||
|
||||
`requestState` round-trips through the client and comes back as attacker-controlled input; the SDK applies no protection of its own. `createRequestStateCodec` returns an HMAC-SHA256 `{ mint, verify }` pair — pass `verify` as `ServerOptions.requestState.verify` and it runs before every handler entry that carries state.
|
||||
|
||||
```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_codec"
|
||||
const stateCodec = createRequestStateCodec<{ step: string }>({
|
||||
key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share it across instances in a fleet
|
||||
ttlSeconds: 600
|
||||
});
|
||||
|
||||
const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestState: { verify: stateCodec.verify } });
|
||||
```
|
||||
|
||||
With the hook in place, the accessor hands the handler `verify`'s decoded payload, and tampered or expired state never reaches the handler at all. Retrying `wipe-cache` with `requestState: 'tampered'` answers a wire-level protocol error:
|
||||
|
||||
```
|
||||
-32602 Invalid or expired requestState
|
||||
```
|
||||
|
||||
::: warning
|
||||
The codec is signed, not encrypted — the client can base64url-decode the payload. Keep secrets out of it.
|
||||
:::
|
||||
|
||||
## Let the shim serve older clients
|
||||
|
||||
The handlers above already serve every connection. On a connection that predates 2026-07-28, the SDK's legacy shim — on by default — fulfils an `input_required` return by pushing real `elicitation/create`, `sampling/createMessage`, and `roots/list` requests over the session, then re-enters the handler with the collected responses and the byte-exact `requestState` echo. Every result quoted on this page came from such a connection.
|
||||
|
||||
Set `ServerOptions.inputRequired.legacyShim: false` to fail loudly instead. Which revision a connection negotiates is covered in [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Recap
|
||||
|
||||
- A handler asks for input by returning `inputRequired(...)`; the client answers the embedded requests and retries the call.
|
||||
- `inputRequired(spec)` needs at least one of `inputRequests` or `requestState`, and throws a `TypeError` without one.
|
||||
- `acceptedContent(ctx.mcpReq.inputResponses, key, schema)` validates the untrusted client answer before it reaches your code; `inputResponse` discriminates declines and the non-elicitation kinds.
|
||||
- A write-once handler re-derives its position on every entry and requests only what is still missing.
|
||||
- `requestState` is the only cross-round memory; protect it with `createRequestStateCodec` and mint only what earlier rounds proved.
|
||||
- The legacy shim serves the same handlers to pre-2026-07-28 clients.
|
||||
205
docs/servers/logging-progress-cancellation.md
Normal file
205
docs/servers/logging-progress-cancellation.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Logging, progress, and cancellation
|
||||
|
||||
Every handler receives a **context** as its second argument; the request-scoped helpers — progress, logging, and the cancellation signal — live on `ctx.mcpReq`.
|
||||
|
||||
## Report progress from a handler
|
||||
|
||||
A client that wants progress puts a `progressToken` in the request's `_meta`. Read it from `ctx.mcpReq._meta` and send each update with `ctx.mcpReq.notify`.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_progress"
|
||||
server.registerTool(
|
||||
'process-files',
|
||||
{
|
||||
description: 'Process files with progress updates',
|
||||
inputSchema: z.object({ files: z.array(z.string()) })
|
||||
},
|
||||
async ({ files }, ctx) => {
|
||||
const progressToken = ctx.mcpReq._meta?.progressToken;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
// ... process files[i] ...
|
||||
|
||||
if (progressToken !== undefined) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Processed ${files.length} files` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring. Pass an `onprogress` callback and the SDK puts the `progressToken` in `_meta` for you.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_onprogress"
|
||||
const result = await client.callTool(
|
||||
{ name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } },
|
||||
{ onprogress: update => console.log(update) }
|
||||
);
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
The callback fires once per file, then the call resolves:
|
||||
|
||||
```
|
||||
{ progress: 1, total: 3, message: 'Processed a.csv' }
|
||||
{ progress: 2, total: 3, message: 'Processed b.csv' }
|
||||
{ progress: 3, total: 3, message: 'Processed c.csv' }
|
||||
[ { type: 'text', text: 'Processed 3 files' } ]
|
||||
```
|
||||
|
||||
## Skip progress when the client did not ask
|
||||
|
||||
Drop `onprogress` and the same request arrives with no `progressToken`, so the guard in the handler sends nothing.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_noProgress"
|
||||
const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } });
|
||||
console.log(quiet.content);
|
||||
```
|
||||
|
||||
Only the result comes back:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Processed 2 files' } ]
|
||||
```
|
||||
|
||||
`progress` must increase on every notification for the same token; `total` and `message` are optional.
|
||||
|
||||
## Log to the client
|
||||
|
||||
::: warning Deprecated — SEP-2577
|
||||
Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated).
|
||||
:::
|
||||
|
||||
Declare the `logging` capability when you construct the server.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#logging_capability"
|
||||
const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
```
|
||||
|
||||
`ctx.mcpReq.log(level, data)` then sends a `notifications/message` from inside any handler — `data` is any JSON value.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_logging"
|
||||
server.registerTool(
|
||||
'validate-records',
|
||||
{
|
||||
description: 'Validate records before import',
|
||||
inputSchema: z.object({ records: z.array(z.string()) })
|
||||
},
|
||||
async ({ records }, ctx) => {
|
||||
await ctx.mcpReq.log('info', `Validating ${records.length} records`);
|
||||
const invalid = records.filter(record => !record.endsWith('.csv'));
|
||||
if (invalid.length > 0) {
|
||||
await ctx.mcpReq.log('warning', { invalid });
|
||||
}
|
||||
return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The connected client surfaces each one through its `notifications/message` handler.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#setNotificationHandler_message"
|
||||
client.setNotificationHandler('notifications/message', notification => {
|
||||
console.log(notification.params.level, notification.params.data);
|
||||
});
|
||||
```
|
||||
|
||||
Calling `validate-records` with one bad record delivers both log notifications before the result:
|
||||
|
||||
```
|
||||
info Validating 2 records
|
||||
warning { invalid: [ 'b.txt' ] }
|
||||
[ { type: 'text', text: '1 of 2 records are valid' } ]
|
||||
```
|
||||
|
||||
How the client's log level reaches `ctx.mcpReq.log` differs by protocol era — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Stop work when the request is cancelled
|
||||
|
||||
`ctx.mcpReq.signal` is an `AbortSignal`. The SDK aborts it when the client sends `notifications/cancelled` for the request, and when the connection closes — check it before each unit of work.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_abort"
|
||||
server.registerTool(
|
||||
'scan-archive',
|
||||
{
|
||||
description: 'Scan every page of the archive',
|
||||
inputSchema: z.object({ pages: z.number().int() })
|
||||
},
|
||||
async ({ pages }, ctx) => {
|
||||
let scanned = 0;
|
||||
for (let page = 0; page < pages; page++) {
|
||||
if (ctx.mcpReq.signal.aborted) {
|
||||
console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`);
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ...
|
||||
scanned++;
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Cancel from the client by aborting the signal you pass to the call.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#callTool_abort"
|
||||
const controller = new AbortController();
|
||||
const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal });
|
||||
|
||||
// the end user clicks Stop while the scan runs
|
||||
setTimeout(() => controller.abort('the end user clicked Stop'), 5);
|
||||
|
||||
await scan.catch(error => console.log(String(error)));
|
||||
```
|
||||
|
||||
The call rejects on the client and the handler stops at its next check; the abort reason travels in the notification and comes out as `ctx.mcpReq.signal.reason`:
|
||||
|
||||
```
|
||||
SdkError: the end user clicked Stop
|
||||
Stopped after 1 of 40 pages: the end user clicked Stop
|
||||
```
|
||||
|
||||
The first line is the client's rejection, the second is the handler's `console.error` on the server. The SDK never sends a response for a cancelled request and discards whatever the handler still returns.
|
||||
|
||||
## Pass the signal to your own I/O
|
||||
|
||||
Hand the same signal to `fetch` — or any API that accepts an `AbortSignal` — and cancellation propagates into the work the handler started.
|
||||
|
||||
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#registerTool_forwardSignal"
|
||||
const SOURCE_URLS = {
|
||||
readme: 'https://example.com/sources/readme.md',
|
||||
changelog: 'https://example.com/sources/changelog.md'
|
||||
};
|
||||
|
||||
server.registerTool(
|
||||
'fetch-source',
|
||||
{
|
||||
description: 'Download one of the known source files',
|
||||
inputSchema: z.object({ source: z.enum(['readme', 'changelog']) })
|
||||
},
|
||||
async ({ source }, ctx) => {
|
||||
const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal });
|
||||
return { content: [{ type: 'text', text: await response.text() }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
On cancellation `fetch` rejects mid-download and the handler unwinds with it, so no work outlives the request.
|
||||
|
||||
::: warning
|
||||
Resolve an identifier against a fixed list, as `fetch-source` does. A tool that fetches a caller-supplied URL lets any connected client drive requests from your server's network position (server-side request forgery).
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
||||
- Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`.
|
||||
- `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one.
|
||||
- `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577).
|
||||
- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.
|
||||
111
docs/servers/notifications.md
Normal file
111
docs/servers/notifications.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
|
||||
# Notifications
|
||||
|
||||
A **notification** is a one-way message your server pushes to a connected client; change notifications tell clients that a list or a resource they cached is stale.
|
||||
|
||||
## Send a list-changed notification
|
||||
|
||||
Start from a server with one tool.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#notifications_server"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const jobs = ['nightly-backup'];
|
||||
|
||||
const server = new McpServer({ name: 'jobs', version: '1.0.0' });
|
||||
|
||||
server.registerTool('list-jobs', { description: 'List the configured jobs' }, async () => ({
|
||||
content: [{ type: 'text', text: jobs.join('\n') }]
|
||||
}));
|
||||
```
|
||||
|
||||
Every notification on this page is observed by an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — which logs each notification method it receives. Push a tool-list change yourself when the tool set changes for a reason the registration API cannot see.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#sendToolListChanged_basic"
|
||||
server.sendToolListChanged();
|
||||
```
|
||||
|
||||
The client receives one `notifications/tools/list_changed` and re-fetches `tools/list`:
|
||||
|
||||
```
|
||||
notifications/tools/list_changed
|
||||
```
|
||||
|
||||
`sendPromptListChanged()` and `sendResourceListChanged()` are the prompt and resource siblings.
|
||||
|
||||
## Let registration changes notify for you
|
||||
|
||||
Hold on to the handle `registerTool` returns — every mutation through it sends the matching list-changed on its own.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#registeredTool_update"
|
||||
const report = server.registerTool('run-report', { description: 'Run the weekly report' }, async () => ({
|
||||
content: [{ type: 'text', text: 'report queued' }]
|
||||
}));
|
||||
|
||||
report.update({ description: 'Run the weekly report and email it' });
|
||||
report.disable();
|
||||
```
|
||||
|
||||
Registering, updating, and disabling each sent a notification of its own — three more, none explicit:
|
||||
|
||||
```
|
||||
notifications/tools/list_changed
|
||||
notifications/tools/list_changed
|
||||
notifications/tools/list_changed
|
||||
```
|
||||
|
||||
`enable()` and `remove()` notify the same way, and the handles returned by `registerResource` and `registerPrompt` send `notifications/resources/list_changed` and `notifications/prompts/list_changed`. Most servers never call a `send*ListChanged()` method directly.
|
||||
|
||||
## Advertise the `listChanged` capability
|
||||
|
||||
`McpServer` advertised `tools: { listChanged: true }` the moment you registered a tool, and does the same for prompts and resources. Only the [low-level `Server`](../advanced/low-level-server.md) needs the capability declared up front.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#Server_listChanged"
|
||||
const lowLevel = new Server({ name: 'jobs', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } });
|
||||
```
|
||||
|
||||
The low-level `Server` refuses to send a notification its capabilities do not cover — `sendToolListChanged()` throws without a `tools` capability — and clients use the `listChanged` flag to decide which notification types to ask for.
|
||||
|
||||
## Publish a resource update through the handler
|
||||
|
||||
On a 2026-07-28 connection, change notifications reach a client only on a [`subscriptions/listen`](../clients/subscriptions.md) stream the client opens — see [Protocol versions](../protocol-versions.md). Behind [`createMcpHandler`](../serving/http.md) the `McpServer` instance is per-request, so publish through the handler, not the instance: `notify` is a typed facade over the handler's open subscription streams.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#handler_notifyResourceUpdated"
|
||||
const handler = createMcpHandler(() => buildJobsServer());
|
||||
|
||||
// After config://app changes:
|
||||
handler.notify.resourceUpdated('config://app');
|
||||
```
|
||||
|
||||
Every client whose stream listed `config://app` receives `notifications/resources/updated` carrying that URI; `notify.toolsChanged()`, `notify.promptsChanged()`, and `notify.resourcesChanged()` publish the three list-changed types the same way. Per-resource updates have one extra gate: the server your factory builds must advertise `resources: { subscribe: true }`.
|
||||
|
||||
::: tip
|
||||
On stdio, [`serveStdio`](../serving/stdio.md) routes the instance's own `send*ListChanged()` and `sendResourceUpdated()` calls onto its open subscription stream — no `notify` facade needed.
|
||||
:::
|
||||
|
||||
::: info Coming from 2025-era subscriptions
|
||||
A 2025-era connection delivers per-resource updates without a listen stream — [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) covers that path, and [Protocol versions](../protocol-versions.md) the era split.
|
||||
:::
|
||||
|
||||
## Pick an event bus for multi-process deployments
|
||||
|
||||
The `bus` option accepts any `ServerEventBus`; `InMemoryServerEventBus` is what `createMcpHandler` builds when you omit it.
|
||||
|
||||
```ts source="../../examples/guides/servers/notifications.examples.ts#createMcpHandler_bus"
|
||||
const bus = new InMemoryServerEventBus();
|
||||
|
||||
const shared = createMcpHandler(() => buildJobsServer(), { bus });
|
||||
```
|
||||
|
||||
The in-memory bus never leaves the process, so one process needs nothing more. Run more than one and you implement the two-method `ServerEventBus` interface — `publish` and `subscribe` — over your own pub/sub backend, then pass the same instance to every handler; see [Sessions, state, and scaling](../serving/sessions-state-scaling.md).
|
||||
|
||||
## Recap
|
||||
|
||||
- `sendToolListChanged()`, `sendPromptListChanged()`, and `sendResourceListChanged()` push a list-changed notification to connected clients.
|
||||
- Registering, updating, enabling, disabling, or removing through a registration handle sends the matching list-changed for you.
|
||||
- `McpServer` advertises `listChanged` as you register; only the low-level `Server` declares it up front.
|
||||
- Behind `createMcpHandler`, publish through `handler.notify`; delivery reaches every open subscription stream that opted in.
|
||||
- One process needs no `bus`; more than one shares a `ServerEventBus`.
|
||||
205
docs/servers/prompts.md
Normal file
205
docs/servers/prompts.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Prompts
|
||||
|
||||
A **prompt** is a message template a connected client invokes by name. Clients surface prompts directly to people — slash commands, menu entries — where [tools](./tools.md) are picked by the model.
|
||||
|
||||
## Register a prompt
|
||||
|
||||
`registerPrompt` takes a name, a config, and a callback that returns the messages. `argsSchema` is a Zod object schema describing the arguments.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_review"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const server = new McpServer({ name: 'review', version: '1.0.0' });
|
||||
|
||||
server.registerPrompt(
|
||||
'review-code',
|
||||
{
|
||||
title: 'Code Review',
|
||||
description: 'Review code for best practices and potential issues',
|
||||
argsSchema: z.object({
|
||||
code: z.string().describe('The code to review')
|
||||
})
|
||||
},
|
||||
({ code }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Review this code:\n\n${code}` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`prompts/list` now advertises `review-code` with one required argument, `code`.
|
||||
|
||||
::: tip
|
||||
`.describe()` survives the conversion: `prompts/list` carries `The code to review` as the `code` argument's `description` — what a client shows next to the input field.
|
||||
:::
|
||||
|
||||
::: info Coming from v1?
|
||||
`registerPrompt` replaces `prompt()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same when someone picks the prompt. Fetch it with `getPrompt`.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#getPrompt_review"
|
||||
const result = await client.getPrompt({ name: 'review-code', arguments: { code: 'let x = 1' } });
|
||||
console.log(result.messages);
|
||||
```
|
||||
|
||||
The callback's messages come back with the argument filled in:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: 'Review this code:\n\nlet x = 1' }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Validate the arguments with the schema
|
||||
|
||||
Drop the required argument.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#getPrompt_invalid"
|
||||
import type { ProtocolError } from '@modelcontextprotocol/client';
|
||||
|
||||
try {
|
||||
await client.getPrompt({ name: 'review-code', arguments: {} });
|
||||
} catch (error) {
|
||||
const { code, message } = error as ProtocolError;
|
||||
console.log(code, message);
|
||||
}
|
||||
```
|
||||
|
||||
The SDK rejects the request before your callback runs:
|
||||
|
||||
```
|
||||
-32602 Invalid arguments for prompt review-code: code: Invalid input: expected string, received undefined
|
||||
```
|
||||
|
||||
A failed prompt validation is a protocol error — `getPrompt` rejects with a `ProtocolError` carrying code `-32602` (Invalid params). A [tool](./tools.md) argument rejection comes back as an `isError: true` result instead.
|
||||
|
||||
From that one schema the SDK derives the argument list `prompts/list` advertises, validates `prompts/get` arguments before your callback runs, and infers the callback's argument types.
|
||||
|
||||
## Build the messages
|
||||
|
||||
The callback returns `{ messages }`. Each message names a `role` — `'user'` or `'assistant'` — and one `content` block; add an `assistant` message after the `user` message to seed how the reply starts.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_messages"
|
||||
server.registerPrompt(
|
||||
'explain-error',
|
||||
{
|
||||
description: 'Explain a compiler error and suggest the smallest fix',
|
||||
argsSchema: z.object({ error: z.string() })
|
||||
},
|
||||
({ error }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Explain this compiler error:\n\n${error}` }
|
||||
},
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'The one-line cause:' }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The host hands the messages to the model in order, so the trailing `assistant` message becomes the start of its reply. `content` accepts the same union a tool result does: `text`, `image`, `audio`, `resource_link`, and `resource`.
|
||||
|
||||
## Embed a resource in a message
|
||||
|
||||
`type: 'resource'` puts a resource's contents inside a message. Register the resource as usual — see [Resources](./resources.md) — and embed the same `uri`, `mimeType`, and `text` in the prompt.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_embedResource"
|
||||
const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.';
|
||||
|
||||
server.registerResource('style-guide', 'doc://style-guide', { mimeType: 'text/markdown' }, async uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: styleGuide }]
|
||||
}));
|
||||
|
||||
server.registerPrompt(
|
||||
'review-against-style',
|
||||
{
|
||||
description: 'Review code against the team style guide',
|
||||
argsSchema: z.object({ code: z.string() })
|
||||
},
|
||||
({ code }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: {
|
||||
type: 'resource' as const,
|
||||
resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: styleGuide }
|
||||
}
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: { type: 'text' as const, text: `Review this code against the style guide:\n\n${code}` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`prompts/get` returns the style guide inline as the first message, so the client never makes a second `resources/read` round trip:
|
||||
|
||||
```
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'doc://style-guide',
|
||||
mimeType: 'text/markdown',
|
||||
text: '- Prefer const over let.\n- No single-letter identifiers.'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `uri` tells the client which registered resource the embedded copy came from.
|
||||
|
||||
## Offer argument autocompletion
|
||||
|
||||
Wrap an argument with `completable()` to suggest values while a client fills in the form.
|
||||
|
||||
```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_completable"
|
||||
import { completable } from '@modelcontextprotocol/server';
|
||||
|
||||
server.registerPrompt(
|
||||
'translate',
|
||||
{
|
||||
description: 'Translate a snippet into another language',
|
||||
argsSchema: z.object({
|
||||
language: completable(z.string(), value =>
|
||||
['typescript', 'python', 'rust', 'go'].filter(language => language.startsWith(value))
|
||||
),
|
||||
code: z.string()
|
||||
})
|
||||
},
|
||||
({ language, code }) => ({
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Translate to ${language}:\n\n${code}` } }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The client sends `completion/complete` with the characters typed so far; the SDK runs your function and returns the matching values. [Completion](./completion.md) covers the request flow and context-aware suggestions.
|
||||
|
||||
## Recap
|
||||
|
||||
- `registerPrompt(name, config, callback)` registers a prompt; clients discover it through `prompts/list`.
|
||||
- `argsSchema` is one Zod object: the advertised argument list, argument validation, and the callback's argument types.
|
||||
- Arguments that fail the schema reject `prompts/get` with a `-32602` protocol error; the callback never runs.
|
||||
- The callback returns `{ messages }`; each message names a `role` and one `content` block.
|
||||
- A message can embed a registered resource's contents with `type: 'resource'`.
|
||||
- `completable()` adds per-argument autocompletion.
|
||||
222
docs/servers/resources.md
Normal file
222
docs/servers/resources.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
|
||||
# Resources
|
||||
|
||||
A **resource** is read-only data — a file, a database row, a rendered report — that a connected client lists, reads, and attaches as context for the model. The client decides what to read: resources are application-controlled, where [tools](./tools.md) are model-controlled.
|
||||
|
||||
## Register a static resource
|
||||
|
||||
`registerResource` takes a name, a fixed URI, metadata, and a read callback.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_static"
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
||||
|
||||
const server = new McpServer({ name: 'workspace', version: '1.0.0' });
|
||||
|
||||
server.registerResource(
|
||||
'config',
|
||||
'config://app',
|
||||
{
|
||||
title: 'Application Config',
|
||||
description: 'Application configuration data',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async uri => ({
|
||||
contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`resources/list` now advertises `config://app` with that metadata, and `resources/read` on `config://app` runs the callback.
|
||||
|
||||
::: info Coming from v1?
|
||||
`registerResource` replaces `resource()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Return the contents from the read callback
|
||||
|
||||
The callback returns `{ contents: [...] }`. Add a resource whose contents hold two items: each one echoes the `uri` it answers for and carries either `text` or a base64 `blob`.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_report"
|
||||
// A 1x1 PNG; a production server reads these bytes from disk or object storage.
|
||||
const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=';
|
||||
|
||||
server.registerResource(
|
||||
'report',
|
||||
'report://latest',
|
||||
{
|
||||
title: 'Latest usage report',
|
||||
description: 'Weekly usage summary with a rendered chart',
|
||||
mimeType: 'text/markdown'
|
||||
},
|
||||
async uri => ({
|
||||
contents: [
|
||||
{ uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' },
|
||||
{ uri: uri.href, mimeType: 'image/png', blob: chartPng }
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Read the resource.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#readResource_report"
|
||||
const { contents } = await client.readResource({ uri: 'report://latest' });
|
||||
console.log(contents);
|
||||
```
|
||||
|
||||
The callback's array comes back unchanged, one entry per item:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
uri: 'report://latest',
|
||||
mimeType: 'text/markdown',
|
||||
text: 'Active installs grew 12% week over week.'
|
||||
},
|
||||
{
|
||||
uri: 'report://latest',
|
||||
mimeType: 'image/png',
|
||||
blob: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `mimeType` on each item describes that item; the `mimeType` in the registration config describes the resource as a whole in `resources/list`.
|
||||
|
||||
## Add a resource template
|
||||
|
||||
A `ResourceTemplate` registers a whole URI pattern instead of one URI. `list` is a required key — pass `undefined` when the instances are unbounded.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_template"
|
||||
server.registerResource(
|
||||
'user-profile',
|
||||
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
|
||||
{
|
||||
title: 'User Profile',
|
||||
description: 'Profile data for one user',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
async (uri, { userId }) => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
The matched variables arrive parsed as the read callback's second argument. Read any URI the pattern matches.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#readResource_template"
|
||||
const profile = await client.readResource({ uri: 'users://7/profile' });
|
||||
console.log(profile.contents);
|
||||
```
|
||||
|
||||
The callback ran with `userId` bound to `'7'`:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
uri: 'users://7/profile',
|
||||
mimeType: 'application/json',
|
||||
text: '{"userId":"7","plan":"pro"}'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## List the template's instances
|
||||
|
||||
`users://{userId}/profile` is readable but never appears in `resources/list` — with `list: undefined` there is nothing to enumerate. Register a template over an enumerable set and give it a `list` callback.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_list"
|
||||
server.registerResource(
|
||||
'team-roster',
|
||||
new ResourceTemplate('teams://{teamId}/roster', {
|
||||
list: async () => ({
|
||||
resources: [
|
||||
{ uri: 'teams://core/roster', name: 'Core team roster' },
|
||||
{ uri: 'teams://growth/roster', name: 'Growth team roster' }
|
||||
]
|
||||
})
|
||||
}),
|
||||
{
|
||||
description: 'Members of one team',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (uri, { teamId }) => ({
|
||||
contents: [{ uri: uri.href, text: `Members of team ${teamId}` }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`resources/list` merges the static resources with every template's `list` results:
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#listResources"
|
||||
const { resources } = await client.listResources();
|
||||
console.log(resources.map(resource => resource.uri));
|
||||
```
|
||||
|
||||
Both `teams://` rosters are discoverable; the `users://` template contributes nothing:
|
||||
|
||||
```
|
||||
[
|
||||
'config://app',
|
||||
'report://latest',
|
||||
'teams://core/roster',
|
||||
'teams://growth/roster'
|
||||
]
|
||||
```
|
||||
|
||||
`resources/templates/list` still advertises both URI patterns (`client.listResourceTemplates()`), so a client that already knows a `userId` builds the concrete URI itself.
|
||||
|
||||
## Sanitize file-backed paths
|
||||
|
||||
A template variable that becomes a filesystem path is client-controlled input. Resolve it to a real path and reject anything outside the root before you read.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#registerResource_file"
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const DOCS_ROOT = path.resolve('./docs');
|
||||
|
||||
server.registerResource(
|
||||
'doc',
|
||||
new ResourceTemplate('docs://{file}', { list: undefined }),
|
||||
{
|
||||
description: 'A markdown page from the docs directory',
|
||||
mimeType: 'text/markdown'
|
||||
},
|
||||
async (uri, { file }) => {
|
||||
const requested = await realpath(path.join(DOCS_ROOT, String(file)));
|
||||
if (!requested.startsWith(DOCS_ROOT + path.sep)) {
|
||||
throw new Error(`${uri.href} resolves outside the docs root`);
|
||||
}
|
||||
return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
`realpath` collapses `..` segments and symlinks to the path that is actually on disk; the `startsWith` check then rejects anything that escaped `DOCS_ROOT`. Throw on rejection — [Errors](./errors.md) covers how a thrown error reaches the client.
|
||||
|
||||
::: warning
|
||||
Never pass a template variable or a client-supplied URI to a filesystem API unchecked. `..` arrives raw and percent-encoded, and a symlink inside the root can point outside it — compare resolved real paths, never the strings the client sent.
|
||||
:::
|
||||
|
||||
## Tell clients when a resource changes
|
||||
|
||||
Registering, enabling, disabling, or removing a resource already sends `notifications/resources/list_changed`. Send it yourself when the set changes for a reason the SDK cannot see.
|
||||
|
||||
```ts source="../../examples/guides/servers/resources.examples.ts#sendResourceListChanged"
|
||||
server.sendResourceListChanged();
|
||||
```
|
||||
|
||||
The notification tells connected clients to call `resources/list` again. A change to one resource's content is a different signal, `notifications/resources/updated` — [Notifications](./notifications.md) covers both.
|
||||
|
||||
## Recap
|
||||
|
||||
- `registerResource(name, uri, config, readCallback)` registers a resource at a fixed URI.
|
||||
- The read callback returns `{ contents: [...] }`; each item echoes the `uri` and carries `text` or a base64 `blob`.
|
||||
- A `ResourceTemplate` registers a URI pattern; the matched variables arrive parsed as the read callback's second argument.
|
||||
- A template's `list` callback is what makes its instances appear in `resources/list`.
|
||||
- Resolve file-backed paths to their real location and reject anything outside the root before reading.
|
||||
- Registration changes emit `notifications/resources/list_changed` automatically.
|
||||
80
docs/servers/sampling.md
Normal file
80
docs/servers/sampling.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Sampling
|
||||
|
||||
::: warning Deprecated — SEP-2577
|
||||
Call your LLM provider's API directly from your server instead. **Sampling** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated).
|
||||
:::
|
||||
|
||||
## Replace sampling with a direct provider call
|
||||
|
||||
Sampling routes an LLM call through the connected client: a tool handler sends a prompt, the host runs it through a model it controls, and the handler resumes with the completion. The 2026-07-28 revision removes the server-to-client request channel that carries it.
|
||||
|
||||
Migrate by importing your LLM provider's SDK into the server and calling it from the tool handler with your own API key. The handler keeps its shape; the `requestSampling` call is the only line that changes, and you stop depending on what the client supports.
|
||||
|
||||
## Request a completion from the client
|
||||
|
||||
`ctx.mcpReq.requestSampling` sends a `sampling/createMessage` request to the connected client from inside a tool handler. The client runs the messages through its model and resolves the call with the completion.
|
||||
|
||||
```ts source="../../examples/guides/servers/sampling.examples.ts#registerTool_sampling"
|
||||
server.registerTool(
|
||||
'summarize',
|
||||
{
|
||||
description: 'Summarize text using the client LLM',
|
||||
inputSchema: z.object({ text: z.string() })
|
||||
},
|
||||
async ({ text }, ctx) => {
|
||||
const response = await ctx.mcpReq.requestSampling({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize in one sentence:\n\n${text}` } }],
|
||||
maxTokens: 500
|
||||
});
|
||||
return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The handler blocks until the client answers, so your server never holds the key for the model that does the work — the host does.
|
||||
|
||||
::: info
|
||||
On a 2026-07-28 connection `requestSampling` throws. The replacement on that revision is returning an embedded `createMessage` request from the handler — [input_required](./input-required.md) owns that form. Era differences are listed in [Protocol versions](../protocol-versions.md).
|
||||
:::
|
||||
|
||||
## Read the model's reply
|
||||
|
||||
The response is a `CreateMessageResult`: the client decides which model fulfils the request and returns its name as `model`, plus the assistant `role` and one `content` block. The handler above folds it into its tool result, so calling `summarize` from a client whose model is named `host-model` returns:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Model (host-model): {"type":"text","text":"Sampling lets a tool ask the client for a completion."}'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Require the sampling capability
|
||||
|
||||
`requestSampling` only works against a client that declared the `sampling` capability and registered a `sampling/createMessage` handler — [Handle requests from the server](../clients/server-requests.md) covers that side.
|
||||
|
||||
Pass `enforceStrictCapabilities: true` to the `McpServer` constructor and the SDK checks the client's declared capabilities before it sends any server-initiated request. Against a client that never declared `sampling`, `requestSampling` then throws inside your handler, and the call comes back as an ordinary `isError` tool result:
|
||||
|
||||
```
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Client does not support sampling (required for sampling/createMessage)'
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
## Recap
|
||||
|
||||
- Sampling is deprecated (SEP-2577); the migration target is a direct LLM provider call from your server.
|
||||
- `ctx.mcpReq.requestSampling({ messages, maxTokens })` asks the connected client's model for a completion mid-handler.
|
||||
- The client picks the model; the result carries `model`, `role`, and `content`.
|
||||
- On a 2026-07-28 connection `requestSampling` throws; the embedded-request form lives on the input_required page.
|
||||
- The client must declare the `sampling` capability; `enforceStrictCapabilities: true` rejects the request before the wire when it did not.
|
||||
159
docs/servers/tools.md
Normal file
159
docs/servers/tools.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
status: calibration
|
||||
shape: how-to
|
||||
---
|
||||
|
||||
# Tools
|
||||
|
||||
A **tool** is an action a connected client — and the model driving it — can invoke on your server.
|
||||
|
||||
## Add a tool
|
||||
|
||||
`registerTool` takes a name, a config, and a handler. `inputSchema` is a Zod schema — the only schema you write.
|
||||
|
||||
```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_search"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const catalog = [
|
||||
{ name: 'Espresso cup', price: 12 },
|
||||
{ name: 'Travel mug', price: 24 },
|
||||
{ name: 'Mug rack', price: 36 }
|
||||
];
|
||||
|
||||
const server = new McpServer({ name: 'catalog', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'search',
|
||||
{
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Substring to match against product names'),
|
||||
limit: z.number().int().max(50).optional()
|
||||
})
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
|
||||
const names = hits.slice(0, limit ?? 10).map(product => product.name);
|
||||
return { content: [{ type: 'text', text: names.join('\n') }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types.
|
||||
|
||||
`tools/list` now advertises `search`, and the SDK has already parsed every call that reaches your handler.
|
||||
|
||||
::: tip
|
||||
`.describe()` survives the conversion: the JSON Schema advertised for `query` carries `Substring to match against product names` as its `description` — the only documentation the model gets for that argument.
|
||||
:::
|
||||
|
||||
::: info Coming from v1?
|
||||
`registerTool` replaces `tool()` — run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Call it
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Call the tool with valid arguments.
|
||||
|
||||
```ts source="../../examples/guides/servers/tools.examples.ts#callTool_search"
|
||||
const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } });
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
The handler's `content` comes back unchanged:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Travel mug\nMug rack' } ]
|
||||
```
|
||||
|
||||
## Send arguments the schema rejects
|
||||
|
||||
Change one argument: a `limit` the schema caps at 50.
|
||||
|
||||
```ts source="../../examples/guides/servers/tools.examples.ts#callTool_invalid"
|
||||
const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } });
|
||||
console.log(rejected);
|
||||
```
|
||||
|
||||
The SDK rejects the arguments before your handler runs:
|
||||
|
||||
```
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Input validation error: Invalid arguments for tool search: limit: Too big: expected number to be <=50'
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
The rejection is an ordinary tool result with `isError: true`, so the model reads the message and retries with arguments that fit the schema. Thrown errors and protocol-level failures are their own topic — see [Errors](errors.md).
|
||||
|
||||
## Return structured output
|
||||
|
||||
Add `outputSchema` and return the matching value as `structuredContent`, next to the human-readable `content`.
|
||||
|
||||
```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_structured"
|
||||
server.registerTool(
|
||||
'product-details',
|
||||
{
|
||||
description: 'Look up one product by its exact name',
|
||||
inputSchema: z.object({ name: z.string() }),
|
||||
outputSchema: z.object({ name: z.string(), price: z.number() })
|
||||
},
|
||||
async ({ name }) => {
|
||||
const product = catalog.find(candidate => candidate.name === name);
|
||||
if (!product) throw new Error(`No product named ${name}`);
|
||||
const output = { name: product.name, price: product.price };
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(output) }],
|
||||
structuredContent: output
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The SDK validates `structuredContent` against `outputSchema` before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too.
|
||||
|
||||
Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings:
|
||||
|
||||
```
|
||||
{
|
||||
content: [ { type: 'text', text: '{"name":"Travel mug","price":24}' } ],
|
||||
structuredContent: { name: 'Travel mug', price: 24 }
|
||||
}
|
||||
```
|
||||
|
||||
The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Annotate the tool
|
||||
|
||||
`title` is the display name; `annotations` are behavior hints for the client.
|
||||
|
||||
```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_annotations"
|
||||
server.registerTool(
|
||||
'clear-catalog',
|
||||
{
|
||||
title: 'Clear the catalog',
|
||||
description: 'Remove every product from the catalog',
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
|
||||
},
|
||||
async () => {
|
||||
catalog.length = 0;
|
||||
return { content: [{ type: 'text', text: 'Catalog cleared' }] };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
A tool that takes no arguments omits `inputSchema`. Annotations never change how the SDK runs the tool — clients use them to decide what to put in front of the end user: a host can auto-approve a read-only tool and require confirmation before a destructive one.
|
||||
|
||||
## Recap
|
||||
|
||||
- `registerTool(name, config, handler)` registers a tool; `inputSchema` is a Zod object schema.
|
||||
- The one schema yields the advertised JSON Schema, argument validation, and the handler's argument types.
|
||||
- Arguments that fail the schema come back as an `isError: true` tool result; the handler never runs.
|
||||
- `outputSchema` plus `structuredContent` add machine-readable results, validated before they leave the server.
|
||||
- `title` and `annotations` describe the tool to clients and never change execution.
|
||||
Loading…
Reference in a new issue