mcp-runtime-check 0.1.1__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e . pytest
21
+ - run: pytest -q
22
+ - name: Dogfood on our own fixture server
23
+ run: mcp-fuzz --fail-under 100 -- python tests/fixtures/fixture_server.py
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write # required for PyPI trusted publishing (OIDC) — no API token/secret needed
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .DS_Store
@@ -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.
@@ -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,65 @@
1
+ # mcp-fuzz
2
+
3
+ Runtime behavioral testing for [MCP](https://modelcontextprotocol.io) servers.
4
+
5
+ [`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?
6
+
7
+ Static analysis can't see any of that. Only running the code can.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install mcp-runtime-check
13
+ ```
14
+
15
+ (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.)
16
+
17
+ ## Use
18
+
19
+ ```bash
20
+ mcp-fuzz -- python server.py
21
+ mcp-fuzz -- npx -y some-mcp-server
22
+ ```
23
+
24
+ `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:
25
+
26
+ - **valid** — one plausible value per property (respecting `type`, `enum`, `minimum`/`maximum`, `format`, ...) — should succeed.
27
+ - **missing required** — the valid call, with one required property removed at a time — should come back as a structured MCP error, not a crash.
28
+ - **wrong type** — the valid call, with one property swapped to a value of a different JSON type — same expectation.
29
+
30
+ 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.
31
+
32
+ ## Safety — read this before pointing it at anything real
33
+
34
+ `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.
35
+
36
+ ## What the score means
37
+
38
+ 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.
39
+
40
+ ## JSON output / CI
41
+
42
+ ```bash
43
+ mcp-fuzz --json -- python server.py
44
+ mcp-fuzz --fail-under 90 -- python server.py # non-zero exit if crash resilience < 90%
45
+ ```
46
+
47
+ ## Real-world spot check
48
+
49
+ | Repo | Stars | Lang | What mcp-fuzz found |
50
+ |---|---|---|---|
51
+ | [`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. |
52
+ | [`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). |
53
+ | [`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). |
54
+ | [`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. |
55
+ | [`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. |
56
+
57
+ ## Known limitations
58
+
59
+ - 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.
60
+ - 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.
61
+ - stdio transport only for now; no HTTP/SSE servers yet.
62
+
63
+ ## License
64
+
65
+ MIT
@@ -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"
@@ -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()
@@ -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
@@ -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
@@ -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,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-runtime-check"
7
+ version = "0.1.1"
8
+ description = "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."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "mcp>=1.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ mcp-fuzz = "mcp_fuzz.cli:main"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["mcp_fuzz"]
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest",
25
+ "pytest-asyncio",
26
+ ]
@@ -0,0 +1,75 @@
1
+ """A tiny real MCP server used to exercise mcp-fuzz's engine end-to-end.
2
+ Deliberately includes one tool of each kind mcp-fuzz should distinguish:
3
+ well-behaved, crashes on bad input, hangs, and a non-read-only tool that
4
+ should be skipped by default.
5
+
6
+ Written against the official SDK's current `MCPServer` API (`mcp>=2.0`,
7
+ where `FastMCP` was renamed from `mcp.server.fastmcp.FastMCP`) — verified
8
+ directly against the installed package, not assumed from older examples.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+
15
+ from mcp.server.mcpserver import MCPServer
16
+ from mcp.types import ToolAnnotations
17
+
18
+ server = MCPServer("mcp-fuzz-fixture")
19
+
20
+ READ_ONLY = ToolAnnotations(read_only_hint=True)
21
+ NOT_READ_ONLY = ToolAnnotations(read_only_hint=False)
22
+
23
+
24
+ @server.tool(annotations=READ_ONLY)
25
+ def well_behaved(name: str, count: int = 1) -> str:
26
+ """Echoes name count times. Validates its own inputs properly."""
27
+ if not isinstance(name, str):
28
+ raise ValueError("name must be a string")
29
+ if not isinstance(count, int):
30
+ raise ValueError("count must be an integer")
31
+ return (name + " ") * count
32
+
33
+
34
+ @server.tool(annotations=READ_ONLY)
35
+ def crashes_on_bad_input(value: int) -> str:
36
+ """Divides 100 by value. Crashes (unhandled exception) if value is missing or the wrong type."""
37
+ # Deliberately no validation — a wrong-typed or missing `value` raises
38
+ # an uncaught TypeError, simulating a real server that doesn't guard
39
+ # its handler against a malformed call.
40
+ return str(100 / value)
41
+
42
+
43
+ @server.tool(annotations=READ_ONLY)
44
+ def hangs_forever(value: str) -> str:
45
+ """Never returns — simulates a server tool that hangs on certain input."""
46
+ time.sleep(3600)
47
+ return value
48
+
49
+
50
+ @server.tool(annotations=NOT_READ_ONLY)
51
+ def delete_everything(target: str) -> str:
52
+ """A destructive tool that should be skipped by default."""
53
+ return f"deleted {target}"
54
+
55
+
56
+ @server.tool(annotations=READ_ONLY)
57
+ def always_crashes(x: str) -> str:
58
+ """Raises unconditionally, even on schema-valid input — the SDK's own
59
+ exception handling turns this into a structured error, not a process
60
+ crash (see kills_process below for that)."""
61
+ raise RuntimeError("this tool always crashes")
62
+
63
+
64
+ @server.tool(annotations=READ_ONLY)
65
+ def kills_process(x: str) -> str:
66
+ """os._exit terminates the process immediately, bypassing all Python
67
+ exception handling — a real process crash, not an SDK-caught error,
68
+ to verify mcp-fuzz's connection-death detection and reconnect."""
69
+ import os
70
+
71
+ os._exit(1)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ server.run(transport="stdio")
@@ -0,0 +1,94 @@
1
+ """End-to-end tests: these actually launch the real fixture MCP server as a
2
+ subprocess and talk to it over real stdio — not mocked. Slower than a pure
3
+ unit test, but this is the whole point of the tool: verifying it correctly
4
+ classifies real runtime behavior, not just its own input-generation logic
5
+ (see test_generator.py for that)."""
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from mcp_fuzz.engine import run_fuzz
13
+ from mcp_fuzz.report import build_report
14
+
15
+ FIXTURE_SERVER = str(Path(__file__).parent / "fixtures" / "fixture_server.py")
16
+ TIMEOUT = 3.0
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def fuzz_report():
21
+ import asyncio
22
+
23
+ return asyncio.run(run_fuzz(sys.executable, [FIXTURE_SERVER], timeout=TIMEOUT))
24
+
25
+
26
+ def _tool(fuzz_report, name):
27
+ return next(t for t in fuzz_report.tools if t.name == name)
28
+
29
+
30
+ def test_connects_and_lists_all_five_tools(fuzz_report):
31
+ assert fuzz_report.connect_error is None
32
+ names = {t.name for t in fuzz_report.tools}
33
+ assert names == {
34
+ "well_behaved", "crashes_on_bad_input", "hangs_forever",
35
+ "delete_everything", "always_crashes", "kills_process",
36
+ }
37
+
38
+
39
+ def test_non_read_only_tool_is_skipped_by_default(fuzz_report):
40
+ tool = _tool(fuzz_report, "delete_everything")
41
+ assert tool.tested is False
42
+ assert "readOnlyHint" in tool.skip_reason
43
+
44
+
45
+ def test_well_behaved_tool_has_no_crashes(fuzz_report):
46
+ tool = _tool(fuzz_report, "well_behaved")
47
+ assert tool.tested is True
48
+ assert all(o.outcome != "crash" for o in tool.outcomes)
49
+ valid = next(o for o in tool.outcomes if o.case == "valid")
50
+ assert valid.outcome == "ok"
51
+
52
+
53
+ def test_hanging_tool_is_detected_as_timeout(fuzz_report):
54
+ tool = _tool(fuzz_report, "hangs_forever")
55
+ valid = next(o for o in tool.outcomes if o.case == "valid")
56
+ assert valid.outcome == "timeout"
57
+
58
+
59
+ def test_sdk_caught_exception_is_not_misreported_as_a_crash(fuzz_report):
60
+ # always_crashes raises inside its handler; the SDK converts that to a
61
+ # structured is_error response rather than killing the process — must
62
+ # be classified as an error, not a "crash" (process death).
63
+ tool = _tool(fuzz_report, "always_crashes")
64
+ valid = next(o for o in tool.outcomes if o.case == "valid")
65
+ assert valid.outcome == "valid_call_errored"
66
+
67
+
68
+ def test_process_death_is_detected_as_a_real_crash(fuzz_report):
69
+ tool = _tool(fuzz_report, "kills_process")
70
+ valid = next(o for o in tool.outcomes if o.case == "valid")
71
+ assert valid.outcome == "crash"
72
+
73
+
74
+ def test_engine_recovers_after_a_crash_and_keeps_testing(fuzz_report):
75
+ # kills_process is registered before crashes_on_bad_input/well_behaved
76
+ # doesn't matter — what matters is that tools registered *after* the
77
+ # crashing one in iteration order still get real results, not silently
78
+ # dropped because the connection died.
79
+ tool = _tool(fuzz_report, "kills_process")
80
+ non_valid = [o for o in tool.outcomes if o.case != "valid"]
81
+ assert len(non_valid) == 2 # missing_required + wrong_type for its one param
82
+ assert all(o.outcome != "crash" for o in non_valid) # rejected by schema validation, not a repeat crash
83
+
84
+
85
+ def test_report_scores_crash_resilience_without_penalizing_valid_call_errors(fuzz_report):
86
+ report = build_report(fuzz_report)
87
+ assert report.crash_resilience_percent == 100.0
88
+ assert report.grade == "A"
89
+ assert report.crash_count == 0
90
+ assert report.timeout_count == 0
91
+ # The valid-call issues (timeout, SDK-caught error, real crash) are
92
+ # real and surfaced, just not folded into the bad-input crash score.
93
+ flagged = [t for t in report.tools if t.valid_call_issue is not None]
94
+ assert {t.name for t in flagged} == {"hangs_forever", "always_crashes", "kills_process"}
@@ -0,0 +1,51 @@
1
+ """Regression tests for cross-`mcp`-SDK-version field access.
2
+
3
+ mcp<2.0 exposed several `types` fields under their raw camelCase wire name
4
+ directly (`isError`, `inputSchema`, `readOnlyHint`); mcp>=2.0 renamed them to
5
+ snake_case (`is_error`, `input_schema`, `read_only_hint`) with the camelCase
6
+ kept only as a validation alias, not a readable attribute. Found live:
7
+ installing a real target server (`arxiv-mcp-server`, pinned `mcp<2.0`) into
8
+ the same environment as mcp-fuzz downgraded the shared `mcp` package and
9
+ broke every hardcoded snake_case attribute access with an AttributeError.
10
+
11
+ These use plain `SimpleNamespace` stand-ins rather than a real old `mcp`
12
+ install (not pulled in as a test dependency) — `_field`/`_is_read_only` only
13
+ ever use `hasattr`/`getattr`, so a namespace missing the snake_case
14
+ attribute reproduces the real pydantic behavior exactly.
15
+ """
16
+
17
+ from types import SimpleNamespace
18
+
19
+ from mcp_fuzz.engine import _field, _is_read_only
20
+
21
+
22
+ def test_field_prefers_snake_case_when_present():
23
+ obj = SimpleNamespace(is_error=True, isError=False)
24
+ assert _field(obj, "is_error", "isError") is True
25
+
26
+
27
+ def test_field_falls_back_to_camel_case_when_snake_missing():
28
+ # mcp<2.0's shape — only the camelCase wire-format attribute exists.
29
+ obj = SimpleNamespace(isError=True)
30
+ assert _field(obj, "is_error", "isError") is True
31
+
32
+
33
+ def test_is_read_only_true_with_snake_case_annotations():
34
+ tool = SimpleNamespace(annotations=SimpleNamespace(read_only_hint=True))
35
+ assert _is_read_only(tool) is True
36
+
37
+
38
+ def test_is_read_only_true_with_camel_case_annotations():
39
+ # The exact real-world shape (mcp<2.0) that broke against
40
+ # arxiv-mcp-server with an AttributeError before this fix.
41
+ tool = SimpleNamespace(annotations=SimpleNamespace(readOnlyHint=True))
42
+ assert _is_read_only(tool) is True
43
+
44
+
45
+ def test_is_read_only_false_when_no_annotations():
46
+ assert _is_read_only(SimpleNamespace(annotations=None)) is False
47
+
48
+
49
+ def test_is_read_only_false_when_hint_is_false_or_none():
50
+ assert _is_read_only(SimpleNamespace(annotations=SimpleNamespace(read_only_hint=False))) is False
51
+ assert _is_read_only(SimpleNamespace(annotations=SimpleNamespace(read_only_hint=None))) is False
@@ -0,0 +1,115 @@
1
+ from mcp_fuzz.generator import (
2
+ generate_valid_arguments,
3
+ generate_valid_value,
4
+ missing_required_variants,
5
+ wrong_type_variants,
6
+ )
7
+
8
+
9
+ def test_string_respects_enum():
10
+ assert generate_valid_value({"type": "string", "enum": ["b", "a"]}) == "b"
11
+
12
+
13
+ def test_string_respects_format():
14
+ assert generate_valid_value({"type": "string", "format": "email"}) == "test@example.com"
15
+
16
+
17
+ def test_string_respects_min_length():
18
+ value = generate_valid_value({"type": "string", "minLength": 8})
19
+ assert len(value) >= 8
20
+
21
+
22
+ def test_integer_respects_minimum():
23
+ assert generate_valid_value({"type": "integer", "minimum": 5}) == 5
24
+
25
+
26
+ def test_number_respects_exclusive_minimum():
27
+ assert generate_valid_value({"type": "number", "exclusiveMinimum": 0}) == 1.0
28
+
29
+
30
+ def test_boolean():
31
+ assert generate_valid_value({"type": "boolean"}) is True
32
+
33
+
34
+ def test_array_uses_items_schema():
35
+ value = generate_valid_value({"type": "array", "items": {"type": "integer", "minimum": 3}})
36
+ assert value == [3]
37
+
38
+
39
+ def test_nested_object_fills_required_fields():
40
+ schema = {
41
+ "type": "object",
42
+ "properties": {
43
+ "name": {"type": "string"},
44
+ "age": {"type": "integer"},
45
+ },
46
+ "required": ["name"],
47
+ }
48
+ value = generate_valid_value(schema)
49
+ assert value == {"name": "test"}
50
+
51
+
52
+ def test_anyof_uses_first_branch():
53
+ schema = {"anyOf": [{"type": "integer", "minimum": 9}, {"type": "string"}]}
54
+ assert generate_valid_value(schema) == 9
55
+
56
+
57
+ def test_generate_valid_arguments_top_level_schema():
58
+ schema = {
59
+ "type": "object",
60
+ "properties": {
61
+ "query": {"type": "string"},
62
+ "limit": {"type": "integer", "default": 10},
63
+ },
64
+ "required": ["query"],
65
+ }
66
+ args = generate_valid_arguments(schema)
67
+ assert args == {"query": "test", "limit": 10}
68
+
69
+
70
+ def test_missing_required_variants_one_per_required_field():
71
+ schema = {
72
+ "type": "object",
73
+ "properties": {"a": {"type": "string"}, "b": {"type": "string"}},
74
+ "required": ["a", "b"],
75
+ }
76
+ variants = missing_required_variants(schema)
77
+ names = {name for name, _ in variants}
78
+ assert names == {"a", "b"}
79
+ for name, args in variants:
80
+ assert name not in args
81
+
82
+
83
+ def test_missing_required_variants_empty_when_nothing_required():
84
+ schema = {"type": "object", "properties": {"a": {"type": "string"}}}
85
+ assert missing_required_variants(schema) == []
86
+
87
+
88
+ def test_wrong_type_variants_swaps_each_typed_property():
89
+ schema = {
90
+ "type": "object",
91
+ "properties": {
92
+ "count": {"type": "integer"},
93
+ "flag": {"type": "boolean"},
94
+ },
95
+ "required": ["count", "flag"],
96
+ }
97
+ variants = wrong_type_variants(schema)
98
+ by_name = dict(variants)
99
+ assert isinstance(by_name["count"]["count"], str)
100
+ assert isinstance(by_name["flag"]["flag"], str)
101
+
102
+
103
+ def test_wrong_type_variants_skips_untyped_property():
104
+ schema = {
105
+ "type": "object",
106
+ "properties": {"anything": {"description": "no type declared"}},
107
+ "required": ["anything"],
108
+ }
109
+ assert wrong_type_variants(schema) == []
110
+
111
+
112
+ def test_empty_schema_produces_no_variants():
113
+ assert generate_valid_arguments(None) == {}
114
+ assert missing_required_variants(None) == []
115
+ assert wrong_type_variants(None) == []