typescript-sdk/examples/elicitation/client.ts
Felix Weinberger 1823aae891
Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-26 14:01:10 +01:00

98 lines
5.1 KiB
TypeScript

/**
* Auto-answers form and URL elicitations on either protocol era and asserts
* the tool's text reflects the elicitation outcome.
*
* On the 2025-era leg (`--legacy`) the server pushes `elicitation/create`
* requests and a `notifications/elicitation/complete` notification, and the
* `confirm_payment` tool throws a typed `UrlElicitationRequiredError` the
* client catches. On the 2026-07-28 leg the same `elicitation/create`
* handler is dispatched by the auto-fulfilment engine for the embedded
* `inputRequired` requests; there is no throw-style or complete-notification
* surface on that era, so those assertions are gated to the legacy leg.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport, UrlElicitationRequiredError } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const { transport, url, era } = parseExampleArgs();
const client = new Client(
{ name: 'elicitation-example-client', version: '1.0.0' },
{
versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' },
capabilities: { elicitation: { form: {}, url: {} } }
}
);
await (transport === 'stdio'
? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }))
: client.connect(new StreamableHTTPClientTransport(new URL(url))));
// URL-mode requests on the 2025 era carry an `elicitationId`; the client
// waits for `notifications/elicitation/complete` with that id (the
// out-of-band "the user finished the URL flow" signal) before answering.
const completed = new Map<string, () => void>();
client.setNotificationHandler('notifications/elicitation/complete', notification => {
const id = (notification.params as { elicitationId: string }).elicitationId;
completed.get(id)?.();
});
let formAction: 'accept' | 'decline' = 'accept';
client.setRequestHandler('elicitation/create', async (request): Promise<ElicitResult> => {
const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record<string, unknown> } } & Partial<
Pick<ElicitRequestURLParams, 'url' | 'elicitationId'>
>;
if (params.mode === 'url') {
// A real client would open `params.url` in a browser here. On the
// 2025 era it then waits for the matching complete notification
// before resolving; on the 2026 era there is no elicitationId and
// the client answers as soon as the user finishes.
check.ok(params.url?.startsWith('https://example.com/'));
if (params.elicitationId) {
await new Promise<void>(resolve => completed.set(params.elicitationId as string, resolve));
}
return { action: 'accept' };
}
if (params.requestedSchema?.properties?.['destination']) {
return { action: 'accept', content: { destination: 'Tokyo' } };
}
if (params.requestedSchema?.properties?.['departure']) {
return { action: 'accept', content: { departure: '2026-09-01', nights: 7 } };
}
check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema');
if (formAction === 'decline') return { action: 'decline' };
return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } };
});
// ---- Form mode (accept then decline) -------------------------------------
const accepted = await client.callTool({ name: 'register_user' });
check.match(accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', /registered alice <alice@example.com> \(plan: pro\)/);
formAction = 'decline';
const declined = await client.callTool({ name: 'register_user' });
check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/);
// ---- Multi-step form (two chained elicitations inside one tool call) -----
const trip = await client.callTool({ name: 'plan_trip' });
check.match(trip.content?.[0]?.type === 'text' ? trip.content[0].text : '', /trip planned: Tokyo on 2026-09-01 for 7 nights/);
// ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------
const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } });
check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/);
// ---- URL mode (throw-style — 2025-era only) ------------------------------
if (era === 'legacy') {
let caught: UrlElicitationRequiredError | undefined;
try {
await client.callTool({ name: 'confirm_payment', arguments: { cartId: 'cart-42' } });
} catch (error) {
check.ok(error instanceof UrlElicitationRequiredError, 'expected UrlElicitationRequiredError');
caught = error as UrlElicitationRequiredError;
}
check.ok(caught, 'confirm_payment should throw UrlElicitationRequiredError on the 2025 era');
check.equal(caught?.elicitations.length, 1);
check.match(caught?.elicitations[0]?.url ?? '', /confirm-payment\?cart=cart-42/);
}
await client.close();