typescript-sdk/examples/mrtr/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

89 lines
4.1 KiB
TypeScript

/**
* Drives the multi-round-trip server (`./server.ts`) two ways on a 2026-07-28
* connection:
*
* 1. **auto-fulfilment** (the default) — the same `elicitation/create`
* handler the client would register for the 2025-era flow fulfils the
* embedded form and URL elicitations, and the SDK retries the original
* `tools/call` for you. `client.callTool()` returns a plain
* `CallToolResult`;
* 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call
* `allowInputRequired: true`: the input-required value is handed back, and
* the example collects responses, echoes `requestState`, and retries
* itself.
*
* Asserts both flows reach `deployed to …` and exits 0.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import type { CallToolResult, ClientOptions, InputRequiredResult } from '@modelcontextprotocol/client';
import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const { transport, url, era } = parseExampleArgs();
// Both halves connect identically and differ only in ClientOptions; the
// local helper keeps the SDK transport setup visible in THIS file (the
// canonical shape) while avoiding duplicating it for each half.
const connect = async (options: ClientOptions): Promise<Client> => {
const client = new Client(
{ name: 'mrtr-example-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, ...options }
);
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))));
return client;
};
// --- auto-fulfilment (the default) ---
const auto = await connect({ capabilities: { elicitation: { form: {}, url: {} } } });
// The SAME handler a 2025-flow client registers: the auto-fulfilment
// engine dispatches embedded form and URL elicitations through it.
auto.setRequestHandler('elicitation/create', async request => {
const params = request.params as { mode?: string; message: string; url?: string };
if (params.mode === 'url') return { action: 'accept' };
return { action: 'accept', content: { confirm: true } };
});
// callTool returns a plain CallToolResult — the interactive rounds happen
// inside the call.
const autoResult = await auto.callTool({ name: 'deploy', arguments: { env: 'prod' } });
const autoText = autoResult.content?.[0]?.type === 'text' ? autoResult.content[0].text : '';
check.equal(autoText, 'deployed to prod');
await auto.close();
// --- manual mode (autoFulfill: false + allowInputRequired) ---
const manual = await connect({
capabilities: { elicitation: { form: {}, url: {} } },
inputRequired: { autoFulfill: false }
});
let inputResponses: Record<string, unknown> | undefined;
let requestState: string | undefined;
let final: CallToolResult | undefined;
for (let round = 0; round < 10; round++) {
const value = (await manual.request(
{
method: 'tools/call',
params: {
name: 'deploy',
arguments: { env: 'staging' },
...(inputResponses && { inputResponses }),
...(requestState && { requestState })
}
},
{ allowInputRequired: true }
)) as CallToolResult | InputRequiredResult;
if (!isInputRequiredResult(value)) {
final = value;
break;
}
// Collect responses and echo requestState byte-exact.
inputResponses = {};
for (const [key, entry] of Object.entries(value.inputRequests ?? {})) {
inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {};
}
requestState = value.requestState;
}
check.ok(final, 'manual flow should reach a CallToolResult within 10 rounds');
const manualText = final?.content?.[0]?.type === 'text' ? final.content[0].text : '';
check.equal(manualText, 'deployed to staging');
await manual.close();