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/crossref-mcp-server
ソースの最終更新活動
2026年8月7日 02:27
検出された SKILL.md の言語
英語
スター
3
フォーク
0

インストール方法

デフォルトでは、最初にソースを確認する 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.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で見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る