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.
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.
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.
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.
Tear down between tests
Close both ends in your runner's afterEach — the client first, then the handler.
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.
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.
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.
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 covers the server side.
Recap
handler.fetchpassed as the transport'sfetchoption serves every request in-process; the transport never dials the URL.- One
Clientplus onecreateMcpHandleris a complete no-socket integration test of the server you deploy. - Assert on
structuredContent; a handler failure resolves as a result withisError: 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.