docs: client log levels, elicitation completion notification; drive json-schema-2020-12-preservation on the alpha.11 referee (#2686)
This commit is contained in:
parent
68523682fd
commit
a81ef34ac1
9 changed files with 334 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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:^",
|
||||
|
|
|
|||
|
|
@ -802,6 +802,62 @@ async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise<void> {
|
|||
|
||||
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<void> {
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue