From 2c3055c4862843c18e7b76efb66a6b5f678e8638 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 18 Aug 2026 13:38:15 +0000 Subject: [PATCH 1/2] docs: document client log levels and the elicitation completion notification Add "Let the client set the level" to the logging page (setLoggingLevel, the auto-installed logging/setLevel handler, filtered output) and "Signal that the URL flow finished" to the elicitation page (createElicitationCompletionNotifier + the client-side wait on notifications/elicitation/complete). Both companions run the new rounds and assert the quoted output. --- docs/servers/elicitation.md | 95 ++++++++++++++++- docs/servers/logging-progress-cancellation.md | 23 +++- .../guides/servers/elicitation.examples.ts | 100 ++++++++++++++++++ .../logging-progress-cancellation.examples.ts | 20 ++++ 4 files changed, 236 insertions(+), 2 deletions(-) diff --git a/docs/servers/elicitation.md b/docs/servers/elicitation.md index 737c4e55a0..0617562fd0 100644 --- a/docs/servers/elicitation.md +++ b/docs/servers/elicitation.md @@ -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 Promise>(); + +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 { + 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 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. @@ -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. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index 95be3c0627..a23aff35b5 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -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 @@ -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. diff --git a/examples/guides/servers/elicitation.examples.ts b/examples/guides/servers/elicitation.examples.ts index b41e8ba899..4267ad98d5 100644 --- a/examples/guides/servers/elicitation.examples.ts +++ b/examples/guides/servers/elicitation.examples.ts @@ -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 Promise>(); + +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 { + 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 @@ -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 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 => { + 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. diff --git a/examples/guides/servers/logging-progress-cancellation.examples.ts b/examples/guides/servers/logging-progress-cancellation.examples.ts index d0750e51a1..347d3f1e89 100644 --- a/examples/guides/servers/logging-progress-cancellation.examples.ts +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -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(); From 49b9cad7ffe5b0fdb0fe6586b4522eadf94d9245 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 18 Aug 2026 13:38:15 +0000 Subject: [PATCH 2/2] test(conformance): drive json-schema-2020-12-preservation and pin the alpha.11 referee Register the client-side json-schema-2020-12-preservation scenario in the everything client (tools/list, then echo the observed inputSchema through json_schema_echo; modern lifecycle under a 2026-07-28 run), bump @modelcontextprotocol/conformance 0.2.0-alpha.10 -> 0.2.0-alpha.11 and record the reconciliation in both expected-failures baselines. All four legs pass with no new entries. --- pnpm-lock.yaml | 30 +++++++--- .../expected-failures.2026-07-28.yaml | 12 +++- test/conformance/expected-failures.yaml | 9 ++- test/conformance/package.json | 2 +- test/conformance/src/everythingClient.ts | 56 +++++++++++++++++++ 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85218d9ea0..c663ad7086 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1946,8 +1946,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.10 - version: 0.2.0-alpha.10(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.11 + version: 0.2.0-alpha.11(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3231,8 +3231,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.10': - resolution: {integrity: sha512-0V/HZDdWHcg6j0zVBzBsXcPZ571IVi6umKgTpnBhtTx/jm/LONmGF6cIWL2k4Xjyps0OiHV6B37nj2s0pUg0nQ==} + '@modelcontextprotocol/conformance@0.2.0-alpha.11': + resolution: {integrity: sha512-imPK9tx5gQsL6ZKQq4MrsyDYfSaIwpRmX6+ogjbeAXs9LGvxkBxWcY7KcS7TvwaBk/ZiVWl6b/naF4q83UwDRA==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -4231,6 +4231,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + algoliasearch@5.55.0: resolution: {integrity: sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==} engines: {node: '>= 14.0.0'} @@ -7854,10 +7857,12 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.10(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.11(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) commander: 14.0.3 eventsource-parser: 3.0.8 express: 5.2.1 @@ -7872,8 +7877,8 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.11(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 @@ -8759,6 +8764,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8773,6 +8782,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch@5.55.0: dependencies: '@algolia/abtesting': 1.21.0 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index e6fbde1ced..0f7090a82c 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -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 diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 6711cdc30f..1e256bec6d 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,10 +2,17 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.10). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.11). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # +# alpha.10 -> alpha.11 reconciliation: the referee added two scenarios, both +# passing, so neither is baselined — `json-schema-2020-12-preservation` +# (client; SEP-1613/SEP-2106 keyword round-trip, driven by everythingClient.ts) +# and `server-session-lifecycle` (server; Streamable HTTP session teardown). +# The referee now also sends HTTP DELETE after every session-bound scenario +# (conformance#316); the everything server already answers it. +# # NOTE: the SDK's modern-path rejection codes are aligned with what this # referee asserts — both sides have adopted the spec#2907 / conformance#353 # renumber (-32020 HeaderMismatch / -32021 MissingRequiredClientCapability / diff --git a/test/conformance/package.json b/test/conformance/package.json index 8558f92b50..f51e84d9ea 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -38,7 +38,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.10", + "@modelcontextprotocol/conformance": "0.2.0-alpha.11", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 3ba48ccbf7..cf33555a4b 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -802,6 +802,62 @@ async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise { registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient); +// ============================================================================ +// JSON Schema 2020-12 keyword preservation scenario (SEP-1613, SEP-2106) +// ============================================================================ + +/** The tool whose `inputSchema` carries the full JSON Schema 2020-12 fixture. */ +const JSON_SCHEMA_2020_12_TOOL = 'json_schema_2020_12_tool'; +/** The permissive echo tool that hands the observed schema back to the referee. */ +const JSON_SCHEMA_ECHO_TOOL = 'json_schema_echo'; + +/** + * The scenario advertises a focal tool whose inputSchema uses `$schema`, + * `$defs` (with `$anchor`), `additionalProperties`, composition + * (`allOf`/`anyOf`) and conditional (`if`/`then`/`else`) keywords. The client + * lists tools and passes that inputSchema back verbatim — exactly as + * `listTools()` exposes it — through `tools/call json_schema_echo`, so the + * referee can diff what survived the SDK's parsing against its fixture. + * + * The scenario spans both eras: under a 2026-07-28 run the client negotiates + * the modern lifecycle via server/discover (as tools_call does) and drives the + * same list → echo flow. + */ +async function runJsonSchema2020_12PreservationClient(serverUrl: string): Promise { + const client = new Client( + { name: 'json-schema-2020-12-preservation-client', version: '1.0.0' }, + isModernConformanceRun() ? { capabilities: {}, versionNegotiation: { mode: 'auto' } } : { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const focal = tools.tools.find(t => t.name === JSON_SCHEMA_2020_12_TOOL); + if (!focal) { + throw new Error(`Tool '${JSON_SCHEMA_2020_12_TOOL}' not advertised by the server`); + } + logger.debug('Observed inputSchema:', JSON.stringify(focal.inputSchema, null, 2)); + + const result = await client.callTool({ + name: JSON_SCHEMA_ECHO_TOOL, + arguments: { schema: focal.inputSchema } + }); + logger.debug('Echo result:', JSON.stringify(result, null, 2)); + + await client.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('json-schema-2020-12-preservation', runJsonSchema2020_12PreservationClient); + // ============================================================================ // Main entry point // ============================================================================