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.
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 SDKClient: list tools, check schemas, call each tool, and assert on errors. -
Test the transport once over Streamable HTTP: the SDK client connects,
initializereturns 200, wrongAcceptheaders get 406,GETgets 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 title="src/server.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 title="test/unit.test.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 title="test/mcp.test.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 title="src/http.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 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:
- `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](https://fabwaseem.com/blog/mcp-vs-api).
## Manual checks with the MCP Inspector CLI
The [MCP Inspector](https://github.com/modelcontextprotocol/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 title="Inspector CLI"
# 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:
{
"content": [{ "type": "text", "text": "hello-mcp-world" }],
"structuredContent": { "slug": "hello-mcp-world" }
}
And raw curl, for when you suspect the client:
```bash title="curl"
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 title="package.json (scripts)"
{
"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 returnsisError: truewith a readable message. - Output schemas:
structuredContentmatches what you declared. - Transport: SDK client connects;
initializestatus and session header;Accepthandling (406);GETbehavior (405 when stateless). - Auth, if you have it: no token gets 401 with a
WWW-Authenticateheader, and a wrong-audience token is rejected. The MCP security guide covers the cases worth testing. - Size: keep an eye on the
tools/listbyte 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.
FAQ
How do I test an MCP server without an AI client?
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.
What is InMemoryTransport in the MCP SDK?
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.
How do I use the MCP Inspector CLI?
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.
Should an MCP tool throw or return isError?
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.
Why does my MCP server return 406 Not Acceptable?
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.
Top comments (2)
The note that schema failures and unknown tools both came back as isError results instead of throws is the most useful line here. A lot of client code only catches exceptions and quietly treats those as success. One test I'd add to the contract layer: snapshot the tools/list output, descriptions included. Agents pick tools by reading those descriptions, so a reworded one can change behavior without any code test failing. Do you version the snapshot alongside the server version?
Good call, and no, I don't do that yet. The contract tests in the post only check that every tool has a description long enough to be useful, so a reworded description passes them without anyone noticing.
A snapshot would catch it. I'd sort tools/list by name and run it through Vitest's toMatchFileSnapshot, so the snapshot file sits in the repo next to the server. Then a changed description shows up as a diff in the same PR as the code change and the version bump, and someone has to accept it on purpose. That's enough versioning for me, since the snapshot always matches the commit it ships with.