MP-301b · Module 1

Mock Transports & Test Doubles

3 min read

The MCP SDK provides InMemoryTransport for testing the full protocol cycle without stdio or HTTP. You create a linked pair of transports — one for the client, one for the server — connect them, and run your tests through the client. This tests everything: schema validation, request routing, handler execution, error formatting, and response serialization. It is the closest you can get to a real MCP conversation without spawning processes or opening ports.

Beyond InMemoryTransport, you need test doubles for your server's external dependencies. A fixture server is a pre-configured MCP server with deterministic responses — every tool call returns the same result for the same input, regardless of database state, API availability, or network conditions. Build your fixture server by replacing dependency injections with in-memory implementations: a Map instead of a database, static JSON instead of API calls. This makes your integration tests reproducible and CI-friendly.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../../src/server.js";

// Fixture data — deterministic, no external dependencies
const fixtureDb = new Map([
  ["CUS-001", { id: "CUS-001", name: "Acme Corp", status: "active" }],
  ["CUS-002", { id: "CUS-002", name: "Globex Inc", status: "churned" }],
]);

export async function createTestPair() {
  // Server with fixture dependencies
  const server = createServer({
    crm: {
      findCustomer: async (id: string) => fixtureDb.get(id) ?? null,
      listCustomers: async () => [...fixtureDb.values()],
    },
  });

  const client = new Client(
    { name: "test-client", version: "1.0.0" },
    { capabilities: {} },
  );

  const [clientTransport, serverTransport] =
    InMemoryTransport.createLinkedPair();

  await server.connect(serverTransport);
  await client.connect(clientTransport);

  return {
    client,
    server,
    cleanup: async () => {
      await client.close();
      await server.close();
    },
  };
}

// Usage in tests:
// const { client, cleanup } = await createTestPair();
// const result = await client.callTool({ name: "get_customer", arguments: { customer_id: "CUS-001" } });
// await cleanup();
  1. Build a server factory Create a `createServer(deps)` function that accepts injected dependencies. Production passes real clients; tests pass fixtures. This is the foundation of testability.
  2. Create fixture data Build a static dataset that covers your test cases: valid records, edge cases, and expected-not-found IDs. Store it in a test helpers module.
  3. Wire up InMemoryTransport Create a `createTestPair()` helper that returns a connected client + cleanup function. Use it in every integration test to avoid transport boilerplate.