API

Lifecycles

Run the same suite across MCP protocol revisions with createMcpTest.

The 2025 revisions are stateful and let a server push requests to a client; 2026-07-28 is stateless and carries them in-band instead. A server claiming to serve both should be tested on both, so createMcpTest (below) can run every test once per revision.

const test = createMcpTest(() => createServer(), {
  lifecycles: ["2025-11-25", "2026-07-28"],
});

test("echo works on every lifecycle", async ({ mcp }) => {
  const result = await mcp.callTool("echo", { message: "x" });
  expect(result).toHaveTextContent("echo: x");
});

Each test is registered once per revision with the revision appended to its name, each with its own harness, and mcp.lifecycle tells a test which one it is on. For a single revision, protocolVersion is simpler than a one-element matrix.

Where a stateful server bites

Statelessness is not only about who pushes notifications - it changes how often your factory runs. Measured against the published package: on the v1 lane the factory runs once per test, so one server instance serves every call. On the v2 lane it runs per request (six times over a connect plus three tool calls), because that is how the SDK's createMcpHandler serves a stateless lifecycle.

So a server holding state inside the factory passes on v1 and fails on v2, with tool calls that succeed but state that vanishes:

// Breaks on v2: a new `notes` array per request.
export function createServer() {
  const notes: string[] = [];
  // ...registerTool("add_note", ...) pushes into notes
}

// Works on both: state outside the factory, reset between tests.
const notes: string[] = [];
export const reset = () => void (notes.length = 0);
export function createServer() {
  // ...registerTool("add_note", ...) pushes into the shared notes
}

The harness is faithful here rather than convenient: a real stateless deployment instantiates per request too, so a suite that only passes on v1 is telling you something true about your server.

Lane'2025-11-25''2026-07-28'
v1the only revision it negotiatesthrows - the v1 SDK cannot serve it
v2legacy mode; no doubles availabledefault; full support
stdiothe only revision it negotiatesthrows - stdio is driven by v1
urllegacy mode; no doubles availablepinned; otherwise auto-negotiated

Exactly two revisions are selectable, which is an SDK property rather than a choice: pinning accepts modern revisions only, and the 2025 era is reachable just as "legacy". 2025-06-18 cannot be pinned. In lifecycles mode the returned test is typed LifecycleMcpTest and registers plain tests only, so .skip, .only, .each, and .extend are compile errors rather than runtime surprises.

createMcpTest(serverOrFactory, options?)

Returns a vitest test with an mcp fixture: a fresh harness per test, closed after each one. Same arguments as mcpTest.

import { expect } from "vitest";
import { createMcpTest } from "mcp-vitest";
import { createServer } from "./server.js";

const test = createMcpTest(() => createServer());

test("lists prompts", async ({ mcp }) => {
  await expect(mcp).toHavePrompt("greet");
});

Completions

complete() asks the server to complete a prompt argument or a resource-template variable, the same call an editor's autocomplete would make.

const { completion } = await mcp.complete(
  { type: "ref/prompt", name: "greet" },
  { name: "name", value: "A" },
);
expect(completion.values).toEqual(["Ada", "Alan"]);

Pass { type: 'ref/resource', uri } to complete a resource template instead.

On this page