docs: rebuild the v2 documentation around a task-based page tree (#2397)

This commit is contained in:
Felix Weinberger 2026-06-30 21:18:42 +01:00 • committed by GitHub
commit b153505428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

134
docs/testing.md Normal file
View file

@ -0,0 +1,134 @@
---
shape: how-to
---
# Test a server
Drive your server through a real `Client`, in-process — no port, no socket, no mock transport.
## Serve the handler in-process
Start from the `createServer` factory you ship — here, one tool — and pass `handler.fetch` as the client transport's `fetch` option.
```ts source="../examples/guides/testing.examples.ts#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))
});
```
The transport never dials `http://test.local/mcp` — `handler.fetch` serves every request in-process, through the same `createMcpHandler` you deploy.
## Connect a client and call a tool
Create a `Client`, connect it over the transport, and call the tool. `versionNegotiation: { mode: 'auto' }` negotiates the newest protocol revision the handler serves.
```ts source="../examples/guides/testing.examples.ts#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);
```
The handler answered in-process:
```
{ total: 60 }
```
## Assert on the result
Assert on `structuredContent` for the happy path; a handler failure resolves as an ordinary result with `isError: true`, not a thrown error.
```ts source="../examples/guides/testing.examples.ts#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);
```
There is nothing to `catch` — `failed.content` carries the message the model would read:
```
[ { type: 'text', text: 'price must be >= 0' } ]
```
::: tip
This page uses `node:assert/strict`; swap in your runner's `expect` — nothing else changes. Arguments the input schema rejects produce the same `isError: true` result, so they assert the same way — see [Tools](./servers/tools.md).
:::
## Tear down between tests
Close both ends in your runner's `afterEach` — the client first, then the handler.
```ts source="../examples/guides/testing.examples.ts#tearDown"
await client.close();
await handler.close();
```
`handler.close()` aborts any exchange still in flight, so a hung tool call cannot leak into the next test.
## Pair two instances in memory
`InMemoryTransport.createLinkedPair()` returns two transports that are each other's wire — connect one instance to each end.
```ts source="../examples/guides/testing.examples.ts#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);
```
`memClient.callTool` returns the same results over this pair. `createLinkedPair` connects 2025-era instances only; `handler.fetch` is the in-process entry for 2026-07-28 coverage — see [Protocol versions](./protocol-versions.md).
## Cover stdio by spawning the process
Stdio has no in-process shortcut: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and connects to the child over its stdin and stdout.
```ts source="../examples/guides/testing.examples.ts#stdioSpawn"
const stdioClient = new Client({ name: 'test-harness', version: '1.0.0' });
await stdioClient.connect(new StdioClientTransport({ command: 'node', args: ['dist/server.js'] }));
```
From here the client behaves exactly as above, and `stdioClient.close()` shuts the child process down. [Serve over stdio](./serving/stdio.md) covers the server side.
## Recap
- `handler.fetch` passed as the transport's `fetch` option serves every request in-process; the transport never dials the URL.
- One `Client` plus one `createMcpHandler` is a complete no-socket integration test of the server you deploy.
- Assert on `structuredContent`; a handler failure resolves as a result with `isError: true`.
- Close the client, then the handler, between tests.
- `InMemoryTransport.createLinkedPair()` pairs 2025-era instances in memory.
- stdio coverage means spawning the real process with `StdioClientTransport`.