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.

Quellinformationen

Repository
cyanheads/obsidian-mcp-server
Letzte Quellaktivität
19. September 2026 um 15:47
Erkannte Sprache von SKILL.md
Englisch
Sterne
687
Forks
104

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
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.14","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`, `createInMemoryStorage(options?)` provides a real `StorageService` backed by `InMemoryProvider`, and `expectInputRequired(run)` returns the `input_required` result a multi-round-trip handler asked for (see [Mock inputs](#mock-inputs)). **Other testing subpaths:** `/testing/vitest` (fixtures and conformance suites, below), `/testing/fuzz` (see [Fuzz testing](#fuzz-testing)), and `/testing/apps`, whose `renderAppTool` renders an app tool's `ui://` view against your server (a stdio command or an HTTP URL) in a headless MCP Apps host and reports initialization, errors, CSP violations, the view↔host messages, and screenshots. It needs the optional peers `@modelcontextprotocol/client` and `@modelcontextprotocol/ext-apps` plus a `chrome-headless-shell` build; the `field-test` skill covers installing one and reading the report. **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 static `Response` or a response factory. A static response's body is read once, on the route's first match, and every call is served a fresh `Response` over those bytes with the same `status`, `statusText`, and headers — so a consumer that cancels the body, or an error-body reader like `httpErrorFromResponse` that stops past its cap, settles on Node as on Bun. Set `once: true` for one-shot behavior. Unmatched requests throw unless `onUnhandled` is provided. **A request predicate routes on the URL's origin, never a prefix.** `req.url.startsWith(BASE_URL)` also matches a lookalike host (`https://api.example.test.evil.com/...`), which CodeQL reports as high-severity incomplete URL substring sanitization — it scans test files as readily as `src/`, so a suite that is green locally still fails the security check on a pull request. Parse the URL and compare origins, matching the path separately: ```ts match: (req) => { const url = new URL(req.url); return url.origin === new URL(BASE_URL).origin && url.pathname.startsWith('/items/'); }, ``` --- ## 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. A declared reason thrown without a hint — a bare `ctx.fail('reason')` or a service throw carrying `{ reason }` — comes back with the entry's `recovery` as `data.recovery.hint` and a `Recovery:` line in `content[]`, as in production. The one production field it leaves out is `data.requestId` (and the `request <id>` term closing `content[]`), since there is no real request; a test asserting the factory's envelope instead expects both. Calling `definition.handler(...)` directly returns the `McpError` exactly as the throw site built it — no fill, no request id. 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. A result that breaks the tool's own `output` or `enrichment` schema is the definition's bug, so it returns `InternalError` (`-32603`) with a message naming that contract, exactly as in production. Cancellation settles as it does in production. Pass `context: { signal }` and abort it: once the signal has fired, whatever the handler — or the output validation, `format()`, and enrichment after it — throws comes back as `RequestCancelled` (`-32011`), whether that is the signal's `AbortError`, its reason string, a `withRetry` backoff that stopped, or an `McpError` of the handler's own. A throw while the signal is still live keeps its own classification, and argument parsing stays outside the settle, so schema-invalid arguments on an aborted signal still return `InvalidParams`. A `toolContractSuite` error case with an aborted `context.signal` asserts `code: JsonRpcErrorCode.RequestCancelled` the same way. **Resources have no contract runner.** A resource definition's `handler(...)` called directly returns the throw site's `McpError` with no recovery fill, so asserting a resource's declared `errors[]` hints needs a path through the resource factory. Serve the definition from `createWorkerHandler({ name, title, resources: [def] })` (`/worker`) and `fetch` it a `resources/read` JSON-RPC request carrying the current protocol revision in both the `MCP-Protocol-Version` header and `params._meta`. The response is plain JSON or an SSE `data:` frame, and its `error` carries `code`, `data.reason`, and the filled `data.recovery.hint`. --- ## `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({ clientCapabilities: { roots: {} } }) // seeds ctx.clientCapabilities and filters inputResponses to declared kinds 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; clientCapabilities?: ClientCapabilities; 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 | | `clientCapabilities` | Sets `ctx.clientCapabilities` (`undefined` when omitted) and applies the production filter to `inputResponses`: only the answers these capabilities cover reach `ctx.inputs` (elicit → `elicitation`, and `elicitation.form` when it carries `content`; sampling → `sampling`, and `sampling.tools` when it holds a `tool_use` / `tool_result` block; roots → `roots`). Omitted, every seeded response reaches `ctx.inputs`, so existing `{ inputResponses }` tests are unaffected. The mock's `ctx.requestInput` stays ungated either way | | `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`. - **Values** round-trip as JSON, as on every persistent provider. A read returns a fresh object in its JSON form — a `Date` reads back as its ISO string — so a test cannot pass on identity or on a `Date`/`Map` surviving storage. A value JSON cannot encode (`bigint`, a cyclic reference, a top-level `undefined`, function, or symbol) rejects with `McpError(SerializationError)`. - **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.
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen