Help us improve
Share bugs, ideas, or general feedback.
From ntfy-mcp-server
Scaffolds Vitest test files for tools, resources, or services following a project's test layout. Guides test coverage for happy paths, errors, edge cases, and mcp-ts-core context features.
npx claudepluginhub cyanheads/cyanheads --plugin ntfy-mcp-serverHow this skill is triggered — by the user, by Claude, or both
Slash command
/ntfy-mcp-server:add-testThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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`).
Generates Vitest test files for MCP tools, resources, or services, covering happy paths, error paths, and edge cases using createMockContext.
Scaffolds Vitest test files for an MCP server's tools, resources, or services. Useful when adding tests or improving coverage where a definition exists without a matching test.
Tests MCP tool/resource handlers using createMockContext and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, and test isolation.
Share bugs, ideas, or general feedback.
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
ctx features it usesbun run test to verifybun run devcheck to verify typesRead 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 | Use createMockContext({ tenantId: 'test' }) to enable storage |
ctx.elicit / ctx.sample | Mock with vi.fn(), also test the absent case (undefined) |
ctx.progress | Use createMockContext({ progress: true }) for task tools |
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. |
| Auth scopes | Not tested at handler level (framework enforces) — skip |
/**
* @fileoverview Tests for {{TOOL_NAME}} tool.
* @module tests/tools/{{TOOL_NAME}}.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({
// valid input matching the Zod schema
});
const result = await {{TOOL_EXPORT}}.handler(input, ctx);
expect(result).toMatchObject({
// expected output shape
});
});
it('throws on invalid state', async () => {
const ctx = createMockContext();
const input = {{TOOL_EXPORT}}.input.parse({
// input that triggers an error path
});
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toThrow();
});
// Only when the tool declares `errors: [...]`. Drop this block otherwise.
it('throws ctx.fail("{{REASON}}") for the declared failure mode', async () => {
const ctx = createMockContext({ errors: {{TOOL_EXPORT}}.errors });
const input = {{TOOL_EXPORT}}.input.parse({
// input that triggers the declared failure mode
});
await expect({{TOOL_EXPORT}}.handler(input, ctx)).rejects.toMatchObject({
data: { reason: '{{REASON}}' },
});
});
it('formats output completely', () => {
const output = { /* mock output matching the output schema */ };
const blocks = {{TOOL_EXPORT}}.format!(output);
expect(blocks.some((block) => block.type === 'text')).toBe(true);
// Assert the rendered text includes the IDs/fields the LLM needs to act on.
});
});
/**
* @fileoverview Tests for {{RESOURCE_NAME}} resource.
* @module tests/resources/{{RESOURCE_NAME}}.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({
// valid params matching the Zod schema
});
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({
// params for a non-existent resource
});
await expect({{RESOURCE_EXPORT}}.handler(params, ctx)).rejects.toThrow();
});
// For resources that declare an `errors: [...]` contract, pass the contract via
// `createMockContext` so the typed `ctx.fail` is wired automatically:
// const ctx = createMockContext({ errors: {{RESOURCE_EXPORT}}.errors });
// const err = await {{RESOURCE_EXPORT}}.handler(params, ctx).catch((e) => e);
// expect(err.code).toBe(JsonRpcErrorCode.NotFound);
// expect(err.data.reason).toBe('no_match');
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');
}
});
});
/**
* @fileoverview Tests for {{SERVICE_NAME}} service.
* @module tests/services/{{domain}}/{{domain}}-service.test
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { get{{ServiceClass}}, init{{ServiceClass}} } from '@/services/{{domain}}/{{domain}}-service.js';
import { createInMemoryStorage } from '@cyanheads/mcp-ts-core/testing';
describe('{{ServiceClass}}', () => {
beforeEach(() => {
// Re-initialize with fresh config/storage for each test
const mockStorage = createInMemoryStorage();
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().
For tools with task: true, use createMockContext({ progress: true }):
it('reports progress during execution', async () => {
const ctx = createMockContext({ progress: true });
const input = {{TOOL_EXPORT}}.input.parse({ count: 3, delayMs: 10 });
await {{TOOL_EXPORT}}.handler(input, ctx);
const progress = ctx.progress as ContextProgress & {
_total: number;
_completed: number;
_messages: string[];
};
expect(progress._total).toBe(3);
expect(progress._completed).toBe(3);
});
it('respects cancellation', async () => {
const controller = new AbortController();
const ctx = createMockContext({ progress: true, signal: controller.signal });
const input = {{TOOL_EXPORT}}.input.parse({ count: 100, delayMs: 10 });
// Abort after a short delay
setTimeout(() => controller.abort(), 50);
const result = await {{TOOL_EXPORT}}.handler(input, ctx);
// Should have stopped early
expect(result.finalCount).toBeGreaterThan(0);
});
For schema-heavy or input-validation-critical handlers, the framework ships fuzz helpers that generate valid + adversarial inputs from your Zod schemas via fast-check and assert handler invariants (no crashes, no prototype pollution, no stack-trace leaks):
import { fuzzTool } from '@cyanheads/mcp-ts-core/testing/fuzz';
it('survives fuzz testing', async () => {
const report = await fuzzTool({{TOOL_EXPORT}}, { numRuns: 100 });
expect(report.crashes).toHaveLength(0);
expect(report.leaks).toHaveLength(0);
expect(report.prototypePollution).toBe(false);
});
Available helpers from @cyanheads/mcp-ts-core/testing/fuzz: fuzzTool, fuzzResource, fuzzPrompt, zodToArbitrary (custom property-based tests), adversarialArbitrary and ADVERSARIAL_STRINGS (targeted injection sets). Returns a FuzzReport you can assert against. Options: numRuns, numAdversarial, seed (reproducibility), timeout, ctx (MockContextOptions for stateful handlers).
When scaffolding tests for an existing handler, use the Zod schemas to generate meaningful test cases:
input schema — identify required fields, optional fields with defaults, constrained types (enums, min/max, patterns)output schema — know what shape to assert against.min(), .max(), .length(), test at the boundariesformat() renders uncertainty honestly (Not available, omitted badge, etc.) instead of fabricating values.tests/... or colocated with source)@fileoverview and @module header present.rejects.toThrow())format function tested if definedcreateMockContext options match handler's ctx usage (tenantId, progress, elicit, sample)beforeEach if handler depends on a service singletonformat() does not invent facts)bun run test passesbun run devcheck passes