Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.
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.
InMemoryTransport.createLinkedPair() and a real SDK Client: list tools, check schemas, call each tool, and assert on errors.initialize returns 200, wrong Accept headers get 406, GET gets 405 on a stateless server.npx @modelcontextprotocol/inspector --cli <url> --transport http --method tools/list. isError: true, not as a thrown error. Write your assertions so they hold either way.
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.
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 #
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.
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.
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
inputSchemaand 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: truewith 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.
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 title="test/http.test.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>;
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:
initializereturned 200 with nomcp-session-idheader, so clients don't need to track a session.- A request with
Accept: application/jsononly returned 406. The Streamable HTTP spec requires clients to accept bothapplication/jsonandtext/event-stream, and some hand-written clients and proxies don't. GET /mcpreturned 405, which tells clients there is no server-to-client SSE stream on a stateless server.- In stateless mode,
tools/listworked without aninitializecall 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):
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
--transport http --method tools/list
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:
{
"content": [{ "type": "text", "text": "hello-mcp-world" }],
"structuredContent": { "slug": "hello-mcp-world" }
}
And raw curl, for when you suspect the client:
curl -s [http://127.0.0.1:3321/mcp](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 #
{
"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.
tools/list: unique names, object input schemas, descriptions long enough to be useful, expected annotations.callTool: one happy path, one error path that returns structuredContent matches what you declared.WWW-Authenticate header, and a wrong-audience token is rejected. The 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.
Use the official SDK's Client class in your test runner. Connect it to your server with InMemoryTransport.createLinkedPair() for fast in-process tests, or with StreamableHTTPClientTransport for the HTTP path, then call listTools and callTool and assert on the results. No model or editor is needed.
InMemoryTransport, exported from @modelcontextprotocol/sdk/inMemory.js, creates a linked pair of transports inside one process. Connect your server to one end and a Client to the other, and they exchange real JSON-RPC MCP messages without any network, which makes contract tests fast and deterministic.
Run npx @modelcontextprotocol/inspector --cli followed by the server URL, --transport http and a method. For example --method tools/list lists tools, and --method tools/call --tool-name slugify --tool-arg 'text=Hello' calls one. It prints the JSON result, which makes it useful for scripts and CI smoke checks.
Return a result with isError: true and a clear text message for expected failures such as invalid input. The model can read the message and try again. In my tests with SDK 1.32.2, even schema validation failures and unknown tool names came back as isError results.
The Streamable HTTP transport requires the client to send an Accept header listing both application/json and text/event-stream. A request with only Accept: application/json got 406 from the SDK's server transport in my test. Fix the client or the proxy that rewrites the header.
Originally published on fabwaseem.com.