Published 3 June 2026 · Blog
Property-based fuzzing for MCP server security testing
Unit tests verify the cases you already thought of. Property-based fuzzing automatically generates thousands of adversarial inputs — null bytes, path traversal strings, JSON with unexpected types, integers at the edges of range — and breaks your tool handlers before an attacker does. Here is how to wire it up for MCP servers.
A typical MCP server tool handler has five or six happy-path unit tests. A developer writes one for an empty input, one for a valid file path, maybe one for a missing resource. The test suite passes. The handler ships.
Three months later, a security researcher submits a path like ../../etc/passwd and the realpath check — present in the code, tested in isolation — fails to fire because the full path construction uses string concatenation before the check, and the test never exercised that specific ordering. Or a numeric parameter passed as a string causes a silent parseInt(NaN) that becomes 0, which reads the first record in the database instead of returning an error. Or a Unicode zero-width joiner bypasses the blocklist regex that was tested only against ASCII inputs.
These are not exotic bugs. They are the exact kind of input-space edge cases that property-based fuzzing finds automatically, without anyone having to think to write the test case first.
What property-based testing actually is
Property-based testing inverts the unit test model. Instead of specifying "given input X, expect output Y," you specify a property that must hold for any valid input the framework generates: "for any file path my tool receives, the resolved absolute path must always start with the allowed base directory." The framework generates hundreds or thousands of random inputs, checks whether the property holds, and if it finds a counterexample it minimizes (shrinks) that counterexample to the smallest failing case before reporting it.
For MCP server security testing, properties map naturally to security invariants:
- Path containment: for any string input to a file-reading tool, the resolved path must remain within the declared root
- No exception leakage: for any input, the tool must return a structured MCP error response, not a thrown exception with a stack trace
- Numeric bounds: for any integer input outside the declared valid range, the tool must reject, not clamp or wrap silently
- Output size: for any valid input, the tool output must not exceed the declared maximum token count
- Idempotency: for read-only tools, calling the tool twice with the same input must return the same result (no side effects)
These properties cannot be exhaustively verified by hand-written tests. Property-based testing explores the input space systematically.
Setup: fast-check for Node.js MCP servers
fast-check is the standard property-based testing library for TypeScript and JavaScript. It integrates with any test runner — Jest, Vitest, Node's built-in test runner — and includes built-in arbitrary generators for strings, integers, arrays, objects, and Unicode text.
npm install --save-dev fast-check vitest
For a Python MCP server, the equivalent is Hypothesis with pytest:
pip install hypothesis pytest
The examples below use fast-check + TypeScript, but the Hypothesis equivalent is structurally identical.
Test 1: Path traversal property
The most important property for any file-reading tool is path containment. Here is how to express and test it:
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import path from 'path';
import { readDocumentTool } from '../src/tools/read-document.js';
const WORKSPACE_ROOT = '/app/workspace';
describe('read_document — path containment property', () => {
it('always resolves within workspace root for any string input', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ unit: 'grapheme', minLength: 1, maxLength: 512 }),
async (inputPath) => {
const result = await readDocumentTool({ path: inputPath });
if (result.isError) {
// A rejection response is always valid — the tool rejected the input
return true;
}
// If the tool succeeded, the path it actually read must be within root
const resolvedPath = path.resolve(WORKSPACE_ROOT, inputPath);
expect(resolvedPath.startsWith(WORKSPACE_ROOT + path.sep)).toBe(true);
}
),
{ numRuns: 1000, verbose: true }
);
});
});
Running this test against a tool implementation that uses path.join(root, userPath) but checks containment before resolving symlinks will discover the traversal: fast-check generates ../../../etc/passwd within the first few dozen runs, the containment check fails (the joined-but-not-resolved path appears safe), and the framework shrinks the counterexample to the minimal traversal string.
The key design decision is the "rejection is always valid" branch. Properties for MCP tools should not require a successful response — only that if a response is successful, the invariants hold. This prevents false positives from valid error handling.
Test 2: No exception leakage
MCP servers that propagate uncaught exceptions to the LLM leak stack traces containing internal paths, dependency versions, and sometimes credentials. This property verifies that any input produces a structured response, never an exception:
describe('fetch_url — no exception leakage property', () => {
it('never throws for any URL-shaped string input', async () => {
await fc.assert(
fc.asyncProperty(
// Generate adversarial URL-shaped strings: valid URLs, invalid URLs,
// javascript: scheme, file:// scheme, data: URLs, excessively long URLs
fc.oneof(
fc.webUrl(),
fc.string({ unit: 'binary', minLength: 0, maxLength: 2048 }),
fc.constant('javascript:alert(1)'),
fc.constant('file:///etc/passwd'),
fc.constant('data:text/html,'),
fc.constant(''),
fc.constant('a'.repeat(8192)),
),
async (url) => {
let result: unknown;
let threw = false;
try {
result = await fetchUrlTool({ url });
} catch (e) {
threw = true;
}
// The handler must never throw — it must catch and return MCP error
expect(threw).toBe(false);
// If it returned, result must have MCP error or content shape
expect(result).toBeDefined();
}
),
{ numRuns: 500 }
);
});
});
This test reliably catches handlers that forget to wrap the URL fetch in a try-catch, or that validate the scheme but crash on the URL parse for malformed inputs like http:// with no host.
Test 3: Numeric argument bounds
Integer parameters in MCP tools are frequently used to control pagination, depth limits, and timeouts. Improper handling of boundary values (negative numbers, zero, MAX_SAFE_INTEGER, floating-point values passed as integers) causes amplification attacks and unexpected behavior:
describe('list_items — numeric bounds property', () => {
it('rejects or safely handles any integer for the limit parameter', async () => {
await fc.assert(
fc.asyncProperty(
fc.oneof(
fc.integer({ min: -1_000_000, max: 1_000_000 }),
fc.constant(0),
fc.constant(-1),
fc.constant(Number.MAX_SAFE_INTEGER),
fc.constant(Number.MIN_SAFE_INTEGER),
fc.constant(Infinity),
fc.constant(NaN),
),
async (limit) => {
const result = await listItemsTool({ limit });
if (result.isError) {
// Rejection is fine — must include a non-leaking error message
expect(result.content[0].text).not.toMatch(/TypeError|RangeError|at Object\./);
return;
}
// If successful, the number of items returned must be within [0, MAX_PAGE_SIZE]
const MAX_PAGE_SIZE = 100;
const items = JSON.parse(result.content[0].text).items;
expect(items.length).toBeGreaterThanOrEqual(0);
expect(items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE);
}
),
{ numRuns: 200 }
);
});
});
A common bug this catches: handlers that use LIMIT ? in SQL queries without clamping first. Passing -1 to SQLite returns all rows; passing Number.MAX_SAFE_INTEGER triggers a memory exhaustion attempt.
Test 4: Unicode and binary input handling
MCP tool arguments are JSON strings. JSON strings can contain any Unicode codepoint, including control characters, right-to-left override characters, zero-width joiners, and null bytes embedded via