FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

docs: client log levels, elicitation completion notification; drive j… · modelcontextprotocol/typescript-sdk@a81ef34 · GitHub

Commit a81ef34

Browse files
docs: client log levels, elicitation completion notification; drive json-schema-2020-12-preservation on the alpha.11 referee (#2686)
1 parent 6852368 commit a81ef34

9 files changed

Lines changed: 334 additions & 13 deletions

‎docs/servers/elicitation.md‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,99 @@ The client opens the URL and answers once the end user finishes there; whatever
194194
[ { type: 'text', text: 'Linked github.' } ]
195195
```
196196

197+
## Signal that the URL flow finished
198+
199+
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.
200+
201+
```ts source="../../examples/guides/servers/elicitation.examples.ts#createElicitationCompletionNotifier_connectCalendar"
202+
const pendingFlows = new Map<string, () => Promise<void>>();
203+
204+
server.registerTool(
205+
'connect-calendar',
206+
{
207+
description: 'Connect a calendar through a hosted consent flow',
208+
inputSchema: z.object({ provider: z.string() })
209+
},
210+
async ({ provider }, ctx) => {
211+
const elicitationId = crypto.randomUUID();
212+
pendingFlows.set(
213+
elicitationId,
214+
server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
215+
);
216+
try {
217+
const result = await ctx.mcpReq.elicitInput(
218+
{
219+
mode: 'url',
220+
message: `Grant ${provider} calendar access`,
221+
url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
222+
elicitationId
223+
},
224+
// a person is on the other end (the default timeout is 60 s); the signal
225+
// cancels the parked elicitation if the tool call itself is cancelled
226+
{ timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
227+
);
228+
if (result.action !== 'accept') {
229+
return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
230+
}
231+
return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
232+
} finally {
233+
pendingFlows.delete(elicitationId);
234+
}
235+
}
236+
);
237+
238+
// The hosted flow redirects back to your server with the id in `state`; that
239+
// endpoint sends the notification.
240+
async function completeFlow(elicitationId: string): Promise<void> {
241+
await pendingFlows.get(elicitationId)?.();
242+
}
243+
```
244+
245+
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.
246+
247+
```ts source="../../examples/guides/servers/elicitation.examples.ts#setNotificationHandler_elicitationComplete"
248+
const finished = new Map<string, () => void>();
249+
250+
client.setNotificationHandler('notifications/elicitation/complete', notification => {
251+
console.log('URL flow finished:', notification.params.elicitationId);
252+
finished.get(notification.params.elicitationId)?.();
253+
finished.delete(notification.params.elicitationId);
254+
});
255+
256+
client.setRequestHandler('elicitation/create', async (request, ctx) => {
257+
if (request.params.mode === 'url') {
258+
// Open request.params.url in the user's browser; answer once the server signals completion.
259+
const { elicitationId } = request.params;
260+
const done = await new Promise<'complete' | 'cancelled'>(resolve => {
261+
finished.set(elicitationId, () => resolve('complete'));
262+
ctx.mcpReq.signal.addEventListener('abort', () => {
263+
finished.delete(elicitationId);
264+
resolve('cancelled');
265+
});
266+
});
267+
return { action: done === 'complete' ? 'accept' : 'cancel' };
268+
}
269+
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
270+
});
271+
```
272+
273+
The host's own `tools/call` has the same 60-second default, so the caller raises it as well:
274+
275+
```ts source="../../examples/guides/servers/elicitation.examples.ts#callTool_connectCalendar_timeout"
276+
const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });
277+
```
278+
279+
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):
280+
281+
```
282+
URL flow finished: c9a7bcfc-acc9-494c-8ce5-44c921232ea6
283+
[ { type: 'text', text: 'Connected google.' } ]
284+
```
285+
286+
::: info
287+
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).
288+
:::
289+
197290
## Keep secrets out of forms
198291

199292
Form answers travel back through the client and land in the model's context like any other tool result.
@@ -221,5 +314,5 @@ Elicitation only works against a client that declared the `elicitation` capabili
221314
- Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it.
222315
- `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept.
223316
- `default` on a `requestedSchema` field prefills the form; a client that declares `applyDefaults` fills the field in when the end user leaves it out.
224-
- URL mode hands the end user a browser flow — use it for anything sensitive.
317+
- 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.
225318
- Calls against a client that never declared the `elicitation` capability fail before reaching the wire.

‎docs/servers/logging-progress-cancellation.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,27 @@ warning { invalid: [ 'b.txt' ] }
119119
[ { type: 'text', text: '1 of 2 records are valid' } ]
120120
```
121121

122-
How the client's log level reaches `ctx.mcpReq.log` differs by protocol era — see [Protocol versions](../protocol-versions.md).
122+
## Let the client set the level
123+
124+
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.
125+
126+
```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#setLoggingLevel_warning"
127+
await client.setLoggingLevel('warning');
128+
129+
const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } });
130+
console.log(filtered.content);
131+
```
132+
133+
The same tool now delivers only the `warning`; the `info` message never leaves the server:
134+
135+
```
136+
warning { invalid: [ 'd.txt' ] }
137+
[ { type: 'text', text: '1 of 2 records are valid' } ]
138+
```
139+
140+
::: info
141+
On a 2026-07-28 request the client's level arrives per request, not per session — see [Protocol versions](../protocol-versions.md).
142+
:::
123143

124144
## Stop work when the request is cancelled
125145

@@ -202,4 +222,5 @@ Resolve an identifier against a fixed list, as `fetch-source` does. A tool that
202222
- Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`.
203223
- `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one.
204224
- `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577).
225+
- Declaring `logging` also installs `logging/setLevel`; after `client.setLoggingLevel(level)` the SDK drops messages below that level for the session.
205226
- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.

‎examples/guides/servers/elicitation.examples.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,52 @@ server.registerTool(
132132
);
133133
//#endregion registerTool_elicitUrl
134134

135+
// "Signal that the URL flow finished" — the server tells the client when the
136+
// out-of-band flow completes, so the client can answer the pending request.
137+
//#region createElicitationCompletionNotifier_connectCalendar
138+
const pendingFlows = new Map<string, () => Promise<void>>();
139+
140+
server.registerTool(
141+
'connect-calendar',
142+
{
143+
description: 'Connect a calendar through a hosted consent flow',
144+
inputSchema: z.object({ provider: z.string() })
145+
},
146+
async ({ provider }, ctx) => {
147+
const elicitationId = crypto.randomUUID();
148+
pendingFlows.set(
149+
elicitationId,
150+
server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id })
151+
);
152+
try {
153+
const result = await ctx.mcpReq.elicitInput(
154+
{
155+
mode: 'url',
156+
message: `Grant ${provider} calendar access`,
157+
url: `https://calendar.example.com/consent/${encodeURIComponent(provider)}?state=${elicitationId}`,
158+
elicitationId
159+
},
160+
// a person is on the other end (the default timeout is 60 s); the signal
161+
// cancels the parked elicitation if the tool call itself is cancelled
162+
{ timeout: 10 * 60_000, signal: ctx.mcpReq.signal }
163+
);
164+
if (result.action !== 'accept') {
165+
return { content: [{ type: 'text', text: `Consent ${result.action}.` }] };
166+
}
167+
return { content: [{ type: 'text', text: `Connected ${provider}.` }] };
168+
} finally {
169+
pendingFlows.delete(elicitationId);
170+
}
171+
}
172+
);
173+
174+
// The hosted flow redirects back to your server with the id in `state`; that
175+
// endpoint sends the notification.
176+
async function completeFlow(elicitationId: string): Promise<void> {
177+
await pendingFlows.get(elicitationId)?.();
178+
}
179+
//#endregion createElicitationCompletionNotifier_connectCalendar
180+
135181
// ---------------------------------------------------------------------------
136182
// Harness (not shown on the page beyond the two regions below). An in-memory
137183
// client plays the end user; a real host renders UI instead. Imported
@@ -175,6 +221,60 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline'
175221
const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } });
176222
console.log(declined.content);
177223

224+
// "Signal that the URL flow finished" — the client holds its answer until the
225+
// completion notification names the elicitationId it is waiting on.
226+
//#region setNotificationHandler_elicitationComplete
227+
const finished = new Map<string, () => void>();
228+
229+
client.setNotificationHandler('notifications/elicitation/complete', notification => {
230+
console.log('URL flow finished:', notification.params.elicitationId);
231+
finished.get(notification.params.elicitationId)?.();
232+
finished.delete(notification.params.elicitationId);
233+
});
234+
235+
client.setRequestHandler('elicitation/create', async (request, ctx) => {
236+
if (request.params.mode === 'url') {
237+
// Open request.params.url in the user's browser; answer once the server signals completion.
238+
const { elicitationId } = request.params;
239+
const done = await new Promise<'complete' | 'cancelled'>(resolve => {
240+
finished.set(elicitationId, () => resolve('complete'));
241+
ctx.mcpReq.signal.addEventListener('abort', () => {
242+
finished.delete(elicitationId);
243+
resolve('cancelled');
244+
});
245+
});
246+
return { action: done === 'complete' ? 'accept' : 'cancel' };
247+
}
248+
return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } };
249+
});
250+
//#endregion setNotificationHandler_elicitationComplete
251+
252+
// The harness plays the browser: once the server has parked the flow, the end
253+
// user "finishes" at the URL and the callback endpoint fires the notification.
254+
// The client only answers when the notification names the id its request
255+
// carried, so the accept below proves the ids matched.
256+
//#region callTool_connectCalendar_timeout
257+
const connecting = client.callTool({ name: 'connect-calendar', arguments: { provider: 'google' } }, { timeout: 10 * 60_000 });
258+
//#endregion callTool_connectCalendar_timeout
259+
const waitFor = async (label: string, ready: () => boolean): Promise<void> => {
260+
for (let attempt = 0; attempt < 400; attempt++) {
261+
if (ready()) return;
262+
await new Promise(resolve => setTimeout(resolve, 5));
263+
}
264+
throw new Error(`elicitation.md claim failed: ${label} never happened`);
265+
};
266+
await waitFor('the server parked the URL flow', () => pendingFlows.size > 0);
267+
for (const parkedId of pendingFlows.keys()) {
268+
await waitFor('the elicitation request reached the client handler', () => finished.has(parkedId));
269+
await completeFlow(parkedId);
270+
}
271+
const connected = await connecting;
272+
console.log(connected.content);
273+
const connectedText = Array.isArray(connected.content) && connected.content[0]?.type === 'text' ? connected.content[0].text : undefined;
274+
if (connected.isError || connectedText !== 'Connected google.' || pendingFlows.size !== 0) {
275+
throw new Error(`elicitation.md claim failed: completion round returned ${JSON.stringify(connected.content)}`);
276+
}
277+
178278
// "Prefill a field with a default" — a client that declares `applyDefaults`
179279
// accepts with `format` left out; the SDK fills it from the schema before the
180280
// accept reaches the handler.

‎examples/guides/servers/logging-progress-cancellation.examples.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ console.log(quiet.content);
150150
const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } });
151151
console.log(validated.content);
152152

153+
// "Let the client set the level" — the harness swaps in a handler that also
154+
// records each level it sees, so the run can assert what the page claims.
155+
const delivered: string[] = [];
156+
client.setNotificationHandler('notifications/message', notification => {
157+
delivered.push(notification.params.level);
158+
console.log(notification.params.level, notification.params.data);
159+
});
160+
//#region setLoggingLevel_warning
161+
await client.setLoggingLevel('warning');
162+
163+
const filtered = await client.callTool({ name: 'validate-records', arguments: { records: ['c.csv', 'd.txt'] } });
164+
console.log(filtered.content);
165+
//#endregion setLoggingLevel_warning
166+
const filteredText = Array.isArray(filtered.content) && filtered.content[0]?.type === 'text' ? filtered.content[0].text : undefined;
167+
if (delivered.join(',') !== 'warning' || filteredText !== '1 of 2 records are valid') {
168+
throw new Error(
169+
`logging-progress-cancellation.md claim failed: after setLoggingLevel('warning') the client received [${delivered.join(', ')}] and ${JSON.stringify(filtered.content)}`
170+
);
171+
}
172+
153173
// "Stop work when the request is cancelled".
154174
//#region callTool_abort
155175
const controller = new AbortController();

‎pnpm-lock.yaml‎

Lines changed: 23 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/conformance/expected-failures.2026-07-28.yaml‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@
1212
# 2025 legs.
1313
#
1414
# Baseline established against the published @modelcontextprotocol/conformance
15-
# release pinned in package.json. Newer conformance releases are adopted by
16-
# deliberately bumping the pin and reconciling this file in the same change.
15+
# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases
16+
# are adopted by deliberately bumping the pin and reconciling this file in the
17+
# same change.
18+
#
19+
# alpha.10 -> alpha.11 reconciliation: `json-schema-2020-12-preservation`
20+
# (client leg; everythingClient negotiates via server/discover like tools_call)
21+
# passes at 2026-07-28 — the referee reports it as added-after-release, unscored
22+
# on the frozen 2026-07-28 set — and `server-session-lifecycle` is not
23+
# applicable at 2026-07-28 (removed in that revision, skipped by
24+
# --spec-version), so both sections stay empty.
1725
#
1826
# NOTE: the SDK's modern-path rejection codes are aligned with what this
1927
# referee asserts — both sides have adopted the spec#2907 / conformance#353

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL