- 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.
```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.
---
## `createMockContext` options
```ts
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
createMockContext() // minimal — ctx.state operations throw without tenantId
createMockContext({ tenantId: 'test-tenant' }) // enables ctx.state (tenant-scoped in-memory storage)
createMockContext({ errors: myTool.errors }) // attaches typed ctx.fail keyed by the contract reasons
createMockContext({ elicit: vi.fn().mockResolvedValue(...) }) // with elicitation
createMockContext({ progress: true }) // with task progress (ctx.progress populated)
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 {
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:
```ts
const ctx = createMockContext({ progress: true });
// ctx.progress is typed as ContextProgress, but the mock exposes internal state:
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`):
```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
// tests/tools/my-tool.tool.test.ts
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
```ts
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 () => {
// ctx.elicit is undefined — handler must check before calling
const ctx = createMockContext();
const input = myTool.input.parse({ query: 'hello' });
// Should not throw even when ctx.elicit is absent
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.
```ts
عرض على GitHub