Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Testing patterns
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
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 --> conformanceThe 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 asisError: truetool 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: truetool result, matching the spec’s classification of input validation failures as tool execution errors. Hereclient.callTool()resolves; anexpect(...).rejectsassertion 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 throwsThe 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_requiredresults 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 testisvitest run, andnpm run typecheck(tsc --noEmit) is the second local gate. make test- loops every example (npm cithennpm test) the way CI does, including theorchestrator-hostcross-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.
Related
- 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 assertionsexamples/secure-tools-server(in the repository) - the house-style suite to copy, negatives asserted throughoutexamples/sampling-server(in the repository) - the registerCapabilities plus setRequestHandler harness driving a server-initiated flowexamples/orchestrator-host(in the repository) - the client side under test: two in-memory pairs, namespacing, a consent gate proven to block
Bibliography
- Model Context Protocol Specification, Versioning, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- Model Context Protocol Specification, Tools, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Model Context Protocol Specification, Multi Round-Trip Requests, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
- Model Context Protocol Specification, Deprecated Features, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Model Context Protocol, MCP Inspector - https://modelcontextprotocol.io/docs/tools/inspector
- Vercel Documentation, Deployment Protection - https://vercel.com/docs/deployment-protection
- Vercel Documentation, Protection Bypass for Automation - https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation
- Vitest, Documentation - https://vitest.dev/