Test your Model Context Protocol tools, resources, and prompts with expressive matchers, schema validation, fuzz testing, and snapshot support.
Getting Started · Features · API Reference · Contributing
Building MCP servers is straightforward. Knowing they work correctly is the hard part.
| Problem | How mcp-testkit solves it |
|---|---|
| No standard testing patterns | One-line harness setup for any MCP server |
| Boilerplate transport wiring | createHarness() handles InMemoryTransport or stdio for you |
Manual result.content[0].text checks |
Purpose-built matchers like toBeSuccessful() and toHaveTextContent() |
| No schema validation | validateToolSchema() catches issues before production |
| Edge cases found by users, not tests | Built-in fuzz testing with reproducible seeds |
Write tests for MCP servers the same way you write tests for REST APIs — fast, expressive, and reliable.
npm install -D mcp-testkitPeer dependencies (you likely already have these):
npm install @modelcontextprotocol/sdk zodimport { describe, it, expect } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createHarness } from "mcp-testkit";
import { mcpMatchers } from "mcp-testkit/matchers";
expect.extend(mcpMatchers);
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.tool(
"greet",
"Greet a user",
{ name: z.string() },
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }],
})
);
describe("My MCP Server", () => {
it("greets users", async () => {
const harness = await createHarness(server);
const tools = await harness.listTools();
expect(tools).toHaveToolNamed("greet");
const result = await harness.callTool("greet", { name: "World" });
expect(result).toBeSuccessful();
expect(result).toHaveTextContent("Hello, World!");
await harness.close();
});
});Skip the expect.extend() boilerplate by registering matchers automatically:
// vitest.config.ts
export default {
test: {
setupFiles: ["mcp-testkit/setup/vitest"],
},
};Create a fully wired MCP client-server pair in one line. No transport setup, no boilerplate.
import { createHarness } from "mcp-testkit";
// In-memory harness (unit tests) — pass an McpServer instance
const harness = await createHarness(server);
// Stdio harness (integration tests) — pass a command config
const harness = await createHarness({
command: "node",
args: ["./dist/server.js"],
env: { API_KEY: "test" },
});
// Same API for both modes
const tools = await harness.listTools();
const result = await harness.callTool("my-tool", { input: "test" });
const resources = await harness.listResources();
const prompts = await harness.listPrompts();
await harness.close();In-memory vs stdio — when to use which?
| In-memory | Stdio | |
|---|---|---|
| Speed | Instant | Spawns a child process |
| Use case | Unit tests, CI | Integration tests, E2E |
| Setup | Pass McpServer instance |
Pass { command, args } config |
| Isolation | Shares process memory | Full process isolation |
Use in-memory for fast iteration during development. Use stdio to test the actual binary your users will run.
Expressive assertions purpose-built for MCP testing. Works with Vitest and Jest.
expect(tools).toHaveToolNamed("search");
expect(tools).toHaveToolCount(5);
expect(tools).toHaveToolDescription("search", /find items/i);
expect(tools).toHaveInputSchema("search", { query: {}, limit: {} });expect(result).toBeSuccessful();
expect(result).toBeToolError();
expect(result).toHaveTextContent("expected output");
expect(result).toHaveTextContent(/pattern/);
expect(result).toHaveContentCount(1);
expect(result).toHaveContentType("text");Validate that your tool schemas follow MCP best practices before they reach production.
import { validateToolSchema, validateAllToolSchemas } from "mcp-testkit";
const tools = await harness.listTools();
// Validate a single tool
const issues = validateToolSchema(tools[0]);
// [{ path: "properties.query", message: "Missing description", severity: "warning" }]
// Validate all tools at once
const allIssues = validateAllToolSchemas(tools);
// Map<string, SchemaIssue[]>Automatically generate valid and edge-case inputs from any tool's JSON Schema.
import { generateValidInput, generateEdgeCaseInputs } from "mcp-testkit";
const schema = tools[0].inputSchema;
// Generate a minimal valid input
const input = generateValidInput(schema);
// { query: "test" }
// Generate edge cases for thorough testing
const edgeCases = generateEdgeCaseInputs(schema);
// [
// { label: "empty object", input: {} },
// { label: "missing required: query", input: {} },
// { label: "wrong type for: query", input: { query: 42 } },
// { label: "boundary: empty string for query", input: { query: "" } },
// { label: "boundary: very long string for query", input: { query: "a".repeat(10000) } },
// ]Throw random and adversarial inputs at your tools to surface crashes before your users do.
import { fuzzTool, fuzzAllTools } from "mcp-testkit";
// Fuzz a single tool — with a seed for reproducibility
const result = await fuzzTool(harness, "search", {
runs: 100,
seed: 42,
assert: (toolResult) => {
expect(toolResult.content.length).toBeGreaterThan(0);
},
});
console.log(`${result.passed}/${result.runs} passed`);
console.log("Failures:", result.errors);
// Fuzz every tool on the server
const results = await fuzzAllTools(harness, { runs: 50, seed: 42 });Custom serializer that strips non-deterministic fields for stable, readable snapshots.
import { toolResultSerializer } from "mcp-testkit";
expect.addSnapshotSerializer(toolResultSerializer);
const result = await harness.callTool("greet", { name: "World" });
expect(result).toMatchSnapshot();Utility functions for extracting and checking text content in tool results.
import { getTexts, getFirstText, hasText } from "mcp-testkit";
const result = await harness.callTool("greet", { name: "World" });
getTexts(result); // ["Hello, World!"]
getFirstText(result); // "Hello, World!"
hasText(result, /hello/i); // true| Function | Description |
|---|---|
createHarness(server) |
Create an in-memory test harness from an McpServer instance |
createHarness(config) |
Create a stdio test harness from { command, args?, env?, cwd? } |
| Method | Returns | Description |
|---|---|---|
harness.listTools() |
ToolInfo[] |
List all registered tools |
harness.callTool(name, args?) |
ToolResult |
Call a tool by name |
harness.listResources() |
ResourceInfo[] |
List all resources |
harness.readResource(uri) |
ResourceResult |
Read a resource by URI |
harness.listPrompts() |
PromptInfo[] |
List all prompts |
harness.getPrompt(name, args?) |
PromptResult |
Get a prompt by name |
harness.getServerCapabilities() |
Record<string, unknown> |
Get server capabilities |
harness.close() |
void |
Close connection and clean up (idempotent) |
| Matcher | Applies to | Description |
|---|---|---|
toHaveToolNamed(name) |
ToolInfo[] |
Assert a tool with the given name exists |
toHaveToolCount(n) |
ToolInfo[] |
Assert the exact number of tools |
toHaveToolDescription(name, expected) |
ToolInfo[] |
Assert a tool's description matches a string or regex |
toHaveInputSchema(name, props) |
ToolInfo[] |
Assert a tool's schema contains expected properties |
toBeSuccessful() |
ToolResult |
Assert the result has no error flag |
toBeToolError() |
ToolResult |
Assert the result has the error flag |
toHaveTextContent(expected) |
ToolResult |
Assert text content matches a string or regex |
toHaveContentCount(n) |
ToolResult |
Assert the exact number of content items |
toHaveContentType(type) |
ToolResult |
Assert at least one content item has the given type |
| Function | Description |
|---|---|
validateToolSchema(tool) |
Validate a single tool's JSON Schema, returns SchemaIssue[] |
validateAllToolSchemas(tools) |
Validate all tools, returns Map<string, SchemaIssue[]> |
validateOutput(output, schema) |
Validate output data against a JSON Schema |
generateValidInput(schema) |
Generate a minimal valid input object from a schema |
generateEdgeCaseInputs(schema) |
Generate an array of edge-case inputs for boundary testing |
| Function | Description |
|---|---|
fuzzTool(harness, name, options?) |
Fuzz test a single tool with random inputs |
fuzzAllTools(harness, options?) |
Fuzz test every tool on the server |
FuzzOptions
| Option | Type | Default | Description |
|---|---|---|---|
runs |
number |
50 |
Number of random inputs to generate |
seed |
number |
Date.now() |
Seed for reproducible runs |
assert |
(result, input) => void |
— | Custom assertion applied to every result |
| Export | Description |
|---|---|
toolResultSerializer |
Pre-configured snapshot serializer (strips _meta, _timestamp, etc.) |
createToolResultSerializer(options?) |
Create a custom serializer with your own stripFields |
| Function | Description |
|---|---|
getTexts(result) |
Extract all text strings from a ToolResult |
getFirstText(result) |
Extract the first text string, or undefined |
hasText(result, pattern) |
Check if any text content matches a string or regex |
| Dependency | Supported Versions |
|---|---|
| Node.js | 18, 20, 22+ |
| Test Runners | Vitest, Jest |
| MCP SDK | @modelcontextprotocol/sdk v1.12+ |
| Zod | Zod v3.23+ or v4 |
| TypeScript | 5.x |
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
git clone https://github.com/BuildWithAbid/mcp-testkit.git
cd mcp-testkit
npm install
npm test
npm run build