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
2 changed files with 334 additions and 13 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue