Skip to main content

api-testing

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.

الانتقال إلى التثبيت

معلومات المصدر

المستودع
cyanheads/mcp-ts-core
آخر نشاط في المصدر
١٤ سبتمبر ٢٠٢٦ في ٠٢:٢٧
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
١٥١
التفرعات
٢٩

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub