From e74a4bb51ed65d2562f4c3cae4960dd45c3c2698 Mon Sep 17 00:00:00 2001 From: Niels Kaspers Date: Thu, 5 Mar 2026 00:13:02 +0200 Subject: [PATCH 1/2] docs(server): add unit testing guide using InMemoryTransport Adds a ## Testing section to the server guide explaining how to unit-test MCP servers in-process using InMemoryTransport, without spawning subprocesses or opening ports. Includes a working vitest/jest example for testing a tool, and notes on what the approach does and does not cover. Closes #1126 --- docs/server.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/server.md b/docs/server.md index 3c246ac12..52a5e7966 100644 --- a/docs/server.md +++ b/docs/server.md @@ -476,6 +476,80 @@ const app = createMcpExpressApp({ If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. +## Testing + +Unit-testing MCP servers does not require a running HTTP server or a subprocess. Use `InMemoryTransport` from `@modelcontextprotocol/core` to wire a client and server together in-process. + +### Basic setup + +```ts +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; + +function createTestPair() { + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + return { server, client, clientTransport, serverTransport }; +} +``` + +`InMemoryTransport.createLinkedPair()` returns two linked transports: messages written to one are read by the other, with no networking involved. + +### Example: testing a tool + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // or jest +import * as z from 'zod'; +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; + +describe('my-tool', () => { + let server: McpServer; + let client: Client; + + beforeEach(async () => { + server = new McpServer({ name: 'test-server', version: '1.0.0' }); + client = new Client({ name: 'test-client', version: '1.0.0' }); + + server.registerTool( + 'add', + { description: 'Add two numbers', inputSchema: { a: z.number(), b: z.number() } }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + + afterEach(async () => { + await client.close(); + }); + + it('returns the sum', async () => { + const result = await client.callTool({ name: 'add', arguments: { a: 2, b: 3 } }); + expect(result.content[0].text).toBe('5'); + }); +}); +``` + +### What this tests + +An `InMemoryTransport` integration test exercises: + +- Tool/resource/prompt registration and dispatch +- Input validation and output serialization (catches unexpected `JSON.stringify` / schema issues) +- The full MCP protocol message exchange + +It does **not** test HTTP framing, network errors, or transport-level concerns — use real HTTP integration tests (e.g. with `supertest`) for those. + +> [!TIP] +> For tests that also need tasks or a task store, see `test/integration/test/helpers/mcp.ts` in the SDK repository for a reusable `createInMemoryTaskEnvironment` helper. + ## More server features The sections above cover the essentials. The table below links to additional capabilities demonstrated in the runnable examples. From d203eb298c38aea3f9491d9b475a3ded1c4b232d Mon Sep 17 00:00:00 2001 From: Niels Kaspers Date: Thu, 2 Apr 2026 17:13:22 +0300 Subject: [PATCH 2/2] Address review feedback on testing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update InMemoryTransport JSDoc to match InMemoryTaskStore framing - Fix inputSchema syntax: use z.object() instead of raw shape (v1 → v2) - Fix zod import: use 'zod/v4' to match codebase convention - Convert testing code blocks to source= snippet-sync pattern - Add testing regions to serverGuide.examples.ts for CI type-checking - Add @modelcontextprotocol/client + core deps to examples/server - Clarify that InMemoryTransport is already re-exported from server/client via export * from @modelcontextprotocol/core --- docs/server.md | 70 +++++++-------------- examples/server/package.json | 2 + examples/server/src/serverGuide.examples.ts | 42 +++++++++++++ examples/server/tsconfig.json | 1 + packages/client/src/index.ts | 2 +- packages/core/src/util/inMemory.ts | 6 +- packages/server/src/index.ts | 2 +- pnpm-lock.yaml | 6 ++ 8 files changed, 82 insertions(+), 49 deletions(-) diff --git a/docs/server.md b/docs/server.md index 52a5e7966..4272b1caf 100644 --- a/docs/server.md +++ b/docs/server.md @@ -478,65 +478,43 @@ If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP frame ## Testing -Unit-testing MCP servers does not require a running HTTP server or a subprocess. Use `InMemoryTransport` from `@modelcontextprotocol/core` to wire a client and server together in-process. +Unit-testing MCP servers does not require a running HTTP server or a subprocess. Use `InMemoryTransport` to wire a client and server together in-process — it is exported from both `@modelcontextprotocol/server` and `@modelcontextprotocol/client`. ### Basic setup -```ts -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/core'; -import { McpServer } from '@modelcontextprotocol/server'; - -function createTestPair() { - const server = new McpServer({ name: 'test-server', version: '1.0.0' }); - const client = new Client({ name: 'test-client', version: '1.0.0' }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - return { server, client, clientTransport, serverTransport }; -} +```ts source="../examples/server/src/serverGuide.examples.ts#testing_setup" +const server = new McpServer({ name: 'test-server', version: '1.0.0' }); +const client = new Client({ name: 'test-client', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); ``` `InMemoryTransport.createLinkedPair()` returns two linked transports: messages written to one are read by the other, with no networking involved. ### Example: testing a tool -```ts -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // or jest -import * as z from 'zod'; -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/core'; -import { McpServer } from '@modelcontextprotocol/server'; - -describe('my-tool', () => { - let server: McpServer; - let client: Client; - - beforeEach(async () => { - server = new McpServer({ name: 'test-server', version: '1.0.0' }); - client = new Client({ name: 'test-client', version: '1.0.0' }); - - server.registerTool( - 'add', - { description: 'Add two numbers', inputSchema: { a: z.number(), b: z.number() } }, - async ({ a, b }) => ({ - content: [{ type: 'text', text: String(a + b) }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - }); +```ts source="../examples/server/src/serverGuide.examples.ts#testing_tool" +const server = new McpServer({ name: 'test-server', version: '1.0.0' }); +const client = new Client({ name: 'test-client', version: '1.0.0' }); - afterEach(async () => { - await client.close(); - }); +server.registerTool( + 'add', + { description: 'Add two numbers', inputSchema: z.object({ a: z.number(), b: z.number() }) }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) +); - it('returns the sum', async () => { - const result = await client.callTool({ name: 'add', arguments: { a: 2, b: 3 } }); - expect(result.content[0].text).toBe('5'); - }); -}); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + +const result = await client.callTool({ name: 'add', arguments: { a: 2, b: 3 } }); +console.log(result.content); // [{ type: 'text', text: '5' }] + +await client.close(); ``` +Wrap the above in your test framework of choice (`vitest`, `jest`, etc.) — the setup and assertions work identically in any runner. + ### What this tests An `InMemoryTransport` integration test exercises: diff --git a/examples/server/package.json b/examples/server/package.json index d4d61841b..a35629d2e 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -34,6 +34,8 @@ "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/express": "workspace:^", diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index ca4be3d4a..a1ddb8ca9 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto'; +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; @@ -372,7 +374,47 @@ function dnsRebinding_allowedHosts() { return app; } +// --------------------------------------------------------------------------- +// Testing +// --------------------------------------------------------------------------- + +/** Example: Creating a linked client/server pair for in-memory testing. */ +function testing_setup() { + //#region testing_setup + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + //#endregion testing_setup + return { server, client, clientTransport, serverTransport }; +} + +/** Example: Registering a tool and connecting via InMemoryTransport. */ +async function testing_tool() { + //#region testing_tool + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + server.registerTool( + 'add', + { description: 'Add two numbers', inputSchema: z.object({ a: z.number(), b: z.number() }) }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.callTool({ name: 'add', arguments: { a: 2, b: 3 } }); + console.log(result.content); // [{ type: 'text', text: '5' }] + + await client.close(); + //#endregion testing_tool +} + // Suppress unused-function warnings (functions exist solely for type-checking) +void testing_setup; +void testing_tool; void registerTool_basic; void registerTool_resourceLink; void registerTool_logging; diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json index 026b68312..afdde14cf 100644 --- a/examples/server/tsconfig.json +++ b/examples/server/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "paths": { "*": ["./*"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 787cfd2f0..4840ffc0c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -10,5 +10,5 @@ export * from './client/websocket.js'; // experimental exports export * from './experimental/index.js'; -// re-export shared types +// re-export shared types (includes InMemoryTransport) export * from '@modelcontextprotocol/core'; diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts index a02bcafc9..89a5fbff7 100644 --- a/packages/core/src/util/inMemory.ts +++ b/packages/core/src/util/inMemory.ts @@ -8,7 +8,11 @@ interface QueuedMessage { } /** - * In-memory transport for creating clients and servers that talk to each other within the same process. + * In-memory transport for development and testing. Links a client and server + * in the same process without networking. + * + * For production and integration tests that should exercise HTTP framing, + * use {@link StreamableHTTPClientTransport} against a real server. */ export class InMemoryTransport implements Transport { private _otherTransport?: InMemoryTransport; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1a8dbf143..b089c02f5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,5 +8,5 @@ export * from './server/streamableHttp.js'; // experimental exports export * from './experimental/index.js'; -// re-export shared types +// re-export shared types (includes InMemoryTransport) export * from '@modelcontextprotocol/core'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc778c3cf..2a1fd463d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,12 @@ importers: '@hono/node-server': specifier: catalog:runtimeServerOnly version: 1.19.9(hono@4.11.4) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core '@modelcontextprotocol/examples-shared': specifier: workspace:^ version: link:../shared