| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -194,6 +194,99 @@ The client opens the URL and answers once the end user finishes there; whatever | |||
| 194 | 194 | [ { type: 'text', text: 'Linked github.' } ] | |
| 195 | 195 | ``` | |
| 196 | 196 | ||
| 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 | + | ||
| 197 | 290 | ## Keep secrets out of forms | |
| 198 | 291 | ||
| 199 | 292 | 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 | |||
| 221 | 314 | - Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it. | |
| 222 | 315 | - `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept. | |
| 223 | 316 | - `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. | ||
| 225 | 318 | - Calls against a client that never declared the `elicitation` capability fail before reaching the wire. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -119,7 +119,27 @@ warning { invalid: [ 'b.txt' ] } | |||
| 119 | 119 | [ { type: 'text', text: '1 of 2 records are valid' } ] | |
| 120 | 120 | ``` | |
| 121 | 121 | ||
| 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 | + ::: | ||
| 123 | 143 | ||
| 124 | 144 | ## Stop work when the request is cancelled | |
| 125 | 145 | ||
@@ -202,4 +222,5 @@ Resolve an identifier against a fixed list, as `fetch-source` does. A tool that | |||
| 202 | 222 | - Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`. | |
| 203 | 223 | - `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one. | |
| 204 | 224 | - `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. | ||
| 205 | 226 | - `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -132,6 +132,52 @@ server.registerTool( | |||
| 132 | 132 | ); | |
| 133 | 133 | //#endregion registerTool_elicitUrl | |
| 134 | 134 | ||
| 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 | + | ||
| 135 | 181 | // --------------------------------------------------------------------------- | |
| 136 | 182 | // Harness (not shown on the page beyond the two regions below). An in-memory | |
| 137 | 183 | // client plays the end user; a real host renders UI instead. Imported | |
@@ -175,6 +221,60 @@ client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' | |||
| 175 | 221 | const declined = await client.callTool({ name: 'delete-dataset', arguments: { name: 'staging-snapshots' } }); | |
| 176 | 222 | console.log(declined.content); | |
| 177 | 223 | ||
| 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 | + | ||
| 178 | 278 | // "Prefill a field with a default" — a client that declares `applyDefaults` | |
| 179 | 279 | // accepts with `format` left out; the SDK fills it from the schema before the | |
| 180 | 280 | // accept reaches the handler. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -150,6 +150,26 @@ console.log(quiet.content); | |||
| 150 | 150 | const validated = await client.callTool({ name: 'validate-records', arguments: { records: ['a.csv', 'b.txt'] } }); | |
| 151 | 151 | console.log(validated.content); | |
| 152 | 152 | ||
| 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 | + | ||
| 153 | 173 | // "Stop work when the request is cancelled". | |
| 154 | 174 | //#region callTool_abort | |
| 155 | 175 | const controller = new AbortController(); | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,8 +12,16 @@ | |||
| 12 | 12 | # 2025 legs. | |
| 13 | 13 | # | |
| 14 | 14 | # 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. | ||
| 17 | 25 | # | |
| 18 | 26 | # NOTE: the SDK's modern-path rejection codes are aligned with what this | |
| 19 | 27 | # referee asserts — both sides have adopted the spec#2907 / conformance#353 | |
| Back | FazBrowse Home | New Git URL |
0 commit comments