# Getting started (/en/docs/getting-started) ## Install [#install] ```bash npm i -D mcp-vitest ``` mcp-vitest requires Node >= 20 and vitest >= 3.2. Your MCP SDK is an optional peer, so install only the major you use. ## Register the matchers [#register-the-matchers] ```ts title="vitest.config.ts" import { defineConfig } from "vitest/config"; export default defineConfig({ test: { setupFiles: ["mcp-vitest/setup"] }, }); ``` ## Your first test [#your-first-test] ```ts title="server.test.ts" import { expect, test } from "vitest"; import { mcpTest } from "mcp-vitest"; import { createServer } from "./server.js"; test("exposes the echo tool", async () => { const mcp = await mcpTest(createServer()); await expect(mcp).toHaveTool("echo"); const result = await mcp.callTool("echo", { message: "hi" }); expect(result.content[0].text).toBe("echo: hi"); }); ``` The harness closes itself when the test ends, so there is no teardown to write. # Introduction (/en/docs) Testing an MCP server usually means spawning a subprocess, picking a port, or hand-rolling JSON-RPC frames. By default mcp-vitest does none of that: your server runs in-process, driven by a real SDK `Client` over the SDK's own in-process transport, and you get a small harness plus typed matchers on top. When a server cannot be imported, the same harness drives it over stdio or a URL instead. The protocol is never reimplemented, so what your tests exercise is what a real client would. ## Where to go next [#where-to-go-next] * [Getting started](/en/docs/getting-started) installs the package and writes your first test. * [`mcpTest()`](/en/docs/api/mcp-test) is the entry point for everything. # Migrating (/en/docs/migrating) Each section below covers one upgrade. Everything not listed keeps working as before. ## 0.5.0 to 0.5.1 [#050-to-051] A patch, but one behavior change can surface as a new failure, and it does so on purpose. | Change | Effect | If it bites | | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | **`auth` throws on a stdio or in-process server.** | It previously connected and sent no credential, so a suite passed while exercising no authorization at all. Only the URL transport can carry one. | Drop the `auth` option for that lane, or point the test at a URL server if the credential is what you meant to test. | | **The `fakeAuthServer` verifier checks `iss` and `nbf`, and `aud` when `audience` is set.** | A token claiming a foreign issuer, or one that is not yet valid, is now refused. Cross-issuer rejection previously only appeared to work, because each instance mints its own keypair. | Mint tokens through the instance under test rather than hand-rolling claims that contradict it. | Nothing else requires a change. [`fakeAuthServer({ audience })`](/en/docs/api/auth) is opt-in, so audience enforcement is off unless you ask for it, and the manifest helpers now return typed values where they previously returned `unknown` - which removes casts rather than adding them. ## 0.4 to 0.5 [#04-to-05] Two changes narrow what compiles. Neither changes behavior for code that already typechecked. | Change | Effect | If it bites | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`McpServerInput` no longer includes a bare `unknown`.** | `unknown` inside a union absorbs the whole union, so `mcpTest()` previously typechecked against literally any argument at all, with no compile error for a wrong type. | If the value really is one of those shapes but arrives typed loosely, narrow it first. If it is not one of them, the new error is correct and casting past it only moves the failure to runtime. | | **`McpHarness`'s constructor is now marked `@internal`.** | Affects code that constructs `McpHarness` directly instead of going through `mcpTest()` or `createMcpTest()`. The tag is a support signal, not a compile-time restriction - the constructor is still in the published types and `new McpHarness(...)` still builds - but it is no longer a supported entry point, and `RawConnection` and `DoubleRegistry` may change shape without a major version bump. | Nothing breaks today. Move to `mcpTest()` or `createMcpTest()` before those types shift under you. | ## 0.2 to 0.3 [#02-to-03] Three changes alter what your server sees, so they are worth a look if a suite starts behaving differently. | Change | Effect | If it bites | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | **v2 connections now negotiate 2026-07-28.** Through 0.2.1 the v2 lane silently ran `2025-11-25`, because `versionNegotiation` defaults to `'legacy'`. | The 2026 era is required for doubles to work at all. Progress collection was verified unaffected. | `mcpTest(server, { protocolVersion: '2025-11-25' })` restores the old behavior. | | **The client now advertises `sampling`, `elicitation`, and `roots`.** Capabilities are declared at connect, before a test body can register a double, so they go out unconditionally. | A server that branches on client capabilities now takes its sampling or elicitation path where 0.2.1 took the fallback, then fails with `no double is registered`. | Register the double, or assert the fallback against a server built without those branches. | | **`SdkClientLike` gained a required `complete()`.** | Only affects a hand-written `SdkClientLike` or `RawConnection`. | Add the method. Nothing else changed shape. | | **`RawConnection` gained a required `supports`.** | Same audience: a hand-written connection must declare `{ roots, serverInitiatedRequests }`. | Add the field. It is required on purpose - an absent one would read as "supports everything". | # Testing OAuth-protected servers (/en/docs/api/auth) Some MCP servers require a bearer token on every request. `mcp-vitest/auth` covers both sides of that: the `auth` option for sending credentials, and a fake authorization server plus assertions for exercising the RFC 9728 discovery flow end to end. It lives behind a subpath, so `import "mcp-vitest"` still pulls in zero Node builtins. ## Sending credentials [#sending-credentials] `auth` is accepted wherever `mcpTest` and `createMcpTest` take options. `{ token }` becomes an `Authorization: Bearer ` header; `{ headers }` is merged into the request verbatim: ```ts fragment const withToken = await mcpTest( { url: "https://example.com/mcp" }, { auth: { token: "test-token" } }, ); const withHeaders = await mcpTest( { url: "https://example.com/mcp" }, { auth: { headers: { authorization: "Bearer test-token", "x-tenant": "acme" } } }, ); ``` `auth` wins over a [`UrlServerSpec`](/en/docs/api/external-servers)'s own `headers`, case-insensitively - a `token` set here always overrides a same-named header on the spec rather than merging with it. Only the URL transport puts credentials on the wire, so `auth` throws on a stdio or in-process server rather than connecting without one. A silent no-op there looks exactly like working auth. An invalid header value is refused without quoting the value, since a vitest failure report goes to CI logs and the value in question is usually the credential. ## `fakeAuthServer(options?)` [#fakeauthserveroptions] Spins up a real HTTP authorization server backed by its own RS256 keypair - no mocked fetch, no stubbed JWKS. Its `verifier` drops straight into the v2 SDK's `requireBearerAuth`, so your own server's auth wiring is exercised unchanged: ```ts import { fakeAuthServer } from "mcp-vitest/auth"; import { mcpTest, serveHandler } from "mcp-vitest"; import { createMcpHandler, requireBearerAuth } from "@modelcontextprotocol/server"; import { createServer } from "./server.js"; const as = await fakeAuthServer(); const handler = createMcpHandler(() => createServer()); const served = await serveHandler({ async fetch(req) { const authResult = await requireBearerAuth({ verifier: as.verifier })(req); if (authResult instanceof Response) return authResult; return handler.fetch(req, { authInfo: authResult }); }, }); const mcp = await mcpTest( { url: `${served.url}/mcp` }, { auth: { token: as.mintToken() } }, ); // ... await served.close(); await as.close(); ``` | Member | Returns | | ------------------------------ | ------------------------------------------------------------------ | | `issuer` | the server's own base URL | | `jwksUrl` | its JWKS endpoint | | `verifier` | an `OAuthTokenVerifier` for the v2 SDK's `requireBearerAuth` | | `mintToken(claims?)` | a signed token, with `iss` and expiry set for you | | `clientCredentials(clientId?)` | exchanges a `client_credentials` grant for a token, over real HTTP | | `close()` | stops the server, and is safe to call twice | Two instances never trust each other's tokens - each mints its own keypair, so a token from one fails the other's `verifier`. ### Options [#options] | Option | Effect | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `audience` | The resource identifier this server mints for. Set it and the verifier refuses every other audience, including a token carrying none. | | `issuerPath` | Scopes the issuer to a path, e.g. `/tenant1`, for testing a multi-tenant authorization server. | ### What the verifier checks [#what-the-verifier-checks] The signature against this instance's key, `exp`, `nbf` when present, and that `iss` is this issuer. When `audience` is set it also requires the token's `aud` to match. A URL-shaped `aud` is reported as `AuthInfo.resource` whether or not `audience` is set. That matters because **`requireBearerAuth` validates neither the audience nor the issuer** - it checks the token's scopes and expiry and nothing else - so a server enforcing RFC 8707 has to read `resource` itself, and a server that forgets to would otherwise keep a green suite. Set `audience` to write the test that catches it: ```ts import { expect, test } from "vitest"; import { fakeAuthServer } from "mcp-vitest/auth"; test("a token minted for another resource is refused", async () => { const as = await fakeAuthServer({ audience: "https://api.example.com/mcp" }); try { const mine = as.mintToken({ aud: "https://api.example.com/mcp" }); const theirs = as.mintToken({ aud: "https://other.example/mcp" }); const info = await as.verifier.verifyAccessToken(mine); expect(info.resource?.toString()).toBe("https://api.example.com/mcp"); await expect(as.verifier.verifyAccessToken(theirs)).rejects.toThrow(/audience/); } finally { await as.close(); } }); ``` An `aud` array matches on any member, as JWT allows, and a token carrying no audience at all is refused once `audience` is set. `mintToken` accepts an `aud` claim, and the token endpoint honors a `resource` parameter per RFC 8707, so a token minted through the real `client_credentials` exchange carries the audience it asked for. ## Asserting the client side [#asserting-the-client-side] `expectAuthChallenge`, `fetchPrm`, and `hostClientMetadata` cover the discovery flow a real client walks before it has a token: ```ts import { expectAuthChallenge, fetchPrm, hostClientMetadata } from "mcp-vitest/auth"; // asserts a 401 + WWW-Authenticate, and surfaces the PRM url it names const challenge = await expectAuthChallenge("https://example.com/mcp"); // fetches the Protected Resource Metadata document and checks its RFC 9728 shape const prm = await fetchPrm(challenge.prmUrl!); console.log(prm.authorization_servers); // serves a Client ID Metadata Document at a dereferenceable URL, for CIMD-based clients const cimd = await hostClientMetadata({ client_name: "my tests", redirect_uris: ["http://127.0.0.1:0/callback"], }); // ... await cimd.close(); ``` `expectAuthChallenge` throws if the endpoint does not answer with a 401, so it doubles as an assertion that a server is actually protected. `fetchPrm` throws if the document is missing `resource` or `authorization_servers`, the two fields RFC 9728 requires. ## Lower-level JWT helpers [#lower-level-jwt-helpers] `generateAuthKeys`, `signJwt`, and `decodeJwt` are what `fakeAuthServer` is built on: an RSA keypair, an RS256 signer, and a decoder that does not verify. They are exported for tests that need a token from outside a `fakeAuthServer` instance - most tests should reach for `mintToken` instead. # Testing sampling and elicitation (/en/docs/api/doubles) When a tool asks the client for something - an LLM completion, a confirmation from the user - your test supplies the answer. Register a double and the harness answers on the client's behalf; the tool never knows the difference. ```ts fragment const mcp = await mcpTest(() => createServer()); // a function double sees the request mcp.onSampling((req) => { expect(req.maxTokens).toBe(50); return { model: "double", role: "assistant", content: { type: "text", text: "short" }, }; }); // or pass a constant result for elicitation mcp.onElicitation({ action: "accept", content: { confirm: true } }); const result = await mcp.callTool("summarize", { text: "a very long text" }); expect(result).toHaveTextContent("summary: short"); ``` Register a double any time before the call that triggers it. Decline and cancel are ordinary results, so `{ action: 'decline' }` exercises the path where the user says no. Forget one and you get a named error rather than a hang: `the server requested sampling but no double is registered.` A missing double surfaces differently per SDK major, because the mechanisms differ - v1 pushes the request to the client, v2 answers it locally and retries: ```ts fragment // v1: comes back as a tool error expect(await mcp.callTool("summarize", { text: "x" })).toBeToolError( /no double/, ); // v2: rejects the call await expect(mcp.callTool("summarize", { text: "x" })).rejects.toThrow( /no double/, ); ``` Three things worth knowing: * **Doubles need a connection that can carry server-initiated requests.** That is the default everywhere except a v2 or URL connection held to a 2025 revision, which has no such channel - registering there throws immediately rather than letting the call stall. * **Sampling and roots are deprecated** as of 2026-07-28 (SEP-2577), with at least a twelve-month window. Elicitation is not. * **On v2, a call that uses a double emits an extra progress event** the server never sent - the SDK reports each fulfillment round through the progress channel. It reaches `onProgress` and any progress collector, so assert on the events you care about rather than a bare count. ## Roots [#roots] `onRoots` serves the server's `roots/list` requests. **v1 only** - roots is deprecated in the 2026-07-28 revision, so the v2 lane does not advertise the capability and `onRoots` throws there rather than accepting a double that would never fire. ```ts fragment mcp.onRoots([{ uri: "file:///workspace" }]); const result = await mcp.callTool("list-roots"); expect(result).toHaveTextContent("roots: file:///workspace"); ``` # Testing an external server (/en/docs/api/external-servers) Not every server can be imported. `mcpTest` also takes a spawn spec or a URL, and everything else - matchers, collectors, doubles, snapshots - works exactly as in-process. Both report `mcp.kind === 'external'`. Spawn one over stdio; `env` and `cwd` are accepted too: ```ts fragment const mcp = await mcpTest({ command: "node", args: ["./dist/server.js"] }); ``` Or reach one already running, over Streamable HTTP: ```ts fragment const mcp = await mcpTest({ url: "https://example.com/mcp", headers: { authorization: "Bearer test-token" }, }); ``` The child process is terminated when the harness closes, which the fixture does for you. `headers` are merged into every request; for bearer tokens, the dedicated [`auth`](/en/docs/api/auth) option covers the common case with less boilerplate and wins over a same-named header here. Two things that surprise people: * **`env` does not extend your environment, it replaces most of it.** The SDK starts from a small allowlist (`HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`, and platform equivalents) and merges yours on top, so a server reading `API_KEY` from the ambient environment will not see it unless you pass it explicitly. * **A URL connection does not pin a protocol revision.** The server is not yours and may implement any era, so negotiation probes and meets it where it is. Pass `protocolVersion` to hold it to one. ## `serveHandler(handler)` [#servehandlerhandler] Binds a fetch-style handler - what both SDK majors' HTTP handlers expose - to an ephemeral loopback port, so you can point the URL transport at your own server without picking a port: ```ts import { mcpTest, serveHandler } from "mcp-vitest"; import { createMcpHandler } from "@modelcontextprotocol/server"; import { createServer } from "./server.js"; const served = await serveHandler(createMcpHandler(() => createServer())); const mcp = await mcpTest({ url: `${served.url}/mcp` }); // ... await served.close(); ``` ## `detectServerKind(server)` [#detectserverkindserver] Resolves `'v1'` or `'v2'` for an SDK server object, or rejects with a message naming what to pass instead. Exported for the rare case you need to branch on the SDK major yourself. # McpHarness (/en/docs/api/harness) `mcpTest()` resolves to a harness with this shape. `createMcpTest()` returns a vitest `test` wrapper instead, whose per-test `mcp` fixture has the same shape. | Member | Returns | | ------------------------------ | ------------------------------------------------------------------------- | | `kind` | `'v1'`, `'v2'`, or `'external'` | | `lifecycle` | the protocol revision this connection is held to, if any | | `client` | the underlying SDK client (escape hatch) | | `listTools()` | every tool, following pagination | | `callTool(name, args?, opts?)` | the tool result - see [call options](#call-options) | | `listResources()` | every resource, following pagination | | `readResource(uri)` | `{ contents }` | | `listPrompts()` | every prompt, following pagination | | `getPrompt(name, args?)` | `{ messages }` | | `complete(ref, argument)` | `{ completion }` - see [completions](/en/docs/api/lifecycles#completions) | | `onSampling(double)` | answers the server's sampling requests | | `onElicitation(double)` | answers the server's elicitation requests | | `onRoots(roots)` | serves `roots/list` (v1 only) | | `notifications(method?)` | a [notification collector](/en/docs/api/notifications) | | `close()` | disconnects; idempotent | Anything the harness does not wrap is one hop away on `mcp.client`. ## Call options [#call-options] `callTool` takes an options bag for progress, cancellation, and timeouts. Each option maps onto the underlying SDK's own request options, so behavior is the SDK's, not a reimplementation. ```ts fragment // progress const seen: number[] = []; await mcp.callTool( "slow", { ms: 200 }, { onProgress: (p) => seen.push(p.progress) }, ); // cancellation const ac = new AbortController(); setTimeout(() => ac.abort(), 50); await expect( mcp.callTool("slow", { ms: 5000 }, { signal: ac.signal }), ).rejects.toThrow(); // timeout await expect( mcp.callTool("slow", { ms: 5000 }, { timeoutMs: 100 }), ).rejects.toThrow(); ``` | Option | Type | | ------------ | --------------------------------------------------------------------- | | `onProgress` | `(p: { progress: number; total?: number; message?: string }) => void` | | `signal` | `AbortSignal` | | `timeoutMs` | `number` | # API reference (/en/docs/api) Everything here builds on one entry point. [`mcpTest()`](/en/docs/api/mcp-test) connects a server and hands back a harness; the rest of the API is what that harness can do and how you assert on it. New to the package? [Getting started](/en/docs/getting-started) is a shorter path in. ## Connecting [#connecting] [`mcpTest(serverOrFactory, options?)`](/en/docs/api/mcp-test) connects a server and returns an `McpHarness`, the entry point every other API builds on. [`McpHarness`](/en/docs/api/harness) covers the methods and properties available on the harness, including the raw SDK client as an escape hatch. ## Asserting [#asserting] [Matchers](/en/docs/api/matchers) are the typed vitest matchers for tools, resources, prompts, and tool results. [Snapshot testing](/en/docs/api/snapshots) snapshots a server's tool, resource, and prompt surface, and validates structured output against its declared schema. ## Answering the server [#answering-the-server] [Testing sampling and elicitation](/en/docs/api/doubles) answers a server's sampling, elicitation, and roots requests from inside a test. [Notifications](/en/docs/api/notifications) collects and waits on notifications a server pushes to the client. ## Where the server runs [#where-the-server-runs] [Testing an external server](/en/docs/api/external-servers) tests a server you cannot import, over stdio or HTTP, with `serveHandler` and `detectServerKind`. [Testing OAuth-protected servers](/en/docs/api/auth) sends bearer credentials with `auth`, and drives the other side of the handshake with a fake authorization server. [Lifecycles](/en/docs/api/lifecycles) runs the same suite across MCP protocol revisions with `createMcpTest`. ## Migrating [#migrating] Upgrading from an older release? [Migration notes](/en/docs/migrating) lists what changed and what to do about it. # Lifecycles (/en/docs/api/lifecycles) 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. ```ts fragment 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 [#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: ```ts notype // 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'` | | ----- | --------------------------------- | ----------------------------------- | | v1 | the only revision it negotiates | throws - the v1 SDK cannot serve it | | v2 | legacy mode; no doubles available | default; full support | | stdio | the only revision it negotiates | throws - stdio is driven by v1 | | url | legacy mode; no doubles available | pinned; 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?)` [#createmcptestserverorfactory-options] Returns a vitest `test` with an `mcp` fixture: a fresh harness per test, closed after each one. Same arguments as `mcpTest`. ```ts 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 [#completions] `complete()` asks the server to complete a prompt argument or a resource-template variable, the same call an editor's autocomplete would make. ```ts fragment 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. # Matchers (/en/docs/api/matchers) mcp-vitest adds these typed matchers for tools, resources, prompts, and results: | Matcher | Asserts | | ---------------------------------- | --------------------------------------------------------------------------- | | `toHaveTool(name)` | the server exposes that tool (suggests near-misses on typos) | | `toHaveResource(uri)` | the server exposes that resource | | `toHavePrompt(name)` | the server exposes that prompt | | `toHaveTextContent(string\|regex)` | a tool result's text content matches | | `toHaveContent(partial)` | some content part matches the given fields (values may be regexes) | | `toMatchOutputSchema(schema?)` | `structuredContent` satisfies the tool's declared schema, or the one passed | | `toBeToolError(string\|regex?)` | a tool result has `isError: true`, optionally matching text | The three server matchers query the live server, so they are async: `await expect(mcp).toHaveTool('x')`. The four result matchers are synchronous. `setupFiles: ['mcp-vitest/setup']` registers all of them. To register them manually instead: ```ts import { registerMatchers } from "mcp-vitest"; registerMatchers(); ``` # mcpTest(serverOrFactory, options?) (/en/docs/api/mcp-test) Connects a server and resolves with an [`McpHarness`](/en/docs/api/harness). Accepts an `McpServer` instance, a factory (sync or async) that returns one, or a spec for a server you cannot import - `{ command, args? }` to spawn one over stdio, or `{ url, headers? }` to reach one over HTTP. See [testing an external server](/en/docs/api/external-servers). ```ts import { mcpTest } from "mcp-vitest"; import { createServer } from "./server.js"; const mcp = await mcpTest(() => createServer()); ``` | Option | Default | Meaning | | ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoClose` | `true` | Close the harness via vitest's `onTestFinished` when inside a test. | | `protocolVersion` | `'2026-07-28'` (v2) | Hold the connection to one protocol revision - see [lifecycles](/en/docs/api/lifecycles). | | `auth` | - | Send bearer credentials to a URL-connected server - see [testing OAuth-protected servers](/en/docs/api/auth). Throws on a stdio or in-process server, which cannot send one. | With `autoClose: false` you own the lifetime and call `mcp.close()` yourself. Outside a vitest test context, auto-close is skipped and closing is always yours. ## What a connection costs [#what-a-connection-costs] Connecting per test rather than sharing one harness is the point - each test gets a clean server - and it is cheap enough not to think about. From the package's own `npm run bench`, a fresh server plus a full `initialize` handshake costs **0.24ms on SDK v1** and **0.47ms on v2** (means of 8,270 and 4,284 samples, +/-2.3% and +/-3.6%). There is no process to fork and no socket to open, so the cost is a few function calls and the SDK's own handshake. # Notifications (/en/docs/api/notifications) `mcp.notifications(method?)` starts collecting immediately and returns a collector. Pass a method to filter. ```ts fragment const progress = mcp.notifications("notifications/progress"); await mcp.callTool("slow", { ms: 100 }); expect(progress.items).toHaveLength(10); // or wait for one that matters const collector = mcp.notifications("notifications/progress"); const halfway = collector.waitFor( (n) => (n.params as { progress: number }).progress >= 5, ); await mcp.callTool("slow", { ms: 300 }); await expect(halfway).resolves.toMatchObject({ method: "notifications/progress", }); ``` Each item is `{ method, params, at }`, where `at` is milliseconds since the collector was created. `waitFor(predicate, timeoutMs = 5000)` resolves with the first match - including one already collected - and abandons pending waiters on close, so a timeout never surfaces against a later test. A progress token is attached only when you pass `onProgress` or a collector is listening, so a bare `callTool` leaves the request untouched and your server's no-token path stays testable. Params arrive as `{ progress, total?, message? }`; the SDK consumes the token first, so items from two *concurrent* calls to the same tool cannot be told apart - await one at a time when you need to attribute them. **v2 servers collect progress only.** The 2026-07-28 lifecycle is stateless, so there is no persistent server to push `list_changed` from, and the SDK's server side does not emit it - a `subscriptions/listen` for it succeeds but honours nothing. A gap upstream rather than one the harness withholds; it will be wired up when the SDK sends those notifications. v1 servers collect everything the client receives. # Snapshot testing (/en/docs/api/snapshots) Manifest helpers return normalized, deep-sorted objects - stable across runs, with entry-level `_meta` and `undefined` values dropped - so vitest's built-in snapshots catch unintended API changes to your server. A manifest records what the server actually reports over the wire, which includes descriptor fields your MCP SDK adds for you (for example `execution` or a `$schema` on generated schemas). An SDK upgrade can therefore move a snapshot without your server changing; review the diff and update it as you would any snapshot. Capabilities your server does not expose come back empty rather than failing, so a tools-only server snapshots cleanly. ```ts import { expect } from "vitest"; import { createMcpTest } from "mcp-vitest"; import { capabilitiesManifest, toolManifest } from "mcp-vitest/snapshot"; import { createServer } from "./server.js"; const test = createMcpTest(() => createServer()); test("tool surface is unchanged", async ({ mcp }) => { expect(await toolManifest(mcp)).toMatchSnapshot(); }); test("capabilities are unchanged", async ({ mcp }) => { // { tools: string[], resources: string[], prompts: string[] }, names only expect(await capabilitiesManifest(mcp)).toMatchSnapshot(); }); ``` `toolManifest`, `resourceManifest`, `promptManifest`, and `capabilitiesManifest` are available from `mcp-vitest/snapshot` or the package root. Each returns its entries typed rather than as `unknown`, so a manifest can be asserted on directly - `(await toolManifest(mcp)).map((t) => t.name)` needs no cast. ## Structured output [#structured-output] `toMatchOutputSchema()` validates a result's `structuredContent` against the schema the tool declared in `tools/list`. Pass a JSON Schema explicitly to validate against something else. ```ts fragment const result = await mcp.callTool("weather"); expect(result).toMatchOutputSchema(); expect(result).toMatchOutputSchema({ type: "object", properties: { temperature: { type: "number" }, unit: { type: "string" } }, required: ["temperature", "unit"], }); ``` Note that both SDK majors validate declared output schemas server-side: if a tool's output violates its own schema, the call comes back as a tool error (`toBeToolError()`) rather than delivering invalid `structuredContent`. The explicit-schema form is what you want for asserting a contract the tool does not declare.