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
95 changes: 94 additions & 1 deletion docs/servers/elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,99 @@ The client opens the URL and answers once the end user finishes there; whatever
[ { type: 'text', text: 'Linked github.' } ]
```

## Signal that the URL flow finished

The client learns that the end user finished at the URL from a `notifications/elicitation/complete` notification that carries the same `elicitationId`. `server.server.createElicitationCompletionNotifier` returns the function that sends it — keep it where your callback endpoint can reach it, and pass `relatedRequestId` so the notification rides the in-flight tool call. Raise the request `timeout` too — the default is 60 seconds, and a person is on the other end of this one — and forward `ctx.mcpReq.signal` so a cancelled tool call also cancels the parked elicitation.

```ts source="../../examples/guides/servers/elicitation.examples.ts#createElicitationCompletionNotifier_connectCalendar"
const pendingFlows = new Map<string, () => Promise<void>>();

server.registerTool(
'connect-calendar',
{
description: 'Connect a calendar through a hosted consent flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const elicitationId = crypto.randomUUID();
pendingFlows.set(
elicitationId,
server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
);
try {
const result = await ctx.mcpReq.elicitInput(
{
mode: 'url',
message: `Grant ${provider} calendar access`,
url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
elicitationId
},
// a person is on the other end (the default timeout is 60 s); the signal
// cancels the parked elicitation if the tool call itself is cancelled
{ timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
);
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
} finally {
pendingFlows.delete(elicitationId);
}
}
);

// The hosted flow redirects back to your server with the id in `state`; that
// endpoint sends the notification.
async function completeFlow(elicitationId: string): Promise<void> {
await pendingFlows.get(elicitationId)?.();
}
```

On the client, hold the `elicitation/create` answer until the notification names the `elicitationId` the request carried, and let `ctx.mcpReq.signal` release it when the server cancels — a timed-out or abandoned flow must not leave the handler waiting.

```ts source="../../examples/guides/servers/elicitation.examples.ts#setNotificationHandler_elicitationComplete"
const finished = new Map<string, () => void>();

client.setNotificationHandler('notifications/elicitation/complete', notification => {
console.log('URL flow finished:', notification.params.elicitationId);
finished.get(notification.params.elicitationId)?.();
finished.delete(notification.params.elicitationId);
});

client.setRequestHandler('elicitation/create', async (request, ctx) => {
if (request.params.mode === 'url') {
// Open request.params.url in the user's browser; answer once the server signals completion.
const { elicitationId } = request.params;
const done = await new Promise<'complete' | 'cancelled'>(resolve => {
finished.set(elicitationId, () => resolve('complete'));
ctx.mcpReq.signal.addEventListener('abort', () => {
finished.delete(elicitationId);
resolve('cancelled');
});
});
return { action: done === 'complete' ? 'accept' : 'cancel' };
}
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});
```

The host's own `tools/call` has the same 60-second default, so the caller raises it as well:

```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_connectCalendar_timeout"
const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });
```

Let the callback endpoint run `completeFlow` with the id from `state`, and the client logs the notification before the tool result arrives (the id is fresh on every run):

```
URL flow finished: c9a7bcfc-acc9-494c-8ce5-44c921232ea6
[ { type: 'text', text: 'Connected google.' } ]
```

::: info
This notification exists on 2025-11-25 connections only — the 2026-07-28 [input-required](./input-required.md) flow has no `elicitationId` and no completion signal; see [Protocol versions](../protocol-versions.md).
:::

## Keep secrets out of forms

Form answers travel back through the client and land in the model's context like any other tool result.
Expand Down Expand Up @@ -221,5 +314,5 @@ Elicitation only works against a client that declared the `elicitation` capabili
- 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.
- URL mode hands the end user a browser flow — use it for anything sensitive; `createElicitationCompletionNotifier` returns the function that sends `notifications/elicitation/complete` so the client can answer.
- Calls against a client that never declared the `elicitation` capability fail before reaching the wire.
23 changes: 22 additions & 1 deletion docs/servers/logging-progress-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,27 @@ 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).
## Let the client set the level

Declaring `logging` also installs the `logging/setLevel` handler, so a client raises the threshold for its session with `setLoggingLevel` and `ctx.mcpReq.log` drops anything below it.

```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#setLoggingLevel_warning"
await client.setLoggingLevel('warning');

const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } });
console.log(filtered.content);
```

The same tool now delivers only the `warning`; the `info` message never leaves the server:

```
warning { invalid: [ 'd.txt' ] }
[ { type: 'text', text: '1 of 2 records are valid' } ]
```

::: info
On a 2026-07-28 request the client's level arrives per request, not per session — see [Protocol versions](../protocol-versions.md).
:::

## Stop work when the request is cancelled

Expand Down Expand Up @@ -202,4 +222,5 @@ Resolve an identifier against a fixed list, as `fetch-source` does. A tool that
- 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).
- Declaring `logging` also installs `logging/setLevel`; after `client.setLoggingLevel(level)` the SDK drops messages below that level for the session.
- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.
100 changes: 100 additions & 0 deletions examples/guides/servers/elicitation.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,52 @@ server.registerTool(
);
//#endregion registerTool_elicitUrl

// "Signal that the URL flow finished" — the server tells the client when the
// out-of-band flow completes, so the client can answer the pending request.
//#region createElicitationCompletionNotifier_connectCalendar
const pendingFlows = new Map<string, () => Promise<void>>();

server.registerTool(
'connect-calendar',
{
description: 'Connect a calendar through a hosted consent flow',
inputSchema: z.object({ provider: z.string() })
},
async ({ provider }, ctx) => {
const elicitationId = crypto.randomUUID();
pendingFlows.set(
elicitationId,
server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
);
try {
const result = await ctx.mcpReq.elicitInput(
{
mode: 'url',
message: `Grant ${provider} calendar access`,
url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
elicitationId
},
// a person is on the other end (the default timeout is 60 s); the signal
// cancels the parked elicitation if the tool call itself is cancelled
{ timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
);
if (result.action !== 'accept') {
return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
}
return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
} finally {
pendingFlows.delete(elicitationId);
}
}
);

// The hosted flow redirects back to your server with the id in `state`; that
// endpoint sends the notification.
async function completeFlow(elicitationId: string): Promise<void> {
await pendingFlows.get(elicitationId)?.();
}
//#endregion createElicitationCompletionNotifier_connectCalendar

// ---------------------------------------------------------------------------
// Harness (not shown on the page beyond the two regions below). An in-memory
// client plays the end user; a real host renders UI instead. Imported
Expand Down Expand Up @@ -175,6 +221,60 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline'
const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } });
console.log(declined.content);

// "Signal that the URL flow finished" — the client holds its answer until the
// completion notification names the elicitationId it is waiting on.
//#region setNotificationHandler_elicitationComplete
const finished = new Map<string, () => void>();

client.setNotificationHandler('notifications/elicitation/complete', notification => {
console.log('URL flow finished:', notification.params.elicitationId);
finished.get(notification.params.elicitationId)?.();
finished.delete(notification.params.elicitationId);
});

client.setRequestHandler('elicitation/create', async (request, ctx) => {
if (request.params.mode === 'url') {
// Open request.params.url in the user's browser; answer once the server signals completion.
const { elicitationId } = request.params;
const done = await new Promise<'complete' | 'cancelled'>(resolve => {
finished.set(elicitationId, () => resolve('complete'));
ctx.mcpReq.signal.addEventListener('abort', () => {
finished.delete(elicitationId);
resolve('cancelled');
});
});
return { action: done === 'complete' ? 'accept' : 'cancel' };
}
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
});
//#endregion setNotificationHandler_elicitationComplete

// The harness plays the browser: once the server has parked the flow, the end
// user "finishes" at the URL and the callback endpoint fires the notification.
// The client only answers when the notification names the id its request
// carried, so the accept below proves the ids matched.
//#region callTool_connectCalendar_timeout
const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });
//#endregion callTool_connectCalendar_timeout
const waitFor = async (label: string, ready: () => boolean): Promise<void> => {
for (let attempt = 0; attempt < 400; attempt++) {
if (ready()) return;
await new Promise(resolve => setTimeout(resolve, 5));
}
throw new Error(`elicitation.md claim failed: ${label} never happened`);
};
await waitFor('the server parked the URL flow', () => pendingFlows.size > 0);
for (const parkedId of pendingFlows.keys()) {
await waitFor('the elicitation request reached the client handler', () => finished.has(parkedId));
await completeFlow(parkedId);
}
const connected = await connecting;
console.log(connected.content);
const connectedText = Array.isArray(connected.content) && connected.content[0]?.type === 'text' ? connected.content[0].text : undefined;
if (connected.isError || connectedText !== 'Connected google.' || pendingFlows.size !== 0) {
throw new Error(`elicitation.md claim failed: completion round returned ${JSON.stringify(connected.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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ console.log(quiet.content);
const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } });
console.log(validated.content);

// "Let the client set the level" — the harness swaps in a handler that also
// records each level it sees, so the run can assert what the page claims.
const delivered: string[] = [];
client.setNotificationHandler('notifications/message', notification => {
delivered.push(notification.params.level);
console.log(notification.params.level, notification.params.data);
});
//#region setLoggingLevel_warning
await client.setLoggingLevel('warning');

const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } });
console.log(filtered.content);
//#endregion setLoggingLevel_warning
const filteredText = Array.isArray(filtered.content) && filtered.content[0]?.type === 'text' ? filtered.content[0].text : undefined;
if (delivered.join(',') !== 'warning' || filteredText !== '1 of 2 records are valid') {
throw new Error(
`logging-progress-cancellation.md claim failed: after setLoggingLevel('warning') the client received [${delivered.join(', ')}] and ${JSON.stringify(filtered.content)}`
);
}

// "Stop work when the request is cancelled".
//#region callTool_abort
const controller = new AbortController();
Expand Down
30 changes: 23 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions test/conformance/expected-failures.2026-07-28.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@
# 2025 legs.
#
# Baseline established against the published @modelcontextprotocol/conformance
# release pinned in package.json. Newer conformance releases are adopted by
# deliberately bumping the pin and reconciling this file in the same change.
# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases
# are adopted by deliberately bumping the pin and reconciling this file in the
# same change.
#
# alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation`
# (client leg; everythingClient negotiates via server/discover like tools_call)
# passes at 2026-07-28 — the referee reports it as added-after-release, unscored
# on the frozen 2026-07-28 set — and `server-session-lifecycle` is not
# applicable at 2026-07-28 (removed in that revision, skipped by
# --spec-version), so both sections stay empty.
#
# NOTE: the SDK's modern-path rejection codes are aligned with what this
# referee asserts — both sides have adopted the spec#2907 / conformance#353
Expand Down
Loading
Loading