mcp-runtime-check 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_fuzz/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from mcp_fuzz.engine import CallOutcome, FuzzReport, ToolResult, run_fuzz
2
+ from mcp_fuzz.report import Report, build_report
3
+
4
+ __all__ = ["CallOutcome", "FuzzReport", "ToolResult", "run_fuzz", "Report", "build_report"]
5
+ __version__ = "0.1.1"
mcp_fuzz/cli.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+
8
+ from mcp_fuzz.engine import DEFAULT_TIMEOUT_SECONDS, run_fuzz
9
+ from mcp_fuzz.report import build_report, render_text, to_dict
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(
14
+ prog="mcp-fuzz",
15
+ description=(
16
+ "Launches an MCP server over stdio and calls each read-only tool with "
17
+ "schema-derived valid, missing-required, and wrong-type inputs to check "
18
+ "whether it crashes, hangs, or returns a structured error."
19
+ ),
20
+ )
21
+ parser.add_argument(
22
+ "command",
23
+ nargs=argparse.REMAINDER,
24
+ help="the command (and its arguments) that launches the target MCP server, "
25
+ "e.g. `mcp-fuzz -- python server.py` or `mcp-fuzz -- npx -y some-mcp-server`",
26
+ )
27
+ parser.add_argument(
28
+ "--include-destructive",
29
+ action="store_true",
30
+ help="also test tools not annotated readOnlyHint=true. Off by default — see README's "
31
+ "Safety section before turning this on against a server with real side effects.",
32
+ )
33
+ parser.add_argument(
34
+ "--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS,
35
+ help=f"seconds to wait for a single tool call before treating it as a hang (default {DEFAULT_TIMEOUT_SECONDS})",
36
+ )
37
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of text")
38
+ parser.add_argument(
39
+ "--fail-under", type=float, default=None,
40
+ help="exit non-zero if the crash-resilience percent is below this threshold",
41
+ )
42
+ args = parser.parse_args()
43
+
44
+ command_parts = [c for c in args.command if c != "--"]
45
+ if not command_parts:
46
+ parser.error("no server command given — e.g. `mcp-fuzz -- python server.py`")
47
+
48
+ command, *rest = command_parts
49
+ raw = asyncio.run(run_fuzz(
50
+ command=command,
51
+ args=rest,
52
+ include_destructive=args.include_destructive,
53
+ timeout=args.timeout,
54
+ ))
55
+ report = build_report(raw)
56
+
57
+ if args.json:
58
+ print(json.dumps(to_dict(report), indent=2))
59
+ else:
60
+ print(render_text(report))
61
+
62
+ if report.connect_error:
63
+ sys.exit(2)
64
+ if args.fail_under is not None and (
65
+ report.crash_resilience_percent is None or report.crash_resilience_percent < args.fail_under
66
+ ):
67
+ sys.exit(1)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
mcp_fuzz/engine.py ADDED
@@ -0,0 +1,223 @@
1
+ """Connects to a real, running MCP server over stdio and calls each of its
2
+ tools with schema-derived inputs to see how it actually behaves — distinct
3
+ from static analysis (mcp-doctor), which never runs the code at all.
4
+
5
+ Safety: a tool that isn't explicitly annotated `readOnlyHint: true` is
6
+ skipped by default. This library has no way to know whether a "write"-shaped
7
+ tool's side effects are safe to trigger against whatever backend the target
8
+ server is actually configured against (a real database, a real inbox, a
9
+ real filesystem) — silently calling it during a fuzz pass would be reckless
10
+ regardless of how careful the input generation is. Pass
11
+ `include_destructive=True` to opt into testing everything, at the caller's
12
+ own risk.
13
+
14
+ Isolation: any call that raises, times out, or otherwise leaves the
15
+ transport in a bad state triggers a full reconnect (kill + relaunch the
16
+ server subprocess) before the next case runs, so one tool crashing the
17
+ server doesn't invalidate every result after it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from contextlib import AsyncExitStack
24
+ from dataclasses import dataclass, field
25
+
26
+ from mcp import ClientSession, StdioServerParameters, types
27
+ from mcp.client.stdio import stdio_client
28
+
29
+ from mcp_fuzz.generator import (
30
+ generate_valid_arguments,
31
+ missing_required_variants,
32
+ wrong_type_variants,
33
+ )
34
+
35
+ DEFAULT_TIMEOUT_SECONDS = 15.0
36
+
37
+
38
+ @dataclass
39
+ class CallOutcome:
40
+ case: str # "valid" | "missing_required" | "wrong_type"
41
+ property_name: str | None
42
+ outcome: str # "ok" | "graceful_error" | "crash" | "timeout"
43
+ detail: str = ""
44
+
45
+
46
+ @dataclass
47
+ class ToolResult:
48
+ name: str
49
+ tested: bool
50
+ skip_reason: str | None = None
51
+ outcomes: list[CallOutcome] = field(default_factory=list)
52
+
53
+
54
+ @dataclass
55
+ class FuzzReport:
56
+ server_command: str
57
+ tools: list[ToolResult] = field(default_factory=list)
58
+ connect_error: str | None = None
59
+
60
+
61
+ class _ServerConnection:
62
+ """One live stdio connection to the target server, reconnectable on
63
+ demand after a crash/timeout without tearing down the whole fuzz run."""
64
+
65
+ def __init__(self, params: StdioServerParameters):
66
+ self._params = params
67
+ self._stack: AsyncExitStack | None = None
68
+ self.session: ClientSession | None = None
69
+
70
+ async def connect(self) -> None:
71
+ await self.close()
72
+ stack = AsyncExitStack()
73
+ try:
74
+ read, write = await stack.enter_async_context(stdio_client(self._params))
75
+ session = await stack.enter_async_context(ClientSession(read, write))
76
+ await session.initialize()
77
+ except BaseException:
78
+ await stack.aclose()
79
+ raise
80
+ self._stack = stack
81
+ self.session = session
82
+
83
+ async def close(self) -> None:
84
+ if self._stack is not None:
85
+ try:
86
+ await self._stack.aclose()
87
+ except Exception:
88
+ pass # best-effort teardown of a possibly-already-dead process
89
+ self._stack = None
90
+ self.session = None
91
+
92
+
93
+ def _field(model, snake_name: str, camel_name: str):
94
+ """Reads a pydantic model field whose attribute name differs across
95
+ `mcp` SDK major versions: mcp<2.0 exposed several `types` fields under
96
+ their raw camelCase wire name directly (`isError`, `inputSchema`,
97
+ `readOnlyHint`, ...); mcp>=2.0 renamed them to snake_case
98
+ (`is_error`, `input_schema`, `read_only_hint`, ...) with the camelCase
99
+ kept only as a validation alias, not a readable attribute. Verified
100
+ directly: installing a real target server (`arxiv-mcp-server`, which
101
+ pins `mcp<2.0`) into the same environment as mcp-fuzz silently
102
+ downgraded the shared `mcp` package and broke every hardcoded
103
+ snake_case attribute access with an AttributeError. Since mcp-fuzz's
104
+ own resolved `mcp` version is independent of whatever the target
105
+ server uses, and either generation could end up installed here, try
106
+ the current name first and fall back to the older one rather than
107
+ assuming either."""
108
+ if hasattr(model, snake_name):
109
+ return getattr(model, snake_name)
110
+ return getattr(model, camel_name)
111
+
112
+
113
+ def _is_read_only(tool: types.Tool) -> bool:
114
+ annotations = tool.annotations
115
+ if annotations is None:
116
+ return False
117
+ return _field(annotations, "read_only_hint", "readOnlyHint") is True
118
+
119
+
120
+ async def _call_with_outcome(
121
+ conn: _ServerConnection,
122
+ params: StdioServerParameters,
123
+ tool_name: str,
124
+ case: str,
125
+ property_name: str | None,
126
+ arguments: dict,
127
+ timeout: float,
128
+ ) -> CallOutcome:
129
+ """Runs one tool call, classifying the result, and reconnects the shared
130
+ connection afterward if the call left it unusable."""
131
+ try:
132
+ assert conn.session is not None
133
+ result = await asyncio.wait_for(
134
+ conn.session.call_tool(tool_name, arguments), timeout=timeout
135
+ )
136
+ except asyncio.TimeoutError:
137
+ # `asyncio.wait_for` raises `asyncio.TimeoutError`. Python 3.11
138
+ # unified that with the builtin `TimeoutError` (same class), but on
139
+ # 3.10 they're still distinct — `except TimeoutError` alone misses
140
+ # it there and this falls through to the generic crash handler
141
+ # below, misclassifying a genuine timeout as a crash. Caught this
142
+ # via CI running 3.10 (mcp itself requires >=3.10), not locally,
143
+ # where dev happened to be on 3.11+.
144
+ await conn.connect()
145
+ return CallOutcome(case, property_name, "timeout", f"no response within {timeout}s")
146
+ except Exception as exc:
147
+ await conn.connect()
148
+ return CallOutcome(case, property_name, "crash", f"{type(exc).__name__}: {exc}")
149
+
150
+ if isinstance(result, types.CallToolResult) and _field(result, "is_error", "isError"):
151
+ outcome = "graceful_error" if case != "valid" else "ok"
152
+ # A "valid" call returning is_error is itself worth surfacing, but
153
+ # it's a content-level finding, not a crash — record it as an error
154
+ # outcome regardless of which case triggered it so the report shows
155
+ # the true state rather than papering over a valid-call failure.
156
+ if case == "valid":
157
+ outcome = "valid_call_errored"
158
+ text = "; ".join(
159
+ c.text for c in result.content if isinstance(c, types.TextContent)
160
+ )[:300]
161
+ return CallOutcome(case, property_name, outcome, text)
162
+
163
+ return CallOutcome(case, property_name, "ok")
164
+
165
+
166
+ async def run_fuzz(
167
+ command: str,
168
+ args: list[str] | None = None,
169
+ env: dict[str, str] | None = None,
170
+ cwd: str | None = None,
171
+ include_destructive: bool = False,
172
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
173
+ ) -> FuzzReport:
174
+ params = StdioServerParameters(command=command, args=args or [], env=env, cwd=cwd)
175
+ server_label = " ".join([command, *(args or [])])
176
+ report = FuzzReport(server_command=server_label)
177
+
178
+ conn = _ServerConnection(params)
179
+ try:
180
+ await conn.connect()
181
+ except Exception as exc:
182
+ report.connect_error = f"{type(exc).__name__}: {exc}"
183
+ return report
184
+
185
+ try:
186
+ assert conn.session is not None
187
+ tools_result = await conn.session.list_tools()
188
+ except Exception as exc:
189
+ report.connect_error = f"failed to list tools: {type(exc).__name__}: {exc}"
190
+ await conn.close()
191
+ return report
192
+
193
+ for tool in tools_result.tools:
194
+ if not include_destructive and not _is_read_only(tool):
195
+ report.tools.append(ToolResult(
196
+ name=tool.name,
197
+ tested=False,
198
+ skip_reason="not annotated readOnlyHint=true (use include_destructive to test anyway)",
199
+ ))
200
+ continue
201
+
202
+ result = ToolResult(name=tool.name, tested=True)
203
+ schema = _field(tool, "input_schema", "inputSchema")
204
+
205
+ valid_args = generate_valid_arguments(schema)
206
+ result.outcomes.append(
207
+ await _call_with_outcome(conn, params, tool.name, "valid", None, valid_args, timeout)
208
+ )
209
+
210
+ for prop_name, args in missing_required_variants(schema):
211
+ result.outcomes.append(
212
+ await _call_with_outcome(conn, params, tool.name, "missing_required", prop_name, args, timeout)
213
+ )
214
+
215
+ for prop_name, args in wrong_type_variants(schema):
216
+ result.outcomes.append(
217
+ await _call_with_outcome(conn, params, tool.name, "wrong_type", prop_name, args, timeout)
218
+ )
219
+
220
+ report.tools.append(result)
221
+
222
+ await conn.close()
223
+ return report
mcp_fuzz/generator.py ADDED
@@ -0,0 +1,180 @@
1
+ """Generates test-call arguments from a tool's JSON input schema.
2
+
3
+ Three kinds of test cases, all derived purely from the schema (no LLM, no
4
+ network calls of its own):
5
+
6
+ - a "valid" call: one plausible value per property, respecting `type`,
7
+ `enum`, `minimum`/`maximum`, `minLength`/`maxLength`, and `format` where
8
+ present, so a well-behaved tool should accept it without complaint.
9
+ - a "missing required" call per required property: the valid call with that
10
+ one property removed, to check the server returns a structured error
11
+ instead of crashing or silently proceeding with an absent argument.
12
+ - a "wrong type" call per property with an unambiguous `type`: the valid
13
+ call with that one property swapped for a value of a different JSON type,
14
+ same reasoning.
15
+
16
+ Anything the schema doesn't pin down (no `type`, an `anyOf`/`oneOf` with
17
+ genuinely different shapes, a `$ref` this module doesn't resolve) is left
18
+ out of the wrong-type set rather than guessed at — a value that might
19
+ legitimately be valid isn't a useful "wrong type" test case.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ _STRING_FORMAT_SAMPLES = {
27
+ "date": "2026-01-01",
28
+ "date-time": "2026-01-01T00:00:00Z",
29
+ "email": "test@example.com",
30
+ "uri": "https://example.com",
31
+ "uuid": "00000000-0000-0000-0000-000000000000",
32
+ "hostname": "example.com",
33
+ "ipv4": "127.0.0.1",
34
+ "ipv6": "::1",
35
+ }
36
+
37
+
38
+ def _sample_string(schema: dict[str, Any]) -> str:
39
+ if "enum" in schema and schema["enum"]:
40
+ return schema["enum"][0]
41
+ fmt = schema.get("format")
42
+ if fmt in _STRING_FORMAT_SAMPLES:
43
+ return _STRING_FORMAT_SAMPLES[fmt]
44
+ base = "test"
45
+ min_len = schema.get("minLength")
46
+ if isinstance(min_len, int) and min_len > len(base):
47
+ base = base + "x" * (min_len - len(base))
48
+ max_len = schema.get("maxLength")
49
+ if isinstance(max_len, int) and max_len < len(base):
50
+ base = base[:max_len] if max_len > 0 else ""
51
+ return base
52
+
53
+
54
+ def _sample_number(schema: dict[str, Any], integer: bool) -> int | float:
55
+ minimum = schema.get("minimum", schema.get("exclusiveMinimum"))
56
+ maximum = schema.get("maximum", schema.get("exclusiveMaximum"))
57
+ if isinstance(minimum, (int, float)):
58
+ value = minimum + (1 if "exclusiveMinimum" in schema else 0)
59
+ elif isinstance(maximum, (int, float)):
60
+ value = maximum - (1 if "exclusiveMaximum" in schema else 0)
61
+ else:
62
+ value = 1
63
+ return int(value) if integer else float(value)
64
+
65
+
66
+ def generate_valid_value(schema: dict[str, Any]) -> Any:
67
+ """One plausible value for a single property's schema fragment."""
68
+ if "const" in schema:
69
+ return schema["const"]
70
+ if "default" in schema:
71
+ return schema["default"]
72
+ if "enum" in schema and schema["enum"]:
73
+ return schema["enum"][0]
74
+ for combinator in ("anyOf", "oneOf"):
75
+ if combinator in schema and schema[combinator]:
76
+ return generate_valid_value(schema[combinator][0])
77
+ schema_type = schema.get("type")
78
+ if isinstance(schema_type, list):
79
+ schema_type = next((t for t in schema_type if t != "null"), schema_type[0])
80
+ if schema_type == "string":
81
+ return _sample_string(schema)
82
+ if schema_type == "integer":
83
+ return _sample_number(schema, integer=True)
84
+ if schema_type == "number":
85
+ return _sample_number(schema, integer=False)
86
+ if schema_type == "boolean":
87
+ return True
88
+ if schema_type == "array":
89
+ items_schema = schema.get("items", {})
90
+ min_items = schema.get("minItems", 1) or 1
91
+ sample = generate_valid_value(items_schema) if isinstance(items_schema, dict) else "test"
92
+ return [sample for _ in range(max(min_items, 1))]
93
+ if schema_type == "object":
94
+ return generate_valid_object(schema)
95
+ if schema_type == "null":
96
+ return None
97
+ # No usable type information — a generic placeholder is better than
98
+ # omitting the property outright, which would itself look like a
99
+ # missing-required-field test rather than a valid call.
100
+ return "test"
101
+
102
+
103
+ def generate_valid_object(schema: dict[str, Any]) -> dict[str, Any]:
104
+ """A plausible object for an `{"type": "object", "properties": {...}}`
105
+ schema — every `required` property filled in, plus any optional
106
+ property that itself has an `enum`/`const`/`default` (cheap to include,
107
+ makes the "valid" call more representative)."""
108
+ properties = schema.get("properties", {})
109
+ required = set(schema.get("required", []))
110
+ result: dict[str, Any] = {}
111
+ for name, prop_schema in properties.items():
112
+ if not isinstance(prop_schema, dict):
113
+ continue
114
+ if name in required or any(k in prop_schema for k in ("enum", "const", "default")):
115
+ result[name] = generate_valid_value(prop_schema)
116
+ return result
117
+
118
+
119
+ def generate_valid_arguments(input_schema: dict[str, Any] | None) -> dict[str, Any]:
120
+ """The full argument dict for a tool call that should succeed."""
121
+ if not input_schema:
122
+ return {}
123
+ return generate_valid_object(input_schema)
124
+
125
+
126
+ def missing_required_variants(input_schema: dict[str, Any] | None) -> list[tuple[str, dict[str, Any]]]:
127
+ """(property_name, arguments) for each required property, omitted one
128
+ at a time from an otherwise-valid call."""
129
+ if not input_schema:
130
+ return []
131
+ required = input_schema.get("required", [])
132
+ if not required:
133
+ return []
134
+ base = generate_valid_arguments(input_schema)
135
+ variants = []
136
+ for name in required:
137
+ args = dict(base)
138
+ args.pop(name, None)
139
+ variants.append((name, args))
140
+ return variants
141
+
142
+
143
+ _WRONG_TYPE_SAMPLES: dict[str, Any] = {
144
+ "string": 12345,
145
+ "integer": "not-a-number",
146
+ "number": "not-a-number",
147
+ "boolean": "not-a-boolean",
148
+ "array": "not-an-array",
149
+ "object": "not-an-object",
150
+ }
151
+
152
+
153
+ def _wrong_type_value(schema: dict[str, Any]) -> Any | None:
154
+ schema_type = schema.get("type")
155
+ if isinstance(schema_type, list) or schema_type is None:
156
+ return None
157
+ return _WRONG_TYPE_SAMPLES.get(schema_type)
158
+
159
+
160
+ def wrong_type_variants(input_schema: dict[str, Any] | None) -> list[tuple[str, dict[str, Any]]]:
161
+ """(property_name, arguments) for each property whose schema has an
162
+ unambiguous single `type`, with that one property swapped to a value of
163
+ a different JSON type in an otherwise-valid call."""
164
+ if not input_schema:
165
+ return []
166
+ properties = input_schema.get("properties", {})
167
+ if not properties:
168
+ return []
169
+ base = generate_valid_arguments(input_schema)
170
+ variants = []
171
+ for name, prop_schema in properties.items():
172
+ if not isinstance(prop_schema, dict):
173
+ continue
174
+ wrong = _wrong_type_value(prop_schema)
175
+ if wrong is None:
176
+ continue
177
+ args = dict(base)
178
+ args[name] = wrong
179
+ variants.append((name, args))
180
+ return variants
mcp_fuzz/report.py ADDED
@@ -0,0 +1,194 @@
1
+ """Turns a raw FuzzReport into a scored, human- or JSON-readable report.
2
+
3
+ The score is deliberately narrow: it's a *crash-resilience* score — the
4
+ fraction of deliberately-bad-input calls (a missing required field, a
5
+ wrong-typed field) that the server handled with a structured error instead
6
+ of crashing or hanging. It does NOT grade whether a tool's "valid" call
7
+ produced a *correct* result, since a synthetic, schema-only-derived value
8
+ (e.g. a placeholder string for a field that's really supposed to be a real
9
+ arXiv ID or a reachable URL) commonly isn't realistic enough for that to be
10
+ a fair judgment — see `mcp_fuzz.engine`'s "valid_call_errored" outcome,
11
+ which is reported separately as "worth investigating", not folded into the
12
+ score, precisely because it can be a false positive from unrealistic
13
+ synthetic data rather than a real tool bug.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from mcp_fuzz.engine import CallOutcome, FuzzReport, ToolResult
21
+
22
+ BAD_INPUT_CASES = {"missing_required", "wrong_type"}
23
+
24
+
25
+ @dataclass
26
+ class ToolReport:
27
+ name: str
28
+ tested: bool
29
+ skip_reason: str | None
30
+ crashes: list[CallOutcome] = field(default_factory=list)
31
+ timeouts: list[CallOutcome] = field(default_factory=list)
32
+ valid_call_issue: CallOutcome | None = None
33
+ bad_input_case_count: int = 0
34
+
35
+
36
+ @dataclass
37
+ class Report:
38
+ server_command: str
39
+ connect_error: str | None
40
+ tools: list[ToolReport]
41
+ tested_count: int
42
+ skipped_count: int
43
+ total_bad_input_cases: int
44
+ crash_count: int
45
+ timeout_count: int
46
+ crash_resilience_percent: float | None
47
+ grade: str | None
48
+
49
+
50
+ def _grade_for_percent(pct: float) -> str:
51
+ if pct >= 97:
52
+ return "A"
53
+ if pct >= 90:
54
+ return "B"
55
+ if pct >= 75:
56
+ return "C"
57
+ if pct >= 50:
58
+ return "D"
59
+ return "F"
60
+
61
+
62
+ def build_report(raw: FuzzReport) -> Report:
63
+ tool_reports: list[ToolReport] = []
64
+ total_bad_input = 0
65
+ total_crashes = 0
66
+ total_timeouts = 0
67
+ tested_count = 0
68
+ skipped_count = 0
69
+
70
+ for tool in raw.tools:
71
+ if not tool.tested:
72
+ skipped_count += 1
73
+ tool_reports.append(ToolReport(
74
+ name=tool.name, tested=False, skip_reason=tool.skip_reason,
75
+ ))
76
+ continue
77
+
78
+ tested_count += 1
79
+ tr = ToolReport(name=tool.name, tested=True, skip_reason=None)
80
+ for outcome in tool.outcomes:
81
+ if outcome.case == "valid":
82
+ if outcome.outcome in ("crash", "timeout", "valid_call_errored"):
83
+ tr.valid_call_issue = outcome
84
+ continue
85
+ if outcome.case not in BAD_INPUT_CASES:
86
+ continue
87
+ tr.bad_input_case_count += 1
88
+ total_bad_input += 1
89
+ if outcome.outcome == "crash":
90
+ tr.crashes.append(outcome)
91
+ total_crashes += 1
92
+ elif outcome.outcome == "timeout":
93
+ tr.timeouts.append(outcome)
94
+ total_timeouts += 1
95
+ tool_reports.append(tr)
96
+
97
+ if total_bad_input > 0:
98
+ percent = 100.0 * (1 - (total_crashes + total_timeouts) / total_bad_input)
99
+ grade = _grade_for_percent(percent)
100
+ else:
101
+ percent = None
102
+ grade = None
103
+
104
+ return Report(
105
+ server_command=raw.server_command,
106
+ connect_error=raw.connect_error,
107
+ tools=tool_reports,
108
+ tested_count=tested_count,
109
+ skipped_count=skipped_count,
110
+ total_bad_input_cases=total_bad_input,
111
+ crash_count=total_crashes,
112
+ timeout_count=total_timeouts,
113
+ crash_resilience_percent=percent,
114
+ grade=grade,
115
+ )
116
+
117
+
118
+ def render_text(report: Report) -> str:
119
+ lines: list[str] = []
120
+ if report.connect_error:
121
+ lines.append(f"Failed to connect: {report.connect_error}")
122
+ return "\n".join(lines)
123
+
124
+ lines.append(f"mcp-fuzz: {report.server_command}")
125
+ lines.append("")
126
+ if report.crash_resilience_percent is not None:
127
+ lines.append(
128
+ f"Crash resilience: {report.crash_resilience_percent:.0f}% ({report.grade}) "
129
+ f"— {report.crash_count} crash(es), {report.timeout_count} timeout(s) "
130
+ f"across {report.total_bad_input_cases} bad-input calls"
131
+ )
132
+ else:
133
+ lines.append("Crash resilience: n/a (no testable tools had any parameters to fuzz)")
134
+ lines.append(f"Tested {report.tested_count} tool(s), skipped {report.skipped_count} (not read-only)")
135
+ lines.append("")
136
+
137
+ for tool in report.tools:
138
+ if not tool.tested:
139
+ lines.append(f" [skip] {tool.name} — {tool.skip_reason}")
140
+ continue
141
+ flags = []
142
+ if tool.crashes:
143
+ flags.append(f"{len(tool.crashes)} crash(es)")
144
+ if tool.timeouts:
145
+ flags.append(f"{len(tool.timeouts)} timeout(s)")
146
+ if tool.valid_call_issue:
147
+ flags.append(f"valid call: {tool.valid_call_issue.outcome}")
148
+ marker = "FAIL" if (tool.crashes or tool.timeouts) else ("WARN" if tool.valid_call_issue else "ok")
149
+ summary = f" — {'; '.join(flags)}" if flags else ""
150
+ lines.append(f" [{marker}] {tool.name}{summary}")
151
+ for outcome in tool.crashes + tool.timeouts:
152
+ lines.append(f" {outcome.case} ({outcome.property_name}): {outcome.detail}")
153
+ if tool.valid_call_issue:
154
+ lines.append(
155
+ f" valid call — {tool.valid_call_issue.outcome}: {tool.valid_call_issue.detail} "
156
+ "(may be a synthetic-input false positive, not a confirmed bug — see README)"
157
+ )
158
+
159
+ return "\n".join(lines)
160
+
161
+
162
+ def to_dict(report: Report) -> dict:
163
+ return {
164
+ "server_command": report.server_command,
165
+ "connect_error": report.connect_error,
166
+ "tested_count": report.tested_count,
167
+ "skipped_count": report.skipped_count,
168
+ "total_bad_input_cases": report.total_bad_input_cases,
169
+ "crash_count": report.crash_count,
170
+ "timeout_count": report.timeout_count,
171
+ "crash_resilience_percent": report.crash_resilience_percent,
172
+ "grade": report.grade,
173
+ "tools": [
174
+ {
175
+ "name": t.name,
176
+ "tested": t.tested,
177
+ "skip_reason": t.skip_reason,
178
+ "crashes": [_outcome_dict(o) for o in t.crashes],
179
+ "timeouts": [_outcome_dict(o) for o in t.timeouts],
180
+ "valid_call_issue": _outcome_dict(t.valid_call_issue) if t.valid_call_issue else None,
181
+ "bad_input_case_count": t.bad_input_case_count,
182
+ }
183
+ for t in report.tools
184
+ ],
185
+ }
186
+
187
+
188
+ def _outcome_dict(outcome: CallOutcome) -> dict:
189
+ return {
190
+ "case": outcome.case,
191
+ "property": outcome.property_name,
192
+ "outcome": outcome.outcome,
193
+ "detail": outcome.detail,
194
+ }
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-runtime-check
3
+ Version: 0.1.1
4
+ Summary: Runtime behavioral testing for MCP servers: calls every tool with schema-derived inputs and checks whether it actually behaves the way its description and schema claim.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mcp>=1.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # mcp-fuzz
12
+
13
+ Runtime behavioral testing for [MCP](https://modelcontextprotocol.io) servers.
14
+
15
+ [`mcp-doctor`](https://github.com/vishalhabib99/mcp-doctor) reads an MCP server's *source code* and checks whether its tools are well-documented. `mcp-fuzz` does the opposite: it actually **launches the server and calls its tools**, with inputs derived from each tool's own declared JSON schema, and checks whether the server behaves the way that schema and its description claim — does a missing required field get a structured error back, or does the server crash? Does a wrong-typed field get rejected cleanly, or does it hang?
16
+
17
+ Static analysis can't see any of that. Only running the code can.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install mcp-runtime-check
23
+ ```
24
+
25
+ (The PyPI *distribution* name is `mcp-runtime-check` — `mcp-fuzz` and close variants were blocked by PyPI's anti-typosquat check as too similar to existing packages, same naming friction mcp-doctor hit. The installed CLI command is still `mcp-fuzz`, and the repo/import package are unchanged.)
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ mcp-fuzz -- python server.py
31
+ mcp-fuzz -- npx -y some-mcp-server
32
+ ```
33
+
34
+ `mcp-fuzz` launches the command you give it as an MCP server over stdio, lists its tools, and for each one runs three kinds of calls built purely from that tool's own `inputSchema` — no LLM, no network calls of its own:
35
+
36
+ - **valid** — one plausible value per property (respecting `type`, `enum`, `minimum`/`maximum`, `format`, ...) — should succeed.
37
+ - **missing required** — the valid call, with one required property removed at a time — should come back as a structured MCP error, not a crash.
38
+ - **wrong type** — the valid call, with one property swapped to a value of a different JSON type — same expectation.
39
+
40
+ Any call that crashes the server, hangs past `--timeout` (default 15s), or leaves the connection unusable triggers a full reconnect before the next case runs, so one bad tool doesn't invalidate the rest of the report.
41
+
42
+ ## Safety — read this before pointing it at anything real
43
+
44
+ `mcp-fuzz` actually **executes** tool calls. Unlike mcp-doctor, it has real side effects if a tool does. By default, **only tools annotated `readOnlyHint: true` are tested** — everything else is skipped and listed as such in the report. Pass `--include-destructive` to test everything, but only against a server you're confident is safe to call blindly (a local sandbox, a test/staging backend) — never a server wired to production data, a real inbox, a real payment system, etc. Many real-world servers don't set `readOnlyHint` accurately or at all, in which case those tools are conservatively skipped rather than assumed safe.
45
+
46
+ ## What the score means
47
+
48
+ The reported "crash resilience" percentage covers only the **missing-required** and **wrong-type** cases — the fraction that came back as a structured error instead of a crash or hang. It does **not** grade whether the tool's "valid" call produced a *correct* result: a synthetic, schema-only-derived value (a placeholder string where the field really expects a real arXiv ID, or a URL that has to actually resolve) often isn't realistic enough for a failure there to be a fair judgment. A failed "valid" call is reported separately, flagged explicitly as **"may be a synthetic-input false positive, not a confirmed bug"** — worth a manual look, not proof of a bug.
49
+
50
+ ## JSON output / CI
51
+
52
+ ```bash
53
+ mcp-fuzz --json -- python server.py
54
+ mcp-fuzz --fail-under 90 -- python server.py # non-zero exit if crash resilience < 90%
55
+ ```
56
+
57
+ ## Real-world spot check
58
+
59
+ | Repo | Stars | Lang | What mcp-fuzz found |
60
+ |---|---|---|---|
61
+ | [`modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) | — | TS | Official reference server, run cross-language via `npx`. Clean pass — 9/9 read-only tools handled every bad-input case cleanly (100%/A). `trigger-long-running-operation`'s valid call correctly timed out — it's deliberately a long-running operation, exactly the kind of result the report's own "may be a false positive" framing exists for. |
62
+ | [`blazickjp/arxiv-mcp-server`](https://github.com/blazickjp/arxiv-mcp-server) | 3.1k | Python | Found a real bug in mcp-fuzz itself, not the target: installing this repo (which pins `mcp<2.0`) into the same environment downgraded the shared `mcp` package from 2.1.1 to 1.29.1. mcp<2.0 exposes several fields under their raw camelCase wire name (`isError`, `inputSchema`, `readOnlyHint`); mcp>=2.0 renamed them to snake_case. Every hardcoded snake_case attribute access broke with an `AttributeError` the moment an older `mcp` happened to be installed. Fixed with a small compatibility helper that tries the current name first, falls back to the old one. 11 tools tested cleanly afterward (100%/A) — the several "valid call errored" flags are exactly the documented synthetic-input false-positive case (a placeholder `"paper_id": "test"` isn't a real arXiv ID). |
63
+ | [`punitarani/fli`](https://github.com/punitarani/fli) | — | Python | Clean pass — all 4 read-only tools (Google Flights MCP) handled every bad-input case cleanly, and even the synthetic "valid" inputs succeeded without error (100%/A, no "worth investigating" flags at all). |
64
+ | [`modelcontextprotocol/server-sequential-thinking`](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) | — | TS | Official reference server. Clean pass, tested with `--include-destructive` (its one tool isn't read-only-annotated but has no real side effects) — 100%/A. |
65
+ | [`modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) | — | TS | Official reference server, run against a throwaway sandbox directory. Clean pass — 6 of 10 read-only tools "valid call errored" on `ENOENT: no such file`, exactly the documented synthetic-input false positive: a generic placeholder string isn't a real path that exists in the sandbox. The 4 write/edit/move/create tools were correctly skipped as not read-only. |
66
+
67
+ ## Known limitations
68
+
69
+ - Input generation is schema-only. A property with no `type` (or a genuinely ambiguous `anyOf`) is skipped from the wrong-type test set rather than guessed at.
70
+ - No semantic check of *what* a successful response actually contains — that's a deliberately separate, opt-in, LLM-backed capability planned for a later release, not v1.
71
+ - stdio transport only for now; no HTTP/SSE servers yet.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,10 @@
1
+ mcp_fuzz/__init__.py,sha256=xkQ8E7UFbpxmNjtF-jAxUzM7i8x-7ubdUKPsa2OYTWc,238
2
+ mcp_fuzz/cli.py,sha256=2kh7JjuYWonu_4NNqC7YD8kj24vHR5fEcpMAdrg_Hdk,2424
3
+ mcp_fuzz/engine.py,sha256=7f0TIFJzDo10ACEElOmE5uY_mUEdeniU8ruMiABP9ks,8504
4
+ mcp_fuzz/generator.py,sha256=rpT-KpFg6uIOg6bCEIkgUUQLfUfyw0ZZllHtCFTpJ48,6904
5
+ mcp_fuzz/report.py,sha256=rBpG6L7OmRUeYhOBDN8lpjXZ_djGctEy-n-U1zNrhbI,6840
6
+ mcp_runtime_check-0.1.1.dist-info/METADATA,sha256=hUpJSL66KJAYDwVJcSrhdI3DexXyckVKW1Bjk-5E_CI,6852
7
+ mcp_runtime_check-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ mcp_runtime_check-0.1.1.dist-info/entry_points.txt,sha256=VLWVntGQU2GrM2dxpt-pN3VtSzBR8EA8-qfBwKeoQ-s,47
9
+ mcp_runtime_check-0.1.1.dist-info/licenses/LICENSE,sha256=onEcx2Q9IzcIF62m2mPbmrqPzFbz80DHBUsPoCIoLe4,1069
10
+ mcp_runtime_check-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-fuzz = mcp_fuzz.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Habib
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.