Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Testing patterns

Audience:engineerMCP spec 2026-07-28

MCP servers are message-driven programs, and on this stack the testability is architectural before it is a technique: every example registers its tools, resources, and prompts in an exported configureServer(server: McpServer), and the Next.js route handler is a thin shell that hands that function to createMcpHandler and wraps the result in withOriginCheck from src/origin.ts (with withMcpAuth inside it where the example authenticates). Tests import configureServer from src/ and never import Next.js, never open a socket, and never touch a deployed Vercel Function. Everything you want to verify (input validation, authorization, output shape, progress, cancellation, idempotency) is reachable through a client connected in memory, over real JSON-RPC framing and the SDK’s real connection lifecycle. Two small framework-adjacent suites ride alongside in every example: tests/origin.test.ts drives the Origin allowlist with plain Fetch Request objects, and tests/vercel-config.test.ts reads vercel.json to assert the route’s maxDuration is set.

The three layers

Unit: handlerbehaviorvalidation, authz,output, idempotencyIntegration:protocol behaviordiscovery, errorsemantics,capabilitiesConformance: MCPInspectornpm run dev, then apreview deployment
Unit: handlerbehaviorvalidation, authz,output, idempotencyIntegration:protocol behaviordiscovery, errorsemantics,capabilitiesConformance: MCPInspectornpm run dev, then apreview deployment
Mermaid flowchartOpen in Mermaid Live Editor
Diagram source (Mermaid)
flowchart TB
    unit["Unit: handler behavior<br/>validation, authz, output, idempotency"]
    integration["Integration: protocol behavior<br/>discovery, error semantics, capabilities"]
    conformance["Conformance: MCP Inspector<br/>npm run dev, then a preview deployment"]
    unit --> integration --> conformance

The first two layers share one harness. What separates them is what you assert, not what you construct: a unit test asks “given these arguments, does the tool do the right thing?”, an integration test asks “does the server behave correctly as a protocol peer?” (discovery lists, capability negotiation, how errors surface on the wire). Because the in-memory pair is cheap and deterministic, there is no reason to bypass the protocol for unit tests the way a direct function call would; the same connected client serves both layers.

The third layer is manual and matters more here than in a long-lived-process world: the MCP Inspector against your dev server, and then against a preview deployment, because a deployed function has failure modes no local process can reproduce.

The in-memory harness

This is the idiom every example’s suite is built on, verified against SDK v2 (@modelcontextprotocol/server and @modelcontextprotocol/client 2.0.0, the line mcp-handler 2.1.1 peers on with ^2.0.0). The server package exports McpServer and InMemoryTransport; the client package (a devDependency, tests only) exports Client:

import { Client } from "@modelcontextprotocol/client";
import { McpServer, InMemoryTransport } from "@modelcontextprotocol/server";
import { configureServer } from "../src/server";
async function connect() {
const server = new McpServer({ name: "test-server", version: "0.0.0" });
configureServer(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "0.0.0" });
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
]);
return { server, client };
}

InMemoryTransport.createLinkedPair() returns two coupled transports: what one end sends, the other receives. From there the client drives everything: client.listTools() for registration, schemas, and annotations; client.callTool({ name, arguments }) for behavior; client.readResource({ uri }) for resources. No network, no Vercel account, no deployed infrastructure; npm test must pass offline after install, by repo rule. Two v2 migration notes that bite in test code: inputSchema is now a full zod object schema (z.object({ ... }), not the raw shape v1 took), and the v2 packages require zod 4.2.0 or newer (a ^3 install succeeds and then fails at runtime, so pin "zod": "^4.2.0").

What connect() does on the wire depends on the client’s negotiation mode and on what the far end answers. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path. Concretely, verified against 2.0.0 over InMemoryTransport: a bare McpServer answers server/discover with -32601, and the Client defaults versionNegotiation.mode to 'legacy', so connect() performs the initialize handshake at protocol version 2025-11-25, results carry no resultType field, and list results carry no ttlMs/cacheScope at the client API. That is a property of this harness, not of the deployed server: the same configureServer behind createMcpHandler serves the modern frames over HTTP. So keep resultType, ttlMs, and cacheScope assertions out of the in-memory suites (they cannot pass there) and put them in an HTTP-level check against the handler if you need them; the message trace shows the request shape.

Error semantics: one throws, one returns isError

Two behaviors verified against SDK 2.0.0 that your assertions must match, and the first one changed from v1:

  • An unknown tool name rejects with a ProtocolError. v1 (SDK 1.26.0) surfaced unknown tools as isError: true tool results; v2 restores the spec’s classification of an unknown tool name as a protocol error. A suite migrated from v1 must flip these assertions from result inspection to rejection:
await expect(
client.callTool({ name: "nope", arguments: {} }),
).rejects.toThrow(/not found/i);
  • Schema-invalid arguments on a known tool still come back as an isError: true tool result, matching the spec’s classification of input validation failures as tool execution errors. Here client.callTool() resolves; an expect(...).rejects assertion will never fire:
const result = await client.callTool({
name: "echo",
arguments: { message: 42 as unknown as string },
});
expect(result.isError).toBe(true); // resolves with an error result, never throws

The mirror rule for happy paths: assert result.isError is falsy and assert the content, because an error result carries a content array too. A suite that only checks content cannot tell success from failure. examples/minimal-server (in the repository) carries both negatives verbatim.

Server-initiated features: sampling and elicitation

Deprecation note: sampling (and roots) are deprecated as of 2026-07-28 (SEP-2577), with a window of at least twelve months; the suggested migration for sampling is calling the LLM provider directly from the server (on Vercel: the AI SDK or AI Gateway). Elicitation is not deprecated, but the 2026-07-28 contract re-expresses all of these flows as MRTR input_required results and retries rather than server-initiated requests. The tests below exercise the legacy, server-initiated shape because that is what the in-memory harness speaks; the same registered handlers serve the MRTR shape, as noted at the end of this section.

On the legacy handshake the in-memory harness speaks, sampling and elicitation invert the flow mid-call: the server sends a request to the client while a tool is running, so you cannot test them by calling the tool and inspecting the result alone. The in-memory client plays the host’s part. The working idiom on SDK v2 (verified against the installed @modelcontextprotocol/client 2.0.0 typings: handlers register by method name string, and the SDK wraps sampling/createMessage and elicitation/create handlers with schema validation on both sides), in order:

import { Client } from "@modelcontextprotocol/client";
const client = new Client({ name: "test-client", version: "0.0.0" });
// 1. BEFORE connect: declare the capability. The SDK only accepts a
// handler whose capability was declared, and registerCapabilities
// can only be called before connecting to a transport.
client.registerCapabilities({ sampling: {} });
// 2. Install the handler for the server-initiated request.
const seen: unknown[] = [];
client.setRequestHandler("sampling/createMessage", async (request) => {
seen.push(request.params); // record, so tests can assert the outbound request
return {
model: "test-model",
role: "assistant" as const,
content: { type: "text" as const, text: "canned completion" },
};
});
// 3. Only now connect the linked pair.

Order is load-bearing twice over: capabilities are advertised at connect time, so registering them after connect is too late for the server to see them; and the SDK asserts request handlers against the declared capabilities, so installing the handler without the capability fails. Elicitation is the same shape: client.registerCapabilities({ elicitation: {} }), then client.setRequestHandler("elicitation/create", ...) returning { action: "accept", content: { approved: true } } with a flat primitives-only content. Test the three actions (accept, decline, cancel) distinctly, and remember that accept with approved: false is an explicit no, not a decline. A forward-compatibility bonus of this idiom: the v2 client’s MRTR auto-fulfilment engine answers input_required results through these same registered handlers whenever a connection negotiates 2026-07-28 (as it does against the deployed handler with versionNegotiation: { mode: 'auto' }), so the harness survives the change of wire shape.

The recording array is the point of the harness: assert what the server sent (message content, model preferences, elicitation schema), not just what the tool returned. examples/sampling-server (in the repository) and examples/elicitation-server (in the repository) are the reference suites; the client-side pages on sampling request handling and elicitation cover what a real host does where your test returns a canned answer.

Conformance and manual testing: the Inspector

Beyond “does my tool work” sits “does my server behave like an MCP server”: negotiated capabilities only, correct result shapes, correct error codes. The MCP Inspector is the official interactive tool for that. Run npx @modelcontextprotocol/inspector, select the Streamable HTTP transport, and point it at http://localhost:3000/api/mcp with npm run dev running; browse tools, resources, and prompts, fire calls with crafted inputs, and watch the raw frames and notifications. Know what wire you are looking at: the server serves both eras, so the raw frames depend on the Inspector build you run. A 2026-07-28 client shows the sessionless, _meta-carried exchange the 2026-07-28 revision specifies; a 2025-era client shows the legacy initialize handshake at protocol version 2025-11-25, and even then no Mcp-Session-Id header, because the stateless fallback never issues one.

Then do what a laptop-only workflow never forces: deploy and point the same Inspector at the preview URL (vercel deploy mints one per commit; the endpoint is https://<deployment-url>/api/mcp). This is the only layer that exercises real Streamable HTTP mechanics end to end: cold starts, maxDuration, cross-invocation state, and above all the instance-memory bugs that next dev structurally hides because it is one long-lived process. “Works locally, breaks deployed” is almost always a state-location bug; serverless sessions has the symptom table.

Two security notes for this layer. Preview deployments are publicly reachable URLs unless Deployment Protection is enabled, so a preview of a server with real credentials is a live, guessable endpoint. And when protection is on, automated clients (the Inspector, CI conformance runs) authenticate with the Protection Bypass for Automation secret sent as the x-vercel-protection-bypass header; that secret opens every protected deployment in the project, so treat it like any other credential. See Deployment posture.

Determinism

Deterministic suites are a repo rule, not a preference: no wall-clock sleeps, fixed seed data, explicit ordering, and module state exposed for reset in beforeEach wherever a server is stateful. Time-dependent behavior gets modeled out rather than waited on; in examples/async-jobs-server (in the repository) “long-running” work is a fixed step count advanced by an exported non-tool function the tests call explicitly, so the whole progress-and-cancellation lifecycle runs reproducibly with zero sleeps. Progress emissions are captured with recording stubs. The payoff compounds on serverless: a suite with no hidden clock and no hidden instance state is also a suite that cannot accidentally depend on Fluid compute instance reuse.

Assert the negatives

The most common testing gap is a suite that proves the happy path and nothing else; it keeps passing after the security property it implies is broken. Test the load-bearing negatives: that validation rejects a bad argument, that an unknown tool name rejects with a protocol error, that default-deny denies an unauthorized principal, that an unknown job handle fails closed, that a namespaced surface omits the bare tool names, that a fail-closed consent gate blocks on a malformed callback, and that the failure left no partial state behind. A control you do not assert against is a control you do not have.

Tooling

  • vitest - every example’s tests/ runs under it; npm test is vitest run, and npm run typecheck (tsc --noEmit) is the second local gate.
  • make test - loops every example (npm ci then npm test) the way CI does, including the orchestrator-host cross-install; make test-one EX=<dir> scopes to one.
  • MCP Inspector - interactive conformance and debugging against a live server, local or deployed.
  • CI matrix - every example must appear in the examples workflow matrix; see CONTRIBUTING (CONTRIBUTING.md, in the repository). A server without a CI entry is a server free to bitrot silently.
  • Serverless sessions - why “works in next dev, breaks deployed” is a state-location bug the preview-deployment pass exists to catch
  • Transports - the Streamable HTTP mechanics the Inspector exercises that the in-memory pair cannot
  • The 2026-07-28 revision - the contract the wire is moving to, and what your assertions will change to when it lands in an SDK release
  • Deployment - preview deployments, Deployment Protection, and the bypass secret in context
  • examples/minimal-server (in the repository) - the migrated v2 template; its suite carries the verified error-semantics assertions
  • examples/secure-tools-server (in the repository) - the house-style suite to copy, negatives asserted throughout
  • examples/sampling-server (in the repository) - the registerCapabilities plus setRequestHandler harness driving a server-initiated flow
  • examples/orchestrator-host (in the repository) - the client side under test: two in-memory pairs, namespacing, a consent gate proven to block

Bibliography