- 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.10","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`, and `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`.
**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 clonable `Response` or response factory. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided.
---
## 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.
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 and for an output-schema rejection.
---
## `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({ 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;
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 |
| `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`.
- **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);
```
Reach for `createInMemoryStorage()` when a service takes a `StorageService` directly — it builds the same pair.
### Mock inputs
`ctx.requestInput` is the real implementation: it throws an `InputRequiredSignal` the production handler factories convert into an `input_required` result. In a unit test the handler is called directly, so that signal surfaces as a thrown value — which is exactly how you assert the first round.
```ts
import { isInputRequiredSignal } from '@cyanheads/mcp-ts-core';
it('asks for confirmation on the first round', async () => {
const ctx = createMockContext();
await expect(myTool.handler(myTool.input.parse({ path: '/tmp/x' }), ctx))
.rejects.toSatisfy(isInputRequiredSignal);
});
```
To assert on *what* was requested, catch it and read `error.result` — the `input_required` result the handler factory would have returned:
```ts
async function requestedInput(input: ToolInput, options: MockContextOptions = {}) {
try {
await myTool.handler(input, createMockContext(options));
} catch (error) {
if (isInputRequiredSignal(error)) return error.result;
throw error;
}
throw new Error('Expected the handler to request input.');
}
```
`inputResponses` drives the second round. `ctx.inputs.accepted(key, schema)` and `.view(key)` read it with the same helpers production uses, so a wrong response shape fails in the test:
```ts
it('proceeds once the user accepts', async () => {
const ctx = createMockContext({
inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
});
await expect(myTool.handler(input, ctx)).resolves.toMatchObject({ deleted: '/tmp/x' });
});
it('stops when the user declines', async () => {
const ctx = createMockContext({
inputResponses: { confirm: { action: 'decline' } },
});
await expect(myTool.handler(input, ctx)).rejects.toThrow(McpError);
});
```
`ctx.inputs.dropped` is always `[]` on a mock context — the drop only happens in the SDK's wire decoding, so cover it in an integration test rather than a unit one.
### Mock logger
`ctx.log` captures all log calls for inspection. Import `MockContextLogger` from `@cyanheads/mcp-ts-core/testing` and cast `ctx.log` to access the `.calls` array (the cast is necessary because `createMockContext` returns `Context`, which types `log` as `ContextLogger`):
```ts
import { createMockContext, type MockContextLogger } from '@cyanheads/mcp-ts-core/testing';
const ctx = createMockContext();
const log = ctx.log as MockContextLogger;
await myTool.handler(input, ctx);
expect(log.calls.some(c => c.level === 'info' && c.msg.includes('Processing'))).toBe(true);
```
---
## Full test example
```ts
عرض على GitHub