| 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.6","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({ elicit: vi.fn().mockResolvedValue(...) })
createMockContext({ progress: true })
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 {
auth?: AuthContext;
elicit?: (message: string, schema: z.ZodObject<z.ZodRawShape>) => Promise<ElicitResult>;
errors?: readonly ErrorContract[];
notifyPromptListChanged?: () => void;
notifyResourceListChanged?: () => void;
notifyResourceUpdated?: (uri: string) => void;
notifyToolListChanged?: () => void;
progress?: boolean;
sessionId?: string;
requestId?: string;
signal?: AbortSignal;
tenantId?: string;
uri?: URL;
}
| Option | Effect |
|---|
| (none) | Minimal context — ctx.state operations throw without tenantId; ctx.elicit/ctx.progress are undefined |
auth | Sets ctx.auth for scope-checking tests |
elicit | Assigns a function to ctx.elicit for testing elicitation calls |
errors | Attaches a typed ctx.fail against the contract — same wiring the production handler factory uses. Pass myTool.errors directly. |
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 |
sessionId | Sets ctx.sessionId for handlers that branch on session ID |
progress | Populates ctx.progress with real state-tracking implementation (see below) |
requestId | Overrides ctx.requestId (default: 'test-request-id') |
signal | Overrides ctx.signal — useful for cancellation testing |
tenantId | Sets ctx.tenantId and enables ctx.state operations with in-memory storage |
uri | Sets ctx.uri for resource handler testing |
Mock progress
When progress: true, ctx.progress is a real state-tracking object — not vi.fn() spies. It maintains internal state accessible via inspection properties:
const ctx = createMockContext({ progress: true });
const progress = ctx.progress as ContextProgress & {
_total: number;
_completed: number;
_messages: string[];
};
await ctx.progress!.setTotal(10);
await ctx.progress!.increment(3);
await ctx.progress!.update('step message');
expect(progress._total).toBe(10);
expect(progress._completed).toBe(3);
expect(progress._messages).toContain('step message');
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';
import { myTool } from '@/mcp-server/tools/definitions/my-tool.tool.js';
describe('myTool', () => {
it('returns expected output', async () => {
const ctx = createMockContext();
const input = myTool.input.parse({ query: 'hello' });
const result = await myTool.handler(input, ctx);
expect(result.result).toBe('Found: hello');
});
it('throws on invalid state', async () => {
const ctx = createMockContext();
const input = myTool.input.parse({ query: 'TRIGGER_ERROR' });
await expect(myTool.handler(input, ctx)).rejects.toThrow();
});
it('formats response completely', () => {
const result = { result: 'test' };
const blocks = myTool.format!(result);
expect(blocks[0].type).toBe('text');
expect((blocks[0] as { text?: string }).text).toContain('test');
});
});
Parse input through myTool.input.parse(...) to validate against the Zod schema and produce the typed input the handler expects. Call myTool.handler(input, ctx) directly, not through the MCP SDK or any framework wrapper. Assert on the return value for happy paths; use .rejects.toThrow() for error paths. Test format separately if the tool defines one — it's a pure function and needs no ctx. Verify the rendered text includes the fields the LLM needs, and for projection-style tools, add a case with non-default field selections.
Testing with optional capabilities
it('uses elicitation when available', async () => {
const elicit = vi.fn().mockResolvedValue({
action: 'accept',
content: { format: 'json' },
});
const ctx = createMockContext({ elicit });
const input = myTool.input.parse({ query: 'hello' });
await myTool.handler(input, ctx);
expect(elicit).toHaveBeenCalledOnce();
});
it('handles missing elicitation gracefully', async () => {
const ctx = createMockContext();
const input = myTool.input.parse({ query: 'hello' });
await expect(myTool.handler(input, ctx)).resolves.toBeDefined();
});
Testing with form-based client payloads
LLM clients only send populated fields. Form-based clients (MCP Inspector, web UIs) submit the full schema shape — optional object fields arrive with empty-string inner values instead of undefined. Both are valid MCP usage. Test that handlers handle both gracefully.