MCP Server Security
Fuzz testing MCP servers with property-based testing
Unit tests verify that known inputs produce expected outputs. Fuzz testing finds the inputs you didn't think of — the Unicode string that bypasses your path validation, the zero-length array that crashes your rate limiter, the deeply nested object that causes exponential JSON parsing time. Property-based fuzzing with fast-check is the most practical way to add systematic fuzz coverage to an MCP server without a dedicated fuzzing infrastructure.
Why fuzz testing MCP servers specifically
MCP tools accept LLM-generated inputs. Unlike a REST API where a human typed the request, the inputs arriving at your tool handlers are generated by a language model following arbitrary user instructions. The LLM may send inputs that no human would type: Unicode homoglyphs in file paths, control characters in search queries, very large integers as array indices, or deeply nested JSON objects that should be flat strings.
The threat is compounded by prompt injection — an attacker can instruct the LLM to craft inputs that probe your tool's edge cases. Fuzz testing before you ship is how you find these cases before the attacker does.
fast-check basics for MCP handlers
npm install --save-dev fast-check
fast-check generates inputs from "arbitraries" — composable generators for strings, numbers, arrays, and objects. The key property to test for security is the "it should not throw or crash" invariant:
import fc from 'fast-check'
import { handleReadFileTool } from '../src/tools/read-file.js'
describe('read_file tool fuzzing', () => {
test('never crashes on arbitrary file path strings', () => {
fc.assert(
fc.property(
fc.string(), // generates random strings including Unicode, null bytes, etc.
async (filePath) => {
// the handler must either return a result or throw a typed error
// it must NEVER throw an unexpected exception or hang
try {
const result = await handleReadFileTool({ path: filePath })
// if it returns, the result must have a valid structure
expect(result).toHaveProperty('content')
} catch (err) {
// allowed to throw, but must be a typed, expected error
expect(err.message).toBeDefined()
expect(typeof err.message).toBe('string')
}
}
),
{ numRuns: 1000, seed: 42 } // deterministic seed for reproducibility
)
})
})
Schema-aware input generation
Random strings are a good start, but MCP tool inputs have a schema. Using schema-aware generation finds more interesting edge cases:
import { z } from 'zod'
import fc from 'fast-check'
// your tool's input schema
const ScanRepoSchema = z.object({
url: z.string().url(),
branch: z.string().optional(),
depth: z.number().int().min(1).max(100).optional()
})
// build a fast-check arbitrary from the schema shape
const scanRepoArbitrary = fc.record({
url: fc.oneof(
fc.webUrl(), // valid URLs
fc.string(), // invalid URLs (should fail validation)
fc.constant(''), // empty string
fc.constant('javascript:alert(1)'), // URL scheme injection
fc.constant('file:///etc/passwd'), // file:// URL
fc.constant('http://169.254.169.254/latest/meta-data/') // SSRF test
),
branch: fc.option(fc.oneof(
fc.string({ maxLength: 100 }),
fc.constant('../../etc/passwd'), // path traversal in branch name
fc.constant('$(id)'), // command injection
fc.constant('\x00') // null byte
)),
depth: fc.option(fc.oneof(
fc.integer({ min: 1, max: 100 }), // valid range
fc.constant(0), // boundary
fc.constant(-1), // below minimum
fc.constant(Number.MAX_SAFE_INTEGER), // overflow test
fc.constant(NaN), // NaN
fc.constant(Infinity) // Infinity
))
})
test('scan_repo handler is safe against adversarial inputs', async () => {
await fc.assert(
fc.asyncProperty(scanRepoArbitrary, async (input) => {
const result = await handleScanTool(input)
// for SSRF attempts: must not succeed with internal URLs
if (typeof input.url === 'string' && input.url.includes('169.254')) {
// if it returned content, that's a SSRF finding
expect(result.content?.[0]?.text).not.toContain('ami-id')
}
}),
{ numRuns: 500 }
)
})
Shrinking: getting to the minimal failure input
When fast-check finds a failing input, it automatically "shrinks" it — running progressively simpler variations to find the simplest input that still causes the failure. This is invaluable for security work:
// A fuzzer might find a 200-character string that causes a crash. // After shrinking, fast-check reports the minimal failure: // // Property failed after 47 tests (seed: -812746153) // Counterexample: ["../../../../../etc/passwd\x00.txt"] // Shrunk 18 times (from 247 chars to 26 chars) // // The shrunk counterexample is directly actionable: add a test case // and fix the path validation to handle null bytes after extension.
Always record the seed from a failing run — it lets you reproduce the exact same sequence of generated inputs. Add the minimal shrunk counterexample as a permanent unit test case so the regression is captured even if you later remove the fuzz test from CI.
Corpus seeding for MCP-specific attack patterns
Pre-seed your fuzz runs with known MCP attack patterns to guide the generator toward interesting cases faster:
// known-bad inputs for MCP tool path parameters
const PATH_CORPUS = [
'../../../etc/passwd',
'..\\..\\Windows\\System32\\config\\SAM',
'/etc/passwd%00.txt', // null byte after extension
'\x00', // bare null byte
'a'.repeat(10_000), // very long string
'.', // current directory
'', // empty string
'~', // home directory shortcut
]
const pathArbitrary = fc.oneof(
{ weight: 3 }, fc.string(), // 75% random
{ weight: 1 }, fc.constantFrom(...PATH_CORPUS) // 25% corpus seeds
)
Timing-based denial of service detection
Fuzz testing can also detect inputs that cause unexpectedly slow processing — a key vulnerability class in MCP servers that process user-controlled text:
test('tool execution time is bounded regardless of input', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ maxLength: 1000 }),
async (input) => {
const start = performance.now()
try {
await handleTextProcessingTool({ content: input })
} catch {
// errors are ok
}
const elapsed = performance.now() - start
// should complete in under 1 second regardless of input
expect(elapsed).toBeLessThan(1000)
}
),
{ numRuns: 200 }
)
})
This test reliably finds ReDoS vulnerabilities (regular expressions that take exponential time on crafted inputs) and JSON parsing bombs. If a run fails the timing assertion, examine the shrunk counterexample — it will typically be a short string with a specific character pattern that triggers the catastrophic backtracking.
Integrating into CI
// package.json
{
"scripts": {
"test:fuzz": "jest --testPathPattern='fuzz' --testTimeout=60000",
"test:fuzz:ci": "FAST_CHECK_NUM_RUNS=200 jest --testPathPattern='fuzz'"
}
}
Run the full fuzz suite (1000+ runs per property) locally before significant releases, and a reduced suite (200 runs) in CI on every PR. The reduced CI suite still catches most issues — you're looking for easy-to-reproduce bugs, and those tend to appear in the first 100 inputs.
What SkillAudit looks for
SkillAudit checks for fast-check or similar property-based testing libraries in package.json and for fuzz-style test files (**/*.fuzz.test.*, **/*property*.test.*). Repositories with fuzz testing configured score higher on the Documentation Completeness axis. The absence of any fuzz testing on tools that accept user-controlled strings (especially tools that process file paths, execute shell commands, or make HTTP requests) is noted as a maintenance recommendation in the audit report.
Audit your MCP server for fuzz-testable vulnerabilities
SkillAudit's static pass finds command injection, path traversal, and SSRF patterns that property-based fuzzing can verify. Free for public repos.
Run a free audit