| name | add-test |
| description | Scaffold a test file for an existing tool, resource, or service. Use when the user asks to add tests, improve coverage, or when a definition exists without a matching test file.
|
| metadata | {"author":"cyanheads","version":"1.6","audience":"external","type":"reference"} |
Context
Tests use Vitest and createMockContext from @cyanheads/mcp-ts-core/testing. If the repo already has tests, match the existing layout. If the repo has no existing tests, create a root tests/ directory that mirrors the src/ structure (e.g. tests/mcp-server/tools/definitions/echo.tool.test.ts for src/mcp-server/tools/definitions/echo.tool.ts).
For the full createMockContext API and testing patterns, read:
skills/api-testing/SKILL.md
Steps
- Identify the target — which tool, resource, or service needs tests
- Read the source file — understand the handler's logic, input/output schemas, error paths, and which
ctx features it uses
- Create the test file in the repo's existing test layout — search for existing
*.test.ts files to confirm whether tests are colocated with source or under a root tests/ directory
- Write test cases covering happy path, error paths, and edge cases
- Run
bun run test to verify
- Run
bun run devcheck to verify lint, types, and MCP definitions
Determining What to Test
Read the handler and identify:
| Aspect | Test Strategy |
|---|
| Happy path | Valid input → expected output. Include at least one. |
| Input variations | Optional fields omitted, defaults applied, boundary values |
| Error paths | Invalid state, missing resources, service failures → correct error thrown |
ctx.state usage | Available on any mock context (tenant 'default' unless { tenantId } says otherwise). It runs the production storage path, so use storage-legal keys (cache/v1/abc, never cache:v1:abc) and assert TTL expiry with fake timers. |
ctx.requestInput / ctx.inputs | Two rounds. First round: assert the handler throws the input-required signal (.rejects.toSatisfy(isInputRequiredSignal)), or catch it and assert on error.result.inputRequests. Second round: seed createMockContext({ inputResponses }) and assert the handler completes. Cover the decline/cancel branch too. |
ctx.signal | Pass createMockContext({ signal: controller.signal }) and assert a long loop stops early rather than running to completion. |
ctx.fail (typed contract) | Definitions with errors[] need fail attached to the mock ctx — createMockContext({ errors: myTool.errors }) does it for you. Assert on data.reason (stable per-contract entry), not just code. |
format function | Test separately if defined — it's pure, no ctx needed. Verify it renders the IDs and fields the model needs, not just a count or title. For projection-style tools, test non-default field selections. |
| Sparse upstream payloads | For third-party API integrations, build a fixture with omitted fields. Assert normalized output still validates and format() preserves unknown values instead of inventing facts. |
| Form-client payloads | If handler has optional fields: test with empty-string inner values (form clients send "" instead of undefined). Assert handler doesn't break or produce invalid output. |
| Auth scopes | Not tested at handler level (framework enforces) — skip |
Templates
Tool test
import { describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { {{TOOL_EXPORT}} } from '@/mcp-server/tools/definitions/{{tool-name}}.tool.js';
describe('{{TOOL_EXPORT}}', () => {
it('returns expected output for valid input', async () => {
const ctx = createMockContext();
const input = {{TOOL_EXPORT}}.input.parse({
});
const result = await {{TOOL_EXPORT}}.handler(input, ctx);
expect(result).toMatchObject({
});
});
it('throws on invalid state', async () => {
const ctx = createMockContext();
const input = {{TOOL_EXPORT}}.input.parse({
});
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toThrow();
});
it('throws ctx.fail("{{REASON}}") for the declared failure mode', async () => {
const ctx = createMockContext({ errors: {{TOOL_EXPORT}}.errors });
const input = {{TOOL_EXPORT}}.input.parse({
});
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toMatchObject({
data: { reason: '{{REASON}}' },
});
});
it('formats output completely', () => {
const output = { };
const blocks = {{TOOL_EXPORT}}.format!(output);
expect(blocks.some((block) => block.type === 'text')).toBe(true);
});
});
Resource test
import { describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { {{RESOURCE_EXPORT}} } from '@/mcp-server/resources/definitions/{{resource-name}}.resource.js';
describe('{{RESOURCE_EXPORT}}', () => {
it('returns data for valid params', async () => {
const ctx = createMockContext({ tenantId: 'test-tenant' });
const params = {{RESOURCE_EXPORT}}.params.parse({
});
const result = await {{RESOURCE_EXPORT}}.handler(params, ctx);
expect(result).toBeDefined();
});
it('throws when resource not found', async () => {
const ctx = createMockContext({ tenantId: 'test-tenant' });
const params = {{RESOURCE_EXPORT}}.params.parse({
});
await expect({{RESOURCE_EXPORT}}.handler(params, ctx)).rejects.toThrow();
});
it('lists available resources', async () => {
const listing = await {{RESOURCE_EXPORT}}.list!();
expect(listing.resources).toBeInstanceOf(Array);
expect(listing.resources.length).toBeGreaterThan(0);
for (const r of listing.resources) {
expect(r).toHaveProperty('uri');
expect(r).toHaveProperty('name');
}
});
});
Service test
import { beforeEach, describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { StorageService } from '@cyanheads/mcp-ts-core/storage';
import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
const mockConfig = { } as AppConfig;
describe('{{ServiceClass}}', () => {
beforeEach(async () => {
const mockStorage = await StorageService.create({ type: 'in-memory' });
init{{ServiceClass}}(mockConfig, mockStorage);
});
it('performs the expected operation', async () => {
const ctx = createMockContext({ tenantId: 'test-tenant' });
const service = get{{ServiceClass}}();
const result = await service.doWork('input', ctx);
expect(result).toBeDefined();
});
});
If you need to test the accessor's "not initialized" guard, do it in a separate isolated-module test (vi.resetModules() before importing the service module). Don't mix that assertion into a suite that already calls init{{ServiceClass}}() in beforeEach().
Multi-round-trip tool test
A handler that calls ctx.requestInput(...) throws an InputRequiredSignal — in production the handler factory converts it to an input_required result; in a unit test it surfaces as a thrown value. Test both rounds.
import { isInputRequiredSignal } from '@cyanheads/mcp-ts-core';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
it('requests the missing input on the first round', async () => {
const ctx = createMockContext();
const input = {{TOOL_EXPORT}}.input.parse({ path: '/tmp/x' });
try {
await {{TOOL_EXPORT}}.handler(input, ctx);
throw new Error('Expected the handler to request input.');
} catch (error) {
if (!isInputRequiredSignal(error)) throw error;
expect(Object.keys(error.result.inputRequests ?? {})).toEqual(['confirm']);
}
});
it('completes once the response is supplied', async () => {
const ctx = createMockContext({
inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
});
const input = {{TOOL_EXPORT}}.input.parse({ path: '/tmp/x' });
await expect({{TOOL_EXPORT}}.handler(input, ctx)).resolves.toMatchObject({ deleted: '/tmp/x' });
});
it('does not re-ask after a decline', async () => {
const ctx = createMockContext({ inputResponses: { confirm: { action: 'decline' } } });
const input = {{TOOL_EXPORT}}.input.parse({ path: '/tmp/x' });
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toThrow(McpError);
});
Cancellation test
it('respects cancellation', async () => {
const controller = new AbortController();
const ctx = createMockContext({ signal: controller.signal });
const input = {{TOOL_EXPORT}}.input.parse({ count: 100, delayMs: 10 });
setTimeout(() => controller.abort(), 50);
const result = await {{TOOL_EXPORT}}.handler(input, ctx);
expect(result).toBeDefined();
});
Prompt test
import { describe, expect, it } from 'vitest';
import { {{PROMPT_EXPORT}} } from '@/mcp-server/prompts/definitions/{{prompt-name}}.prompt.js';
describe('{{PROMPT_EXPORT}}', () => {
it('generates valid messages for valid args', () => {
const args = {{PROMPT_EXPORT}}.args!.parse({
});
const messages = {{PROMPT_EXPORT}}.generate(args);
expect(messages).toBeInstanceOf(Array);
expect(messages.length).toBeGreaterThan(0);