AI Engineering

How to Test an MCP Server: Vitest, In-Memory Transport, Streamable HTTP and the Inspector CLI

A tested way to test MCP servers in TypeScript: unit tests for tool logic, in-memory contract tests with the official SDK client, Streamable HTTP checks (406, 405, no session) and MCP Inspector CLI commands.

How to test an MCP server cover: a Vitest file linking an SDK client to the server with InMemoryTransport and asserting structuredContent, 13 tests passed
On this page
  1. TL;DR
  2. The server under test
  3. Layer 1: unit tests for the tool logic
  4. Layer 2: contract tests with the in-memory transport
  5. Layer 3: the Streamable HTTP transport
  6. Manual checks with the MCP Inspector CLI
  7. Running it in CI
  8. What to test, as a checklist

Testing an MCP server means checking three layers: the pure functions behind your tools, the MCP contract (tool list, schemas, results and errors), and the transport your clients actually use. You can cover all three with Vitest and the official TypeScript SDK, without starting an AI client, and the whole suite below runs in about 300 ms.

I wrote this while building Benchline, my set of browser tools that are also a stateless MCP server. Every tool there is a pure function first and an MCP tool second, and that split is what makes testing easy. Below is a small, complete example server you can copy, the tests I run against it, and the MCP Inspector CLI commands for manual checks. Everything was run with @modelcontextprotocol/sdk 1.32.2, Vitest 5.0.5 and Node 22.20, and I re-ran the full suite on October 31, 2026 (1.32.2 is still the latest SDK release) with the same result.

TL;DR#

  • Unit-test the logic as plain functions. Most bugs live there, and these tests need no MCP at all.
  • Contract-test the server in memory with InMemoryTransport.createLinkedPair() and a real SDK Client: list tools, check schemas, call each tool, and assert on errors.
  • Test the transport once over Streamable HTTP: the SDK client connects, initialize returns 200, wrong Accept headers get 406, GET gets 405 on a stateless server.
  • Check by hand with npx @modelcontextprotocol/inspector --cli <url> --transport http --method tools/list.
  • In SDK 1.32.2, a schema failure and an unknown tool both came back as a tool result with isError: true, not as a thrown error. Write your assertions so they hold either way.

The server under test#

Two read-only tools: slugify, with a typed output schema, and json_format, which must report bad input as an error result instead of crashing.

ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function slugify(input: string) {
  return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase()
    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}

export function createServer() {
  const server = new McpServer({ name: "demo-tools", version: "1.0.0" });

  server.registerTool("slugify", {
    title: "Slugify",
    description: "Turn a title into a URL slug (lowercase ASCII, words joined by hyphens).",
    inputSchema: { text: z.string().min(1).max(500) },
    outputSchema: { slug: z.string() },
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
  }, async ({ text }) => {
    const slug = slugify(text);
    return { content: [{ type: "text", text: slug }], structuredContent: { slug } };
  });

  server.registerTool("json_format", {
    title: "Format JSON",
    description: "Pretty-print a JSON string with the given indent. Returns an error result for invalid JSON.",
    inputSchema: { json: z.string(), indent: z.number().int().min(0).max(8).default(2) },
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
  }, async ({ json, indent }) => {
    try {
      return { content: [{ type: "text", text: JSON.stringify(JSON.parse(json), null, indent) }] };
    } catch (e) {
      return { isError: true, content: [{ type: "text", text: `Invalid JSON: ${(e as Error).message}` }] };
    }
  });

  return server;
}

The key design choice is that slugify is exported on its own. The MCP handler is a thin wrapper, so the logic is tested like any other function.

Layer 1: unit tests for the tool logic#

ts
import { describe, it, expect } from "vitest";
import { slugify } from "../src/server.ts";

describe("slugify (pure function)", () => {
  it("lowercases and joins words", () => expect(slugify("Hello, MCP World!")).toBe("hello-mcp-world"));
  it("strips accents", () => expect(slugify("Crème Brûlée")).toBe("creme-brulee"));
  it("trims separators", () => expect(slugify("  --a--  ")).toBe("a"));
});

Nothing MCP-specific here, and that is the point. In Benchline, each tool folder has a pure lib/ covered by Vitest. The MCP layer only maps inputs and outputs.

Layer 2: contract tests with the in-memory transport#

The SDK ships InMemoryTransport, which links a client and a server inside one process. You test the real protocol (JSON-RPC messages, schema validation, result shapes) with no ports and no network.

ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/server.ts";

let client: Client;
beforeAll(async () => {
  const [clientT, serverT] = InMemoryTransport.createLinkedPair();
  await createServer().connect(serverT);
  client = new Client({ name: "test", version: "1.0.0" });
  await client.connect(clientT);
});
afterAll(() => client.close());

describe("MCP contract (in-memory)", () => {
  it("lists tools with object schemas and descriptions", async () => {
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
    for (const t of tools) {
      expect(t.name).toMatch(/^[a-z][a-z0-9_]{0,63}$/);
      expect(t.description?.length).toBeGreaterThan(20);
      expect(t.inputSchema.type).toBe("object");
      expect(t.annotations?.readOnlyHint).toBe(true);
    }
    expect(new Set(tools.map((t) => t.name)).size).toBe(tools.length);
  });

  it("returns structured content that matches the output schema", async () => {
    const r = await client.callTool({ name: "slugify", arguments: { text: "Hello MCP" } });
    expect(r.isError).toBeFalsy();
    expect(r.structuredContent).toEqual({ slug: "hello-mcp" });
  });

  it("applies schema defaults", async () => {
    const r = await client.callTool({ name: "json_format", arguments: { json: '{"a":1}' } });
    expect((r.content as any)[0].text).toBe('{\n  "a": 1\n}');
  });

  it("reports bad input as a tool error, not a crash", async () => {
    const r = await client.callTool({ name: "json_format", arguments: { json: "{oops" } });
    expect(r.isError).toBe(true);
    expect((r.content as any)[0].text).toMatch(/^Invalid JSON/);
  });

  it("rejects arguments that fail the input schema", async () => {
    const r = await client.callTool({ name: "slugify", arguments: { text: "" } }).catch((e) => e);
    if (r instanceof Error) expect((r as any).code).toBe(-32602);
    else expect((r as any).isError).toBe(true);
  });

  it("errors on unknown tools", async () => {
    const r = await client.callTool({ name: "nope", arguments: {} }).catch((e) => e);
    expect(r instanceof Error ? true : (r as any).isError).toBe(true);
  });
});

What these tests catch:

  • Schema regressions. Every tool must have an object inputSchema and a useful description. A model picks tools by name and description, so an empty description is a real bug.
  • Annotation drift. If a tool is meant to be read-only, assert readOnlyHint. Clients can use annotations to decide when to ask the user for confirmation.
  • Duplicate names after a refactor.
  • Error shape. A handler that throws on bad input is worse than one that returns isError: true with a clear message, because the model can read the message and retry.

Two results surprised me, so I logged them before writing the assertions. With SDK 1.32.2, calling slugify with an empty string (which fails min(1)) and calling a tool that doesn't exist both resolved to a result with isError: true. The unknown-tool text was MCP error -32602: Tool nope not found. Older SDK versions and other server frameworks may throw a JSON-RPC error instead, so the tests accept both shapes.

Layer 3: the Streamable HTTP transport#

Clients like Cursor, Claude Code and VS Code connect over HTTP, so test that path once. This server is stateless: a new McpServer and transport per request and no session ID. Benchline's public endpoint is stateless in the same way: no API key and no session.

ts
import { createServer as createHttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "./server.ts";

// Stateless Streamable HTTP: a new server + transport per request, no session id.
export function startHttp(port = 0) {
  const http = createHttpServer(async (req, res) => {
    if (req.url !== "/mcp") { res.writeHead(404).end(); return; }
    if (req.method !== "POST") { res.writeHead(405, { Allow: "POST" }).end(); return; }
    const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer);
    const body = JSON.parse(Buffer.concat(chunks).toString() || "null");
    const server = createServer();
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
    res.on("close", () => { transport.close(); server.close(); });
    await server.connect(transport);
    await transport.handleRequest(req, res, body);
  });
  return new Promise<{ url: URL; close: () => void }>((resolve) =>
    http.listen(port, "127.0.0.1", () => {
      const { port } = http.address() as { port: number };
      resolve({ url: new URL(`http://127.0.0.1:${port}/mcp`), close: () => http.close() });
    }));
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const { url } = await startHttp(Number(process.env.PORT ?? 3321));
  console.log(`MCP server on ${url}`);
}
ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { startHttp } from "../src/http.ts";

let srv: Awaited<ReturnType<typeof startHttp>>;
const url = () => process.env.MCP_URL ? new URL(process.env.MCP_URL) : srv.url;
beforeAll(async () => { if (!process.env.MCP_URL) srv = await startHttp(); });
afterAll(() => srv?.close());

const init = { jsonrpc: "2.0", id: 1, method: "initialize",
  params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "curl", version: "1" } } };

describe("Streamable HTTP transport", () => {
  it("works with the official SDK client", async () => {
    const c = new Client({ name: "http-test", version: "1.0.0" });
    await c.connect(new StreamableHTTPClientTransport(url()));
    const { tools } = await c.listTools();
    expect(tools.length).toBeGreaterThan(0);
    await c.close();
  });

  it("answers initialize without a session id (stateless)", async () => {
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),
      headers: { "content-type": "application/json", accept: "application/json, text/event-stream" } });
    expect(r.status).toBe(200);
    expect(r.headers.get("mcp-session-id")).toBeNull();
  });

  it("rejects a client that does not accept both content types", async () => {
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),
      headers: { "content-type": "application/json", accept: "application/json" } });
    expect(r.status).toBe(406);
  });

  it("returns 405 for GET when there is no SSE stream", async () => {
    const r = await fetch(url(), { headers: { accept: "text/event-stream" } });
    expect(r.status).toBe(405);
  });
});

All four passed. Facts worth pinning in tests, because a framework upgrade can silently change them:

  • initialize returned 200 with no mcp-session-id header, so clients don't need to track a session.
  • A request with Accept: application/json only returned 406. The Streamable HTTP spec requires clients to accept both application/json and text/event-stream, and some hand-written clients and proxies don't.
  • GET /mcp returned 405, which tells clients there is no server-to-client SSE stream on a stateless server.
  • In stateless mode, tools/list worked without an initialize call first. A raw curl got back a 1,120-byte JSON response for the two tools.

Set MCP_URL to run the same file against a deployed server: MCP_URL=https://your-host/mcp npx vitest run test/http.test.ts. I ran it against the local server started with node src/http.ts, and all 4 tests passed.

Response size is worth a test too. Everything in tools/list is sent to the model, so it costs context on every conversation. I measured why that matters in MCP vs API.

Manual checks with the MCP Inspector CLI#

The MCP Inspector has a web UI and a CLI mode. The CLI is handy in a terminal or a CI smoke step (I first ran it with version 2.12.0, and the same commands gave the same output with 2.13.0, the latest release on October 31, 2026):

bash
# list tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/list

# call one tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/call --tool-name slugify --tool-arg 'text=Hello MCP World'

The call printed:

json
{
  "content": [{ "type": "text", "text": "hello-mcp-world" }],
  "structuredContent": { "slug": "hello-mcp-world" }
}

And raw curl, for when you suspect the client:

bash
curl -s http://127.0.0.1:3321/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Running it in CI#

json
{
  "scripts": {
    "test": "vitest run",
    "test:remote": "vitest run test/http.test.ts"
  }
}

npm test runs all three layers. In 3 test files and 13 tests, Vitest reported a duration of 295 ms. Run test:remote with MCP_URL set after each deploy as a smoke test. It catches a broken proxy, a missing route or a CDN that strips headers, which is exactly what unit tests can't see.

What to test, as a checklist#

  • Every tool: unit tests for its logic, including edge cases and bad input.
  • tools/list: unique names, object input schemas, descriptions long enough to be useful, expected annotations.
  • Each tool through callTool: one happy path, one error path that returns isError: true with a readable message.
  • Output schemas: structuredContent matches what you declared.
  • Transport: SDK client connects; initialize status and session header; Accept handling (406); GET behavior (405 when stateless).
  • Auth, if you have it: no token gets 401 with a WWW-Authenticate header, and a wrong-audience token is rejected. The MCP security guide covers the cases worth testing.
  • Size: keep an eye on the tools/list byte count as you add tools.

If you are building your first server, start with how to build an MCP server in Next.js and stateless MCP servers. Then connect it to your editor with how to add a remote MCP server.

Questions