This is the documentation for the v2 beta — looking for the v1 documentation?
Skip to content

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
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/mcphandler.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
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
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 catchfailed.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.

ts
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
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.

ts
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.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.