# Testing patterns

Canonical URL: https://vercel-mcp-reference.vercel.app/testing/
Markdown: https://vercel-mcp-reference.vercel.app/testing.md
Audience: engineer. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

MCP servers are message-driven programs, and on this stack the testability is architectural before it is a technique: every example registers its [tools](https://vercel-mcp-reference.vercel.app/glossary/#tool), [resources](https://vercel-mcp-reference.vercel.app/glossary/#resource), and [prompts](https://vercel-mcp-reference.vercel.app/glossary/#prompt) 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](https://vercel-mcp-reference.vercel.app/glossary/#vercel-function). Everything you want to verify (input validation, authorization, output shape, progress, cancellation, idempotency) is reachable through a [client](https://vercel-mcp-reference.vercel.app/glossary/#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

```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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://modelcontextprotocol.io/docs/tools/inspector) against your dev server, and then against a [preview deployment](https://vercel-mcp-reference.vercel.app/glossary/#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`:

```ts
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](https://vercel-mcp-reference.vercel.app/internals/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:

```ts
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:

```ts
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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/glossary/#sampling) and [elicitation](https://vercel-mcp-reference.vercel.app/glossary/#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:

```ts
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](https://vercel-mcp-reference.vercel.app/client-side/sampling-request-handling/) and [elicitation](https://vercel-mcp-reference.vercel.app/client-side/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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/internals/spec-2026-07-28/) 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](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) has the symptom table.

Two security notes for this layer. Preview deployments are publicly reachable URLs unless [Deployment Protection](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/security/checklist/#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](https://vercel-mcp-reference.vercel.app/glossary/#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](https://vercel-mcp-reference.vercel.app/glossary/#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.

## Related

- [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/) - why "works in `next dev`, breaks deployed" is a state-location bug the preview-deployment pass exists to catch
- [Transports](https://vercel-mcp-reference.vercel.app/internals/transports/) - the Streamable HTTP mechanics the Inspector exercises that the in-memory pair cannot
- [The 2026-07-28 revision](https://vercel-mcp-reference.vercel.app/internals/spec-2026-07-28/) - the contract the wire is moving to, and what your assertions will change to when it lands in an SDK release
- [Deployment](https://vercel-mcp-reference.vercel.app/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

- 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/>
