| 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.9","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.
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.
import { createMockContext } from '@cyanheads/mcp-ts-core/testing/vitest';
const tenantTest = mcpTest.extend({
ctx: async ({}, use) => { await use(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.
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.
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.
createMockContext options
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
createMockContext()
createMockContext({ tenantId: 'test-tenant' })
createMockContext({ errors: myTool.errors })
createMockContext({ inputResponses: { confirm: { action: 'accept', content: { ok: true } } } })
createMockContext({ requestState: 'opaque-state' })
createMockContext({ requestId: 'my-id' })
createMockContext({ notifyResourceListChanged: () => {} })
createMockContext({ notifyResourceUpdated: (_uri) => {} })
createMockContext({ signal: controller.signal })
createMockContext({ auth: { clientId: 'test', scopes: [], sub: 'test-user' } })
createMockContext({ uri: new URL('myscheme://item/123') })
MockContextOptions interface:
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.
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.
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:
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:
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):
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
import { describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';