typescript-sdk/examples/guides/testing.examples.ts

130 lines
4.7 KiB
TypeScript

/**
* Companion example for `docs/testing.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The file also runs: it is the no-socket
* client harness the page teaches — `createMcpHandler` served through
* `handler.fetch`, then `InMemoryTransport.createLinkedPair()` — and every
* output the page quotes verbatim is printed by this program.
*
* pnpm --filter @modelcontextprotocol/examples typecheck
* npx tsx guides/testing.examples.ts # from examples/
*
* @module
*/
/* eslint-disable no-console */
// ## Serve the handler in-process
//#region inProcessHandler
import assert from 'node:assert/strict';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
function createServer() {
const server = new McpServer({ name: 'pricing', version: '1.0.0' });
server.registerTool(
'apply-discount',
{
description: 'Apply a percentage discount to a price',
inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }),
outputSchema: z.object({ total: z.number() })
},
async ({ price, percent }) => {
if (price < 0) {
return { content: [{ type: 'text', text: 'price must be >= 0' }], isError: true };
}
const total = price * (1 - percent / 100);
return { content: [{ type: 'text', text: `$${total}` }], structuredContent: { total } };
}
);
return server;
}
const handler = createMcpHandler(createServer);
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init))
});
//#endregion inProcessHandler
// ## Connect a client and call a tool
//#region connectAndCall
const client = new Client({ name: 'test-harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
const result = await client.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } });
console.log(result.structuredContent);
//#endregion connectAndCall
// Proof for the page's claim that this wiring exercises the real 2026-07-28
// HTTP path. Throws (non-zero exit) if the claim is false.
assert.equal(client.getNegotiatedProtocolVersion(), '2026-07-28');
// ## Assert on the result
//#region assertResult
assert.deepStrictEqual(result.structuredContent, { total: 60 });
const failed = await client.callTool({ name: 'apply-discount', arguments: { price: -5, percent: 25 } });
assert.equal(failed.isError, true);
console.log(failed.content);
//#endregion assertResult
// ## Tear down between tests
//#region tearDown
await client.close();
await handler.close();
//#endregion tearDown
// ## Pair two instances in memory
//#region linkedPair
import { InMemoryTransport } from '@modelcontextprotocol/client';
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const memServer = createServer();
const memClient = new Client({ name: 'test-harness', version: '1.0.0' });
await memServer.connect(serverTransport);
await memClient.connect(clientTransport);
//#endregion linkedPair
// ---------------------------------------------------------------------------
// Harness (not shown on the page). Proves the page's prose claims, then
// closes the linked pair so the program terminates on its own.
// ---------------------------------------------------------------------------
// "The same `callTool` from above returns the same result over this pair."
const paired = await memClient.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } });
assert.deepStrictEqual(paired.structuredContent, { total: 60 });
// Proof for the page's era caveat: the linked pair runs the 2025 handshake,
// not the 2026-07-28 revision the `handler.fetch` harness above negotiated.
assert.equal(memClient.getNegotiatedProtocolVersion(), '2025-11-25');
await memClient.close();
await memServer.close();
// ## Cover stdio by spawning the process
//
// `StdioClientTransport.start()` spawns a real child process, so this region
// lives in a wrapper that typechecks but is never called.
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
async function coverStdio() {
//#region stdioSpawn
const stdioClient = new Client({ name: 'test-harness', version: '1.0.0' });
await stdioClient.connect(new StdioClientTransport({ command: 'node', args: ['dist/server.js'] }));
//#endregion stdioSpawn
return stdioClient;
}
void coverStdio;
console.log('testing.examples.ts: all assertions passed');