/** * 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 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 => { const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record } } & Partial< Pick >; 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(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 \(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();