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