Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/clients/calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,30 @@ The updates stream in while the call is still pending; the return type does not
[ { type: 'text', text: '2 orders exported as csv' } ]
```

## Check the connection

`ping` sends a `ping` request and resolves with the empty result the server returns; the SDK answers a `ping` on both sides automatically, so neither side registers a handler.

```ts source="../../examples/guides/clients/calling.examples.ts#ping_basic"
const pong = await client.ping({ timeout: 5000 });
console.log(pong);
```

The `orders` server answers at once:

```
{}
```

A server that stops answering rejects the call with an `SdkError` coded `REQUEST_TIMEOUT` once `timeout` elapses.

`ping` is a 2025-era method — see [Protocol versions](../protocol-versions.md).

## 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.
- `ping()` checks that the server still answers; both sides answer pings automatically.
1 change: 1 addition & 0 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ This table is the only copy of the era differences in these docs. `getProtocolEr
| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) |
| HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band |
| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` |
| Liveness check | `client.ping()` | not defined — outbound call rejects per the era-mismatch row |

## Separate deprecation from era

Expand Down
40 changes: 40 additions & 0 deletions docs/servers/elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,45 @@ server.registerTool(
[ { type: 'text', text: 'Declined - nothing deleted.' } ]
```

## Prefill a field with a default

Set `default` on a field and the client renders the form with that value already filled in.

```ts source="../../examples/guides/servers/elicitation.examples.ts#registerTool_elicitDefault"
server.registerTool(
'export-report',
{
description: 'Export a report after the user picks a format',
inputSchema: z.object({ name: z.string() })
},
async ({ name }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Export ${name} as which format?`,
requestedSchema: {
type: 'object',
properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } },
required: ['format']
}
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Export ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] };
}
);
```

`requestedSchema` reaches the client unchanged, `default` included; the end user submits the prefilled `pdf` or picks `csv`. An accept with `format` left out still returns:

```
[ { type: 'text', text: 'Exported quarterly-sales as pdf.' } ]
```

::: info
A client that declares `elicitation: { form: { applyDefaults: true } }` — an SDK flag, not a protocol capability — fills defaulted fields the end user leaves out before the accept reaches your handler; the output above is that case.
:::

## 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`.
Expand Down Expand Up @@ -181,5 +220,6 @@ Elicitation only works against a client that declared the `elicitation` capabili
- `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.
- `default` on a `requestedSchema` field prefills the form; a client that declares `applyDefaults` fills the field in when the end user leaves it out.
- 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.
43 changes: 42 additions & 1 deletion docs/servers/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,47 @@ server.registerPrompt(

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

## Add an image to a message

An `image` block carries base64 `data` and a `mimeType`; pair it with a `text` block that says what to do with the image.

```ts source="../../examples/guides/servers/prompts.examples.ts#registerPrompt_image"
server.registerPrompt(
'describe-image',
{
description: 'Describe an image for alt text',
argsSchema: z.object({ imageBase64: z.string().describe('Base64-encoded PNG') })
},
({ imageBase64 }) => ({
messages: [
{
role: 'user' as const,
content: { type: 'image' as const, data: imageBase64, mimeType: 'image/png' }
},
{
role: 'user' as const,
content: { type: 'text' as const, text: 'Write one sentence of alt text for this image.' }
}
]
})
);
```

`prompts/get` returns the image block as the first message, bytes unchanged:

```
{
role: 'user',
content: {
type: 'image',
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
mimeType: 'image/png'
}
}
```

`audio` takes the same shape: base64 `data` plus `mimeType`.

## 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.
Expand Down Expand Up @@ -201,5 +242,5 @@ The client sends `completion/complete` with the characters typed so far; the SDK
- `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'`.
- A message can carry an `image` (base64 `data` plus `mimeType`) or embed a registered resource's contents with `type: 'resource'`.
- `completable()` adds per-argument autocompletion.
64 changes: 64 additions & 0 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,69 @@ Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings:

The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md).

## Return other content types

One result can mix content blocks: `image` and `audio` carry base64 `data` with a `mimeType`; `resource` embeds a resource's contents inline; `resource_link` names a resource by `uri` without its bytes.

```ts source="../../examples/guides/servers/tools.examples.ts#registerTool_contentTypes"
// Base64 payloads; read yours from disk: readFileSync('card.png').toString('base64')
const cardPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const spokenNameWav = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';

server.registerTool(
'product-card',
{
description: 'Render one product as an image, a spoken name, and its catalog record',
inputSchema: z.object({ name: z.string() })
},
async ({ name }) => {
const product = catalog.find(candidate => candidate.name === name);
if (!product) throw new Error(`No product named ${name}`);
return {
content: [
{ type: 'image', data: cardPng, mimeType: 'image/png' },
{ type: 'audio', data: spokenNameWav, mimeType: 'audio/wav' },
{
type: 'resource',
resource: {
uri: `catalog://products/${encodeURIComponent(product.name)}`,
mimeType: 'application/json',
text: JSON.stringify(product)
}
}
]
};
}
);
```

Calling `product-card` with `{ name: 'Travel mug' }` returns the three blocks as written:

```
[
{
type: 'image',
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
mimeType: 'image/png'
},
{
type: 'audio',
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=',
mimeType: 'audio/wav'
},
{
type: 'resource',
resource: {
uri: 'catalog://products/Travel%20mug',
mimeType: 'application/json',
text: '{"name":"Travel mug","price":24}'
}
}
]
```

The blocks reach the client exactly as returned, and the embedded `resource` arrives without a `resources/read` round trip.

## Annotate the tool

`title` is the display name; `annotations` are behavior hints for the client.
Expand Down Expand Up @@ -156,4 +219,5 @@ A tool that takes no arguments omits `inputSchema`. Annotations never change how
- 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.
- `content` blocks are `text`, `image`, `audio`, `resource_link`, or an embedded `resource`; one result can mix them.
- `title` and `annotations` describe the tool to clients and never change execution.
37 changes: 35 additions & 2 deletions docs/serving/legacy-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,45 @@ Behind an Express body parser the Node stream is already drained: build the `Req

The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 moves to Streamable HTTP — `createMcpHandler` above — as part of the [v2 upgrade](../migration/upgrade-to-v2.md).

The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated).
The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated, planned for removal in v3).

Mount the frozen transport on two Express routes: `GET /sse` opens the stream and `POST /messages` delivers each client message to the session its `sessionId` query names. `createMcpExpressApp` takes the same options as on the [Express](./express.md) page: binding beyond localhost drops the default `Host`/`Origin` validation, so name the hosts you serve in `allowedHosts`, and raise `jsonLimit` above Express's 100kb default, since the SSE transport itself accepts messages up to 4mb.

```ts source="../../examples/guides/serving/legacy-clients.examples.ts#SSEServerTransport_express"
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse';

const sessions = new Map<string, SSEServerTransport>();
const sseApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['sse.example.com'], jsonLimit: '4mb' });

sseApp.get('/sse', async (_req, res) => {
const transport = new SSEServerTransport('/messages', res);
sessions.set(transport.sessionId, transport);
transport.onclose = () => sessions.delete(transport.sessionId);
await buildServer().connect(transport);
});

sseApp.post('/messages', async (req, res) => {
const sessionId = req.query.sessionId;
if (typeof sessionId !== 'string') {
res.status(400).send('Missing sessionId parameter');
return;
}
const transport = sessions.get(sessionId);
if (!transport) {
res.status(404).send('Session not found');
return;
}
await transport.handlePostMessage(req, res, req.body);
});
```

Each `GET /sse` connects a fresh instance from `buildServer` and answers with an `endpoint` event naming `/messages?sessionId=…`; the client POSTs every JSON-RPC message there and reads responses off the stream.

## Recap

- Both entry points serve 2025-era clients from the same factory by default; `legacy: 'reject'` makes an endpoint modern-only.
- The default HTTP posture is per request and stateless: legacy `GET` and `DELETE` session operations answer `405`.
- `serveStdio` decides the era once per connection; its default is `'serve'`.
- `isLegacyRequest` in front of a strict handler keeps an existing sessionful 2025 deployment serving its clients.
- The v2 server never serves SSE; the frozen v1 transport is `@modelcontextprotocol/server-legacy/sse`, and the client keeps `SSEClientTransport`.
- The v2 server never serves SSE; the frozen v1 `SSEServerTransport` in `@modelcontextprotocol/server-legacy/sse` mounts on `GET /sse` + `POST /messages`, and the client keeps `SSEClientTransport`.
9 changes: 9 additions & 0 deletions examples/guides/clients/calling.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,5 +201,14 @@ const exported = await client.callTool(
console.log(exported.content);
//#endregion callTool_progress

// "Check the connection" — the empty result the page quotes.
//#region ping_basic
const pong = await client.ping({ timeout: 5000 });
console.log(pong);
//#endregion ping_basic
if (Object.keys(pong).length !== 0) {
throw new Error(`calling.md claim failed: ping resolved with ${JSON.stringify(pong)}`);
}

await client.close();
await server.close();
53 changes: 53 additions & 0 deletions examples/guides/servers/elicitation.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,37 @@ server.registerTool(
);
//#endregion registerTool_elicitActions

// "Prefill a field with a default" — the requested schema carries `default`.
// Wrapped so the harness can register the same tool on a second server whose
// client declares `applyDefaults`; the page's fence shows the body unindented.
function registerExportReport(server: McpServer): void {
//#region registerTool_elicitDefault
server.registerTool(
'export-report',
{
description: 'Export a report after the user picks a format',
inputSchema: z.object({ name: z.string() })
},
async ({ name }, ctx) => {
const result = await ctx.mcpReq.elicitInput({
mode: 'form',
message: `Export ${name} as which format?`,
requestedSchema: {
type: 'object',
properties: { format: { type: 'string', title: 'Format', enum: ['pdf', 'csv'], default: 'pdf' } },
required: ['format']
}
});
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Export ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Exported ${name} as ${result.content?.format}.` }] };
}
);
//#endregion registerTool_elicitDefault
}
registerExportReport(server);

// "Send the end user to a URL" — url mode hands the browser flow to the client.
//#region registerTool_elicitUrl
server.registerTool(
Expand Down Expand Up @@ -144,6 +175,28 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline'
const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } });
console.log(declined.content);

// "Prefill a field with a default" — a client that declares `applyDefaults`
// accepts with `format` left out; the SDK fills it from the schema before the
// accept reaches the handler.
const defaultsClient = new Client(
{ name: 'defaults-host', version: '1.0.0' },
{ capabilities: { elicitation: { form: { applyDefaults: true } } } }
);
defaultsClient.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} }));
const [defaultsClientTransport, defaultsServerTransport] = InMemoryTransport.createLinkedPair();
const defaultsServer = new McpServer({ name: 'feedback', version: '1.0.0' });
registerExportReport(defaultsServer);
await defaultsServer.connect(defaultsServerTransport);
await defaultsClient.connect(defaultsClientTransport);
const exported = await defaultsClient.callTool({ name: 'export-report', arguments: { name: 'quarterly-sales' } });
console.log(exported.content);
Comment thread
claude[bot] marked this conversation as resolved.
const exportedText = Array.isArray(exported.content) && exported.content[0]?.type === 'text' ? exported.content[0].text : undefined;
if (exported.isError || exportedText !== 'Exported quarterly-sales as pdf.') {
throw new Error(`elicitation.md claim failed: applyDefaults round returned ${JSON.stringify(exported.content)}`);
}
await defaultsClient.close();
await defaultsServer.close();

// "Require the elicitation capability" — the same form tool served to a client
// that never declared the elicitation capability. elicitInput throws before
// anything reaches the wire and the message becomes the tool result.
Expand Down
Loading
Loading