- name
- api-testing
- description
- Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
- metadata
- {"author":"cyanheads","version":"1.13","audience":"external","type":"reference"}
## Overview
Tests target handler behavior directly — call `handler(input, ctx)`, assert on the return value or thrown error. The framework's handler factory (try/catch, formatting, telemetry) is not involved. Use `createMockContext` from `@cyanheads/mcp-ts-core/testing` to construct the `ctx` argument.
**Additional exports from `/testing`:** `createMockSession()` binds a mock handler context to an HTTP session; `createFetchMock()` provides a strict upstream HTTP fake; `runToolContract()` executes a definition through schema, handler, formatting, enrichment/content, and production-shaped error-envelope checks. `createMockLogger()` returns a standalone `MockContextLogger`, `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`, and `expectInputRequired(run)` returns the `input_required` result a multi-round-trip handler asked for (see [Mock inputs](#mock-inputs)).
**Philosophy:** Test behavior, not implementation. Refactors should not break tests. Match the repo's existing test layout: fresh scaffolds use `tests/`, while colocated `src/**/*.test.ts` files are also supported. Integration tests at I/O boundaries over unit tests of internals.
---
## `mcpTest` — fixture-based Vitest test
`mcpTest` is a `test.extend`-based Vitest test that provides `ctx`, `session`, `fetchMock`, and `storage` as **per-test fixtures** — fresh instances for every test, eliminating boilerplate and enforcing isolation automatically. `fetchMock` is installed as `globalThis.fetch` only when requested by a test and restored afterward.
```ts
import { mcpTest } from '@cyanheads/mcp-ts-core/testing/vitest';
mcpTest('echoes the message', async ({ ctx }) => {
const result = await echoTool.handler(echoTool.input.parse({ message: 'hi' }), ctx);
expect(result.message).toBe('hi');
});
mcpTest('uses storage fixture', async ({ ctx, storage }) => {
const svc = new MyService(config, storage);
const result = await svc.doWork(ctx);
expect(result).toBeDefined();
});
mcpTest('stubs an upstream HTTP boundary', async ({ fetchMock }) => {
fetchMock.route({
match: 'https://api.example.test/items/42',
respond: Response.json({ id: '42' }),
});
await expect(loadItem('42')).resolves.toMatchObject({ id: '42' });
});
```
### Fixtures
| Fixture | Type | Per-test? | Notes |
|:--------|:-----|:----------|:------|
| `ctx` | `Context` | Yes | Fresh `createMockContext()` each test |
| `session` | `MockSession` | Yes | Fresh `{ sessionId, tenantId, ctx }` from `createMockSession()` |
| `fetchMock` | `FetchMockHarness` | Yes | Strict fetch fake installed/restored around the requesting test |
| `storage` | `StorageService` | Yes | Fresh `createInMemoryStorage()` each test |
### Extending with the function form
Override fixtures using the **function form** (`async ({}, use) => { ... }`) to preserve per-test freshness. A bare-value override shares one mutable instance across the entire file — defeating the fixture's isolation guarantee.
```ts
import { createMockContext } from '@cyanheads/mcp-ts-core/testing/vitest';
// Correct — function form gives each test a fresh context:
const tenantTest = mcpTest.extend({
ctx: async ({}, use) => { await use(createMockContext({ tenantId: 'test-tenant' })); },
});
// Wrong — bare value shares one ctx across every test in the file:
// const tenantTest = mcpTest.extend({ ctx: createMockContext({ tenantId: 'test-tenant' }) });
```
The portable `/testing` helpers are re-exported from `@cyanheads/mcp-ts-core/testing/vitest` so fixture overrides don't need a second import.
---
## Upstream HTTP testing with `createFetchMock`
Use the fetch harness at real outbound I/O boundaries. Stub the external service, not server-owned services or handlers.
```ts
import { createFetchMock } from '@cyanheads/mcp-ts-core/testing';
const http = createFetchMock([
{
method: 'GET',
match: 'https://api.example.test/items/42',
respond: Response.json({ id: '42', name: 'Example' }),
},
]);
http.install();
try {
await expect(loadItem('42')).resolves.toEqual({ id: '42', name: 'Example' });
expect(http.calls[0]?.request.url).toBe('https://api.example.test/items/42');
} finally {
http.restore();
}
```
Routes match in registration order. `match` accepts an exact URL, `RegExp`, or request predicate; `respond` accepts a static `Response` or a response factory. A static response's body is read once, on the route's first match, and every call is served a fresh `Response` over those bytes with the same `status`, `statusText`, and headers — so a consumer that cancels the body, or an error-body reader like `httpErrorFromResponse` that stops past its cap, settles on Node as on Bun. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
**A request predicate routes on the URL's origin, never a prefix.** `req.url.startsWith(BASE_URL)` also matches a lookalike host (`https://api.example.test.evil.com/...`), which CodeQL reports as high-severity incomplete URL substring sanitization — it scans test files as readily as `src/`, so a suite that is green locally still fails the security check on a pull request. Parse the URL and compare origins, matching the path separately:
```ts
match: (req) => {
const url = new URL(req.url);
return url.origin === new URL(BASE_URL).origin && url.pathname.startsWith('/items/');
},
```
---
## Tool conformance with `toolContractSuite`
Point the reusable suite at a definition plus representative success and failure inputs. It checks input/output schemas, invokes the real handler, applies formatting/enrichment/content, and validates both public error surfaces.
```ts
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
import { toolContractSuite } from '@cyanheads/mcp-ts-core/testing/vitest';
toolContractSuite(searchTool, {
success: [{ name: 'returns matches', input: { query: 'mcp' } }],
errors: [{
name: 'reports an empty query',
input: { query: '' },
code: JsonRpcErrorCode.InvalidParams,
reason: 'empty_query',
}],
});
```
Use `runToolContract(definition, input, { context })` from `/testing` when a custom test runner or an imperative assertion is a better fit. It intentionally skips transport auth and telemetry; those belong in transport/integration tests.
A declared reason thrown without a hint — a bare `ctx.fail('reason')` or a service throw carrying `{ reason }` — comes back with the entry's `recovery` as `data.recovery.hint` and a `Recovery:` line in `content[]`, as in production. The one production field it leaves out is `data.requestId` (and the `request <id>` term closing `content[]`), since there is no real request; a test asserting the factory's envelope instead expects both. Calling `definition.handler(...)` directly returns the `McpError` exactly as the throw site built it — no fill, no request id.
Arguments that fail the `input` schema are rejected the way the production handler factory rejects them: `InvalidParams` (`-32602`), with a message naming the tool and every failing field. That is the code a client sees on the wire, so assert it — not `ValidationError` (`-32007`), which stays the classification for a `ZodError` a handler throws itself. A result that breaks the tool's own `output` or `enrichment` schema is the definition's bug, so it returns `InternalError` (`-32603`) with a message naming that contract, exactly as in production.
Cancellation settles as it does in production. Pass `context: { signal }` and abort it: once the signal has fired, whatever the handler — or the output validation, `format()`, and enrichment after it — throws comes back as `RequestCancelled` (`-32011`), whether that is the signal's `AbortError`, its reason string, a `withRetry` backoff that stopped, or an `McpError` of the handler's own. A throw while the signal is still live keeps its own classification, and argument parsing stays outside the settle, so schema-invalid arguments on an aborted signal still return `InvalidParams`. A `toolContractSuite` error case with an aborted `context.signal` asserts `code: JsonRpcErrorCode.RequestCancelled` the same way.
---
## `createMockContext` options
```ts
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
createMockContext() // working ctx.state on tenant 'default'
createMockContext({ tenantId: 'test-tenant' }) // explicit tenant scope for ctx.state
createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
createMockContext({ inputResponses: { confirm: { action: 'accept', content: { ok: true } } } }) // second round of a multi-round-trip handler
createMockContext({ requestState: 'opaque-state' }) // seeds ctx.inputs.state()
createMockContext({ clientCapabilities: { roots: {} } }) // seeds ctx.clientCapabilities and filters inputResponses to declared kinds
createMockContext({ requestId: 'my-id' }) // override request ID (default: 'test-request-id')
createMockContext({ notifyResourceListChanged: () => {} }) // with resource-list change notifier
createMockContext({ notifyResourceUpdated: (_uri) => {} }) // with resource update notifier
createMockContext({ signal: controller.signal }) // custom AbortSignal
createMockContext({ auth: { clientId: 'test', scopes: [], sub: 'test-user' } }) // with auth context
createMockContext({ uri: new URL('myscheme://item/123') }) // for resource handler testing
```
`MockContextOptions` interface:
```ts
interface MockContextOptions<TErrors extends readonly ErrorContract[] | undefined> {
auth?: AuthContext;
clientCapabilities?: ClientCapabilities;
errors?: TErrors | undefined;
inputResponses?: InputResponses | Record<string, unknown>;
notifyPromptListChanged?: () => void;
notifyResourceListChanged?: () => void;
notifyResourceUpdated?: (uri: string) => void;
notifyToolListChanged?: () => void;
requestId?: string;
requestState?: unknown;
sessionId?: string;
signal?: AbortSignal;
tenantId?: string;
uri?: URL;
}
```
| Option | Effect |
|:-------|:-------|
| _(none)_ | Working `ctx.state` on tenant `'default'`; `ctx.inputs` is empty (first round) |
| `auth` | Sets `ctx.auth` for scope-checking tests |
| `clientCapabilities` | Sets `ctx.clientCapabilities` (`undefined` when omitted) and applies the production filter to `inputResponses`: only the answers these capabilities cover reach `ctx.inputs` (elicit → `elicitation`, and `elicitation.form` when it carries `content`; sampling → `sampling`, and `sampling.tools` when it holds a `tool_use` / `tool_result` block; roots → `roots`). Omitted, every seeded response reaches `ctx.inputs`, so existing `{ inputResponses }` tests are unaffected. The mock's `ctx.requestInput` stays ungated either way |
| `errors` | Attaches a typed `ctx.fail` against the contract — same wiring the production handler factory uses. Pass `myTool.errors` directly; the return type narrows to `HandlerContext<ReasonOf<…>>`, so the context is assignable to that definition's handler parameter. |
| `inputResponses` | Seeds `ctx.inputs` with the responses a retried request would carry, keyed by the identifiers the handler's `ctx.requestInput(...)` assigned (see below) |
| `notifyPromptListChanged` | Assigns `ctx.notifyPromptListChanged` for prompt-list change notification tests |
| `notifyResourceListChanged` | Assigns `ctx.notifyResourceListChanged` for resource notification tests |
| `notifyResourceUpdated` | Assigns `ctx.notifyResourceUpdated` for resource update notification tests |
| `notifyToolListChanged` | Assigns `ctx.notifyToolListChanged` for tool-list change notification tests |
| `requestId` | Overrides `ctx.requestId` (default: `'test-request-id'`) |
| `requestState` | Seeds `ctx.inputs.state()` — the opaque state a prior round attached |
| `sessionId` | Sets `ctx.sessionId` for handlers that branch on session ID |
| `signal` | Overrides `ctx.signal` — useful for cancellation testing |
| `tenantId` | Scopes `ctx.state` to a specific tenant. Defaults to `'default'` — the value stdio (and HTTP with `MCP_AUTH_MODE=none`) resolves |
| `uri` | Sets `ctx.uri` for resource handler testing |
### Mock state
`ctx.state` is a real `StorageService` over an `InMemoryProvider` — the production storage path, not a `Map`. A test therefore sees the same rules a deployed server enforces:
- **Keys** match `^[a-zA-Z0-9_.\-/]+$` and may not contain `..`. Colons are rejected, so `cache:v1:abc` throws `McpError(ValidationError)` in the test exactly as it would in a deployment; use `cache/v1/abc`.
- **Values** round-trip as JSON, as on every persistent provider. A read returns a fresh object in its JSON form — a `Date` reads back as its ISO string — so a test cannot pass on identity or on a `Date`/`Map` surviving storage. A value JSON cannot encode (`bigint`, a cyclic reference, a top-level `undefined`, function, or symbol) rejects with `McpError(SerializationError)`.
- **TTL** is honored. An entry written with `{ ttl: 30 }` reads back as `null` once 30 seconds elapse — drive the clock with `vi.useFakeTimers()` to assert expiry.
- **`getMany` / `setMany` / `deleteMany` / `list`** validate every key and prefix, and `list` paginates with the same opaque cursors.
- **Cancellation** applies: once `ctx.signal` aborts, state operations reject.
```ts
const ctx = createMockContext();
await ctx.state.set('cache/v1/abc', { hits: 1 }, { ttl: 30 });
await expect(ctx.state.get('cache/v1/abc')).resolves.toEqual({ hits: 1 });
await expect(ctx.state.set('cache:v1:abc', {})).rejects.toThrow(McpError);
await ctx.state.set('seen/abc', { at: new Date('2026-01-01T00:00:00Z') });
await expect(ctx.state.get('seen/abc')).resolves.toEqual({ at: '2026-01-01T00:00:00.000Z' });
```
Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
### Mock inputs
View on GitHub