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/obsidian-mcp-server
最近来源活动
2026年9月19日 15:47
检测到的 SKILL.md 语言
英语
星标
687
分支
104

安装方式

默认使用会先检查来源的 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 查看