mcpmedic 0.1.0__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.
- mcpmedic-0.1.0/PKG-INFO +64 -0
- mcpmedic-0.1.0/README.md +48 -0
- mcpmedic-0.1.0/ci.yml +11 -0
- mcpmedic-0.1.0/mcp_collision.py +225 -0
- mcpmedic-0.1.0/mcp_doctor.py +109 -0
- mcpmedic-0.1.0/mcp_schema_audit.py +222 -0
- mcpmedic-0.1.0/mcp_session_audit.py +239 -0
- mcpmedic-0.1.0/pyproject.toml +28 -0
- mcpmedic-0.1.0/test_mcp_doctor.py +110 -0
mcpmedic-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcpmedic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One command, every MCP audit - session hygiene, wire shapes, name collisions. Zero dependencies.
|
|
5
|
+
Author: ZidoCode
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agent,audit,doctor,jsonrpc,llm,mcp,model-context-protocol
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# mcp-doctor
|
|
18
|
+
|
|
19
|
+
**One command, every MCP audit — session hygiene, wire shapes, name collisions.**
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/mcpmedic/)
|
|
22
|
+
[](https://github.com/ZidoCode/mcp-doctor/actions)
|
|
23
|
+
|
|
24
|
+
MCP integrations break in three places and none of them crash:
|
|
25
|
+
|
|
26
|
+
| Where | What breaks | Auditor |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| **Recorded session** | dangling tool_call_id poisons later requests ([opencode #44852](https://github.com/anomalyco/opencode/issues/44852)), swallowed `isError` ([zeroclaw #7122](https://github.com/zeroclaw-labs/zeroclaw/pull/7122)), error-channel confusion ([memoria #258](https://github.com/matrixorigin/memoria/issues/258)) | `session` — [mcp-session-audit](https://github.com/ZidoCode/mcp-session-audit) |
|
|
29
|
+
| **Advertised listings** | schemas that make strict clients silently reject tools ([gitlab-mcp #286](https://github.com/zereight/gitlab-mcp/issues/286), [langfuse #13804](https://github.com/langfuse/langfuse/issues/13804), [strapi #27395](https://github.com/strapi/strapi/issues/27395)) | `schema` — [mcp-schema-audit](https://github.com/ZidoCode/mcp-schema-audit) |
|
|
30
|
+
| **Tool names** | collision between servers makes the agent call the wrong tool ([VSCode #324992](https://github.com/microsoft/vscode/issues/324992), Copilot #11480) | `collide` — [mcp-collision](https://github.com/ZidoCode/mcp-collision) |
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install mcpmedic
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
mcp-doctor check recording.jsonl # session + schema, one pass
|
|
42
|
+
mcp-doctor session recording.jsonl # session hygiene only
|
|
43
|
+
mcp-doctor schema recording.jsonl # wire-shape conformance only
|
|
44
|
+
mcp-doctor collide --client claude # name collisions across installed servers
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Input: JSONL of recorded MCP messages — a CI log, a debug dump, a user-reported
|
|
48
|
+
session file. No live server needed.
|
|
49
|
+
|
|
50
|
+
```jsonl
|
|
51
|
+
{"direction":"request","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
|
|
52
|
+
{"direction":"response","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"srv","version":"1.0"}}}
|
|
53
|
+
{"direction":"response","id":2,"result":{"tools":[{"name":"search","inputSchema":{"type":"object","properties":{}}}]}}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit codes (CI-friendly): **0** clean · **1** warnings · **2** critical.
|
|
57
|
+
|
|
58
|
+
## Member auditors
|
|
59
|
+
|
|
60
|
+
- [mcp-session-audit](https://github.com/ZidoCode/mcp-session-audit) — dangling calls, orphan/duplicate responses, isError swallowed, error-as-text, unknown tools
|
|
61
|
+
- [mcp-schema-audit](https://github.com/ZidoCode/mcp-schema-audit) — empty schemas, root type violations, draft-07 tuples, duplicate names, protocol version sanity
|
|
62
|
+
- [mcp-collision](https://github.com/ZidoCode/mcp-collision) — predicts tool-name collisions before your agent does
|
|
63
|
+
|
|
64
|
+
MIT License.
|
mcpmedic-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# mcp-doctor
|
|
2
|
+
|
|
3
|
+
**One command, every MCP audit — session hygiene, wire shapes, name collisions.**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/mcpmedic/)
|
|
6
|
+
[](https://github.com/ZidoCode/mcp-doctor/actions)
|
|
7
|
+
|
|
8
|
+
MCP integrations break in three places and none of them crash:
|
|
9
|
+
|
|
10
|
+
| Where | What breaks | Auditor |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| **Recorded session** | dangling tool_call_id poisons later requests ([opencode #44852](https://github.com/anomalyco/opencode/issues/44852)), swallowed `isError` ([zeroclaw #7122](https://github.com/zeroclaw-labs/zeroclaw/pull/7122)), error-channel confusion ([memoria #258](https://github.com/matrixorigin/memoria/issues/258)) | `session` — [mcp-session-audit](https://github.com/ZidoCode/mcp-session-audit) |
|
|
13
|
+
| **Advertised listings** | schemas that make strict clients silently reject tools ([gitlab-mcp #286](https://github.com/zereight/gitlab-mcp/issues/286), [langfuse #13804](https://github.com/langfuse/langfuse/issues/13804), [strapi #27395](https://github.com/strapi/strapi/issues/27395)) | `schema` — [mcp-schema-audit](https://github.com/ZidoCode/mcp-schema-audit) |
|
|
14
|
+
| **Tool names** | collision between servers makes the agent call the wrong tool ([VSCode #324992](https://github.com/microsoft/vscode/issues/324992), Copilot #11480) | `collide` — [mcp-collision](https://github.com/ZidoCode/mcp-collision) |
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
pip install mcpmedic
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
mcp-doctor check recording.jsonl # session + schema, one pass
|
|
26
|
+
mcp-doctor session recording.jsonl # session hygiene only
|
|
27
|
+
mcp-doctor schema recording.jsonl # wire-shape conformance only
|
|
28
|
+
mcp-doctor collide --client claude # name collisions across installed servers
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Input: JSONL of recorded MCP messages — a CI log, a debug dump, a user-reported
|
|
32
|
+
session file. No live server needed.
|
|
33
|
+
|
|
34
|
+
```jsonl
|
|
35
|
+
{"direction":"request","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
|
|
36
|
+
{"direction":"response","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"srv","version":"1.0"}}}
|
|
37
|
+
{"direction":"response","id":2,"result":{"tools":[{"name":"search","inputSchema":{"type":"object","properties":{}}}]}}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Exit codes (CI-friendly): **0** clean · **1** warnings · **2** critical.
|
|
41
|
+
|
|
42
|
+
## Member auditors
|
|
43
|
+
|
|
44
|
+
- [mcp-session-audit](https://github.com/ZidoCode/mcp-session-audit) — dangling calls, orphan/duplicate responses, isError swallowed, error-as-text, unknown tools
|
|
45
|
+
- [mcp-schema-audit](https://github.com/ZidoCode/mcp-schema-audit) — empty schemas, root type violations, draft-07 tuples, duplicate names, protocol version sanity
|
|
46
|
+
- [mcp-collision](https://github.com/ZidoCode/mcp-collision) — predicts tool-name collisions before your agent does
|
|
47
|
+
|
|
48
|
+
MIT License.
|
mcpmedic-0.1.0/ci.yml
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp-collision — predict MCP tool name collisions before your client does.
|
|
3
|
+
|
|
4
|
+
Multiple MCP servers installed side-by-side can expose tools with the same
|
|
5
|
+
name. Clients react differently: some prefix (server__tool), some silently
|
|
6
|
+
drop, some shadow. The collision is invisible until your agent calls the
|
|
7
|
+
wrong tool (VSCode #324992, CopilotKit #2409, PraisonAI #2711 — and the
|
|
8
|
+
shadowing bug class behind Hermes #72032).
|
|
9
|
+
|
|
10
|
+
mcp-collision reads your client's MCP config, talks to each server over
|
|
11
|
+
stdio MCP (initialize -> tools/list), collects every tool name, and reports
|
|
12
|
+
exactly which calls would be ambiguous — per sanitization scheme.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 mcp_collision.py scan <config.json> [--client claude|cursor|cline|raw]
|
|
16
|
+
python3 mcp_collision.py check <tools1.json> <tools2.json> [...] # pre-dumped lists
|
|
17
|
+
|
|
18
|
+
Config format: any JSON with a "mcpServers" object (Claude Desktop,
|
|
19
|
+
Cursor .mcp.json, Cline, etc.), stdio servers only (command-based).
|
|
20
|
+
|
|
21
|
+
Zero dependencies. Python 3.8+.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------- sanitize
|
|
34
|
+
|
|
35
|
+
def sanitize(client, server, tool):
|
|
36
|
+
"""How a client names a tool from server S with tool name T."""
|
|
37
|
+
if client == "raw":
|
|
38
|
+
return tool
|
|
39
|
+
sep = "__" if client in ("claude", "cline") else "-"
|
|
40
|
+
name = f"{server}{sep}{tool}"
|
|
41
|
+
if client == "claude":
|
|
42
|
+
# Claude Code sanitizes invalid chars per Hermes #72032 class of bugs:
|
|
43
|
+
# chars outside [a-zA-Z0-9_-] collapse to _, so different raw names
|
|
44
|
+
# can collapse to the same sanitized name.
|
|
45
|
+
name = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
|
46
|
+
return name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- mcp stdio
|
|
50
|
+
|
|
51
|
+
def list_tools(command, cwd=None, env_extra=None, timeout=20):
|
|
52
|
+
"""Spawn an MCP stdio server, initialize, return its tools/list.
|
|
53
|
+
Returns (tools, error). tools = list of {name, description}."""
|
|
54
|
+
env = dict(os.environ)
|
|
55
|
+
if env_extra:
|
|
56
|
+
env.update(env_extra)
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stdin=subprocess.PIPE,
|
|
59
|
+
stderr=subprocess.DEVNULL, cwd=cwd, env=env, text=True)
|
|
60
|
+
except OSError as e:
|
|
61
|
+
return None, f"cannot spawn {command[0]!r}: {e}"
|
|
62
|
+
|
|
63
|
+
def send(obj):
|
|
64
|
+
proc.stdin.write(json.dumps(obj) + "\n")
|
|
65
|
+
proc.stdin.flush()
|
|
66
|
+
|
|
67
|
+
def recv():
|
|
68
|
+
line = proc.stdout.readline()
|
|
69
|
+
if not line:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(line)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
78
|
+
"protocolVersion": "2024-11-05",
|
|
79
|
+
"capabilities": {},
|
|
80
|
+
"clientInfo": {"name": "mcp-collision", "version": "0.1"}}})
|
|
81
|
+
resp = recv()
|
|
82
|
+
if not resp or "result" not in (resp or {}):
|
|
83
|
+
return None, f"no initialize response ({(resp or {}).get('error', {}).get('message', 'timeout/invalid')})"
|
|
84
|
+
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
85
|
+
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
|
86
|
+
# tolerate notifications between response frames
|
|
87
|
+
for _ in range(10):
|
|
88
|
+
resp = recv()
|
|
89
|
+
if resp is None:
|
|
90
|
+
break
|
|
91
|
+
if resp.get("id") == 2:
|
|
92
|
+
break
|
|
93
|
+
if not resp or resp.get("id") != 2:
|
|
94
|
+
return None, "no tools/list response"
|
|
95
|
+
tools = resp["result"].get("tools") or []
|
|
96
|
+
return tools, None
|
|
97
|
+
finally:
|
|
98
|
+
try:
|
|
99
|
+
proc.stdin.close()
|
|
100
|
+
proc.wait(timeout=5)
|
|
101
|
+
except Exception:
|
|
102
|
+
proc.kill()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_config(path):
|
|
106
|
+
"""Load an MCP config file, return {server_name: {command: [...], env: {}}}."""
|
|
107
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
108
|
+
text = f.read()
|
|
109
|
+
# tolerate JSONC: strip // and /* */ comments, trailing commas
|
|
110
|
+
text = re.sub(r"//[^\n]*", "", text)
|
|
111
|
+
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
|
112
|
+
text = re.sub(r",(\s*[}\]])", r"\1", text)
|
|
113
|
+
cfg = json.loads(text)
|
|
114
|
+
servers = cfg.get("mcpServers") or cfg.get("mcp_servers") or {}
|
|
115
|
+
if not isinstance(servers, dict):
|
|
116
|
+
raise ValueError("mcpServers is not an object")
|
|
117
|
+
return servers
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------- analysis
|
|
121
|
+
|
|
122
|
+
def analyze(tool_map, client):
|
|
123
|
+
"""tool_map: {server: [tool names]}. Returns (collisions, sanitized_names).
|
|
124
|
+
collisions: {final_name: [server, tool, ...]} with len > 1."""
|
|
125
|
+
sanitized = {} # final name -> [(server, raw_tool)]
|
|
126
|
+
for server, tools in tool_map.items():
|
|
127
|
+
for t in tools:
|
|
128
|
+
final = sanitize(client, server, t)
|
|
129
|
+
sanitized.setdefault(final, []).append((server, t))
|
|
130
|
+
collisions = {k: v for k, v in sanitized.items() if len(v) > 1}
|
|
131
|
+
return collisions, sanitized
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------- CLI
|
|
135
|
+
|
|
136
|
+
def main(argv=None):
|
|
137
|
+
ap = argparse.ArgumentParser(prog="mcp-collision",
|
|
138
|
+
description="Predict MCP tool name collisions before your client does.")
|
|
139
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
140
|
+
|
|
141
|
+
p_scan = sub.add_parser("scan", help="scan an MCP config file (live servers)")
|
|
142
|
+
p_scan.add_argument("config")
|
|
143
|
+
p_scan.add_argument("--client", default="claude",
|
|
144
|
+
choices=["claude", "cursor", "cline", "raw"])
|
|
145
|
+
p_scan.add_argument("--timeout", type=int, default=20)
|
|
146
|
+
p_scan.add_argument("--json", action="store_true", help="machine-readable output")
|
|
147
|
+
|
|
148
|
+
p_check = sub.add_parser("check", help="check pre-dumped tool name lists")
|
|
149
|
+
p_check.add_argument("files", nargs="+")
|
|
150
|
+
p_check.add_argument("--client", default="claude",
|
|
151
|
+
choices=["claude", "cursor", "cline", "raw"])
|
|
152
|
+
p_check.add_argument("--json", action="store_true", help="machine-readable output")
|
|
153
|
+
|
|
154
|
+
args = ap.parse_args(argv)
|
|
155
|
+
args.timeout = max(1, getattr(args, "timeout", 20))
|
|
156
|
+
|
|
157
|
+
if args.cmd == "check":
|
|
158
|
+
tool_map = {}
|
|
159
|
+
for f in args.files:
|
|
160
|
+
name = os.path.splitext(os.path.basename(f))[0]
|
|
161
|
+
with open(f, "r", encoding="utf-8") as fh:
|
|
162
|
+
data = json.load(fh)
|
|
163
|
+
tools = data if isinstance(data, list) else data.get("tools", [])
|
|
164
|
+
tool_map[name] = [t if isinstance(t, str) else t.get("name", "?") for t in tools]
|
|
165
|
+
return _report(tool_map, args.client, args.json)
|
|
166
|
+
|
|
167
|
+
# scan
|
|
168
|
+
try:
|
|
169
|
+
servers = load_config(args.config)
|
|
170
|
+
except OSError as e:
|
|
171
|
+
print(f"error: cannot read config {args.config}: {e}", file=sys.stderr)
|
|
172
|
+
return 2
|
|
173
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
174
|
+
print(f"error: invalid config {args.config}: {e}", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
if not servers:
|
|
177
|
+
print("no mcpServers found in config")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
tool_map = {}
|
|
181
|
+
failures = []
|
|
182
|
+
for name, spec in servers.items():
|
|
183
|
+
cmd = spec.get("command")
|
|
184
|
+
if not cmd:
|
|
185
|
+
failures.append((name, "not a stdio server (no command) — remote servers skipped"))
|
|
186
|
+
continue
|
|
187
|
+
command = [cmd] + (spec.get("args") or [])
|
|
188
|
+
tools, err = list_tools(command, env_extra=spec.get("env"), timeout=args.timeout)
|
|
189
|
+
if err:
|
|
190
|
+
failures.append((name, err))
|
|
191
|
+
else:
|
|
192
|
+
tool_map[name] = [t.get("name", "?") for t in tools]
|
|
193
|
+
|
|
194
|
+
rc = _report(tool_map, args.client, args.json)
|
|
195
|
+
for name, err in failures:
|
|
196
|
+
print(f" skipped {name}: {err}", file=sys.stderr)
|
|
197
|
+
return rc
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _report(tool_map, client, as_json):
|
|
201
|
+
total = sum(len(v) for v in tool_map.values())
|
|
202
|
+
collisions, _ = analyze(tool_map, client)
|
|
203
|
+
if as_json:
|
|
204
|
+
print(json.dumps({
|
|
205
|
+
"client": client, "servers": {k: len(v) for k, v in tool_map.items()},
|
|
206
|
+
"total_tools": total, "collisions": {k: v for k, v in collisions.items()},
|
|
207
|
+
}, indent=2))
|
|
208
|
+
else:
|
|
209
|
+
print(f"client: {client} — {len(tool_map)} servers, {total} tools")
|
|
210
|
+
for s in sorted(tool_map):
|
|
211
|
+
print(f" {s:24s} {len(tool_map[s]):>3} tools")
|
|
212
|
+
if not collisions:
|
|
213
|
+
print("\nno collisions detected")
|
|
214
|
+
return 0
|
|
215
|
+
print(f"\n{len(collisions)} COLLISION(S):")
|
|
216
|
+
for final in sorted(collisions):
|
|
217
|
+
print(f" {final}")
|
|
218
|
+
for server, tool in collisions[final]:
|
|
219
|
+
print(f" <- {server}: {tool}")
|
|
220
|
+
print("\nthese calls are ambiguous — your client picks one, you may never know which.")
|
|
221
|
+
return 2 if collisions else 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp-doctor — one command, every MCP audit.
|
|
3
|
+
|
|
4
|
+
MCP integrations break in three places and none of them crash: the recorded
|
|
5
|
+
session (dangling tool calls, swallowed isError, error-channel confusion),
|
|
6
|
+
the advertised listings (schemas that make strict clients silently reject
|
|
7
|
+
tools), and the tool names themselves (collision between servers that makes
|
|
8
|
+
an agent call the wrong tool). Each of those has a dedicated auditor; this
|
|
9
|
+
is the one door that runs them.
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
doctor session <recording.jsonl> session hygiene audit
|
|
13
|
+
doctor schema <recording.jsonl> wire-shape conformance audit
|
|
14
|
+
doctor collide --client <claude|cursor|cline|raw> [--timeout N]
|
|
15
|
+
tool-name collision scan
|
|
16
|
+
doctor check <recording.jsonl> session + schema in one pass
|
|
17
|
+
|
|
18
|
+
Each subcommand inherits its module's exit codes (0 clean / 1 warnings /
|
|
19
|
+
2 critical) so CI can gate on any of them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
import mcp_session_audit
|
|
27
|
+
import mcp_schema_audit
|
|
28
|
+
|
|
29
|
+
VERSION = "0.1.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_jsonl(path):
|
|
33
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
34
|
+
return [ln for ln in (l.strip() for l in f) if ln]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_session(args):
|
|
38
|
+
return mcp_session_audit.report(args.recording,
|
|
39
|
+
mcp_session_audit.audit(_load_jsonl(args.recording)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_schema(args):
|
|
43
|
+
return mcp_schema_audit.report(args.recording,
|
|
44
|
+
mcp_schema_audit.audit(_load_jsonl(args.recording)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_check(args):
|
|
48
|
+
"""Session + schema on the same recording, one combined report."""
|
|
49
|
+
lines = _load_jsonl(args.recording)
|
|
50
|
+
s_findings = mcp_session_audit.audit(list(lines))
|
|
51
|
+
sch_findings = mcp_schema_audit.audit(list(lines))
|
|
52
|
+
combined = s_findings + sch_findings
|
|
53
|
+
print(f"mcp-doctor check: {args.recording} (session + schema)")
|
|
54
|
+
if not combined:
|
|
55
|
+
print(" ALL CLEAR — session hygiene and wire shapes are both client-safe.")
|
|
56
|
+
return 0
|
|
57
|
+
for sev, code, msg in combined:
|
|
58
|
+
sym = {"CRITICAL": "[CRITICAL]", "WARNING": "[WARNING ]", "INFO": "[INFO ]"}[sev]
|
|
59
|
+
print(f" {sym} [{code}] {msg}")
|
|
60
|
+
counts = {"CRITICAL": 0, "WARNING": 0, "INFO": 0}
|
|
61
|
+
for sev, _, _ in combined:
|
|
62
|
+
counts[sev] += 1
|
|
63
|
+
parts = [f"{counts[s]} {s.lower()}" for s in ("CRITICAL", "WARNING", "INFO") if counts[s]]
|
|
64
|
+
print(f"\n verdict: {', '.join(parts)}.")
|
|
65
|
+
if counts["CRITICAL"]:
|
|
66
|
+
print(" the session or listing will fail in a way the model or client cannot see.")
|
|
67
|
+
return 2
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_collide(args):
|
|
72
|
+
import mcp_collision
|
|
73
|
+
argv = ["--client", args.client]
|
|
74
|
+
if args.timeout:
|
|
75
|
+
argv += ["--timeout", str(args.timeout)]
|
|
76
|
+
return mcp_collision.main(argv)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv=None):
|
|
80
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
81
|
+
ap = argparse.ArgumentParser(
|
|
82
|
+
prog="mcp-doctor",
|
|
83
|
+
description="One command, every MCP audit — session hygiene, wire shapes, name collisions.")
|
|
84
|
+
ap.add_argument("--version", action="version", version=f"mcp-doctor {VERSION}")
|
|
85
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
86
|
+
|
|
87
|
+
s = sub.add_parser("session", help="session hygiene audit (dangling calls, swallowed isError)")
|
|
88
|
+
s.add_argument("recording")
|
|
89
|
+
s.set_defaults(fn=cmd_session)
|
|
90
|
+
|
|
91
|
+
s = sub.add_parser("schema", help="wire-shape conformance audit (client-rejecting schemas)")
|
|
92
|
+
s.add_argument("recording")
|
|
93
|
+
s.set_defaults(fn=cmd_schema)
|
|
94
|
+
|
|
95
|
+
s = sub.add_parser("check", help="session + schema combined, one pass")
|
|
96
|
+
s.add_argument("recording")
|
|
97
|
+
s.set_defaults(fn=cmd_check)
|
|
98
|
+
|
|
99
|
+
s = sub.add_parser("collide", help="tool-name collision scan across installed servers")
|
|
100
|
+
s.add_argument("--client", default="claude", choices=["claude", "cursor", "cline", "raw"])
|
|
101
|
+
s.add_argument("--timeout", type=int, default=20)
|
|
102
|
+
s.set_defaults(fn=cmd_collide)
|
|
103
|
+
|
|
104
|
+
args = ap.parse_args(argv)
|
|
105
|
+
return args.fn(args)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp-schema-audit — audit recorded MCP listings for client-rejecting wire shapes.
|
|
3
|
+
|
|
4
|
+
MCP servers publish broken tool listings in ways that are invisible on the
|
|
5
|
+
server side: the endpoint returns 200, no log fires, and the failure happens
|
|
6
|
+
entirely in the client's validation — tools get silently dropped (langfuse
|
|
7
|
+
#13804), the whole tool list is rejected (gitlab-mcp #286, strapi #27395), or
|
|
8
|
+
a dropped schema is replaced with {} and the model has to reverse-engineer
|
|
9
|
+
arguments one failed call at a time (prime-agent #1073). Quality linters
|
|
10
|
+
(mcp-schema-lint, mcp-assert) grade descriptions and naming; none of them
|
|
11
|
+
check the shapes that make strict clients REJECT tools outright.
|
|
12
|
+
|
|
13
|
+
This tool does. Input: JSONL of recorded MCP messages (same format as
|
|
14
|
+
mcp-session-audit):
|
|
15
|
+
|
|
16
|
+
{"direction":"request","id":1,"method":"initialize","params":{...}}
|
|
17
|
+
{"direction":"response","id":1,"result":{"protocolVersion":"2025-11-25",...}}
|
|
18
|
+
{"direction":"response","id":2,"result":{"tools":[...]}}
|
|
19
|
+
|
|
20
|
+
Checks:
|
|
21
|
+
1. inputSchema missing or {} — clients reject or drop the tool
|
|
22
|
+
2. inputSchema.type missing/not "object" — TS SDK rejects (langfuse #13804)
|
|
23
|
+
3. draft-07 dialect + tuple items:[...] — strict 2020-12 clients reject
|
|
24
|
+
everything (strapi #27395)
|
|
25
|
+
4. duplicate tool names
|
|
26
|
+
5. protocolVersion echoed / negotiated that is not a known MCP revision
|
|
27
|
+
(go-sdk #1033 echoed 2026-07-28 through the legacy handshake)
|
|
28
|
+
6. initialize result missing serverInfo name/version
|
|
29
|
+
7. empty listing — a tools/list result with zero tools
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 clean, 1 warnings only, 2 any CRITICAL.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
CRITICAL = "CRITICAL"
|
|
38
|
+
WARNING = "WARNING"
|
|
39
|
+
INFO = "INFO"
|
|
40
|
+
|
|
41
|
+
# known MCP protocol revisions (legacy era + modern era)
|
|
42
|
+
KNOWN_VERSIONS = {
|
|
43
|
+
"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25",
|
|
44
|
+
"2026-03-01", "2026-07-28",
|
|
45
|
+
}
|
|
46
|
+
DRAFT07 = "http://json-schema.org/draft-07/schema"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _schema_findings(where, schema, findings):
|
|
50
|
+
"""Validate one tool schema dict for rejection-blocking shapes."""
|
|
51
|
+
if schema is None:
|
|
52
|
+
findings.append((CRITICAL, "schema-missing",
|
|
53
|
+
f"{where}: inputSchema missing — clients cannot construct "
|
|
54
|
+
"arguments; many drop the tool (gitlab-mcp #286)"))
|
|
55
|
+
return
|
|
56
|
+
if not isinstance(schema, dict):
|
|
57
|
+
findings.append((WARNING, "schema-not-object",
|
|
58
|
+
f"{where}: inputSchema is {type(schema).__name__}, not an object"))
|
|
59
|
+
return
|
|
60
|
+
if not schema:
|
|
61
|
+
findings.append((CRITICAL, "schema-empty",
|
|
62
|
+
f"{where}: inputSchema is {{}} — violates the MCP schema spec; "
|
|
63
|
+
"strict clients reject the tool (gitlab-mcp #286, prime-agent #1073)"))
|
|
64
|
+
return
|
|
65
|
+
if schema.get("type") != "object":
|
|
66
|
+
findings.append((CRITICAL, "root-type-not-object",
|
|
67
|
+
f"{where}: inputSchema.type is {schema.get('type')!r}, expected "
|
|
68
|
+
"'object' — MCP TS SDK rejects the tool (langfuse #13804)"))
|
|
69
|
+
dialect = schema.get("$schema")
|
|
70
|
+
if isinstance(dialect, str) and "draft-07" in dialect:
|
|
71
|
+
# structural draft-07: tuple syntax items:[...] (invalid in 2020-12)
|
|
72
|
+
def _tuples(node, path="$"):
|
|
73
|
+
hits = []
|
|
74
|
+
if isinstance(node, dict):
|
|
75
|
+
if isinstance(node.get("items"), list):
|
|
76
|
+
hits.append(path + ".items")
|
|
77
|
+
for k, v in node.items():
|
|
78
|
+
hits.extend(_tuples(v, f"{path}.{k}"))
|
|
79
|
+
elif isinstance(node, list):
|
|
80
|
+
for i, v in enumerate(node):
|
|
81
|
+
hits.extend(_tuples(v, f"{path}[{i}]"))
|
|
82
|
+
return hits
|
|
83
|
+
tuples = _tuples(schema)
|
|
84
|
+
if tuples:
|
|
85
|
+
findings.append((CRITICAL, "draft07-dialect",
|
|
86
|
+
f"{where}: advertises draft-07 with tuple syntax "
|
|
87
|
+
f"({tuples[0]}) — strict 2020-12 clients reject the tool "
|
|
88
|
+
"(strapi #27395, TS SDK #2084)"))
|
|
89
|
+
else:
|
|
90
|
+
findings.append((WARNING, "draft07-dialect",
|
|
91
|
+
f"{where}: advertises draft-07 — SEP-1613 default is "
|
|
92
|
+
"2020-12; strict validators may reject"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def audit(lines):
|
|
96
|
+
"""Audit parsed JSONL lines. Returns list of (severity, code, msg)."""
|
|
97
|
+
findings = []
|
|
98
|
+
requested_versions = [] # protocolVersion the client asked for
|
|
99
|
+
server_version = None
|
|
100
|
+
server_info = None
|
|
101
|
+
tool_lists = 0
|
|
102
|
+
saw_init = False
|
|
103
|
+
total_tools = 0
|
|
104
|
+
names = {}
|
|
105
|
+
|
|
106
|
+
for n, line in enumerate(lines, 1):
|
|
107
|
+
if isinstance(line, str):
|
|
108
|
+
try:
|
|
109
|
+
line = json.loads(line)
|
|
110
|
+
except (json.JSONDecodeError, ValueError):
|
|
111
|
+
findings.append((WARNING, "unparseable-line", f"line {n}: not valid JSON"))
|
|
112
|
+
continue
|
|
113
|
+
if not isinstance(line, dict):
|
|
114
|
+
findings.append((WARNING, "unparseable-line", f"line {n}: not an object"))
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
method = line.get("method")
|
|
118
|
+
result = line.get("result")
|
|
119
|
+
params = line.get("params")
|
|
120
|
+
|
|
121
|
+
# client's requested version
|
|
122
|
+
if method == "initialize" and isinstance(params, dict):
|
|
123
|
+
rv = params.get("protocolVersion")
|
|
124
|
+
if rv:
|
|
125
|
+
requested_versions.append(rv)
|
|
126
|
+
|
|
127
|
+
if not isinstance(result, dict):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# initialize result
|
|
131
|
+
if "serverInfo" in result or "protocolVersion" in result:
|
|
132
|
+
saw_init = True
|
|
133
|
+
server_version = result.get("protocolVersion")
|
|
134
|
+
server_info = result.get("serverInfo")
|
|
135
|
+
if server_version is not None and str(server_version) not in KNOWN_VERSIONS:
|
|
136
|
+
findings.append((CRITICAL, "unknown-protocol-version",
|
|
137
|
+
f"line {n}: server negotiated {server_version!r} — not a "
|
|
138
|
+
"known MCP revision; clients will fail to bind session "
|
|
139
|
+
"semantics (go-sdk #1033 echoed a modern era version "
|
|
140
|
+
"through the legacy handshake)"))
|
|
141
|
+
if requested_versions and server_version is not None:
|
|
142
|
+
if str(requested_versions[-1]) != str(server_version):
|
|
143
|
+
findings.append((INFO, "version-renegotiated",
|
|
144
|
+
f"line {n}: client requested "
|
|
145
|
+
f"{requested_versions[-1]!r}, server answered "
|
|
146
|
+
f"{server_version!r} — legal (MUST respond with a "
|
|
147
|
+
"supported version) but worth confirming intent "
|
|
148
|
+
"(ferrum-edge #2168 hard-errored instead)"))
|
|
149
|
+
|
|
150
|
+
# tools/list result
|
|
151
|
+
tools = result.get("tools")
|
|
152
|
+
if isinstance(tools, list):
|
|
153
|
+
tool_lists += 1
|
|
154
|
+
total_tools += len(tools)
|
|
155
|
+
if not tools:
|
|
156
|
+
findings.append((WARNING, "empty-tools",
|
|
157
|
+
f"line {n}: tools/list returned zero tools — server "
|
|
158
|
+
"exposes nothing; most UIs show a dead connector"))
|
|
159
|
+
for t in tools:
|
|
160
|
+
if not isinstance(t, dict):
|
|
161
|
+
continue
|
|
162
|
+
name = t.get("name")
|
|
163
|
+
if not isinstance(name, str) or not name:
|
|
164
|
+
findings.append((CRITICAL, "tool-unnamed",
|
|
165
|
+
f"line {n}: tool without a name — uncallable"))
|
|
166
|
+
continue
|
|
167
|
+
if name in names:
|
|
168
|
+
findings.append((CRITICAL, "duplicate-tool",
|
|
169
|
+
f"line {n}: duplicate tool name '{name}' (first seen "
|
|
170
|
+
f"line {names[name]}) — only one can be called"))
|
|
171
|
+
names[name] = n
|
|
172
|
+
_schema_findings(f"tool '{name}' (line {n})", t.get("inputSchema"),
|
|
173
|
+
findings)
|
|
174
|
+
|
|
175
|
+
if saw_init and (not isinstance(server_info, dict) or not server_info.get("name")):
|
|
176
|
+
findings.append((WARNING, "serverinfo-unnamed",
|
|
177
|
+
"initialize result has no serverInfo.name — shows up as a "
|
|
178
|
+
"blank connector in logs and UIs"))
|
|
179
|
+
if not saw_init and (tool_lists or requested_versions):
|
|
180
|
+
findings.append((WARNING, "no-initialize",
|
|
181
|
+
"no initialize result in the recording — can't audit "
|
|
182
|
+
"negotiation or serverInfo"))
|
|
183
|
+
|
|
184
|
+
return findings
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
SEV_SYM = {CRITICAL: "[CRITICAL]", WARNING: "[WARNING ]", INFO: "[INFO ]"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def report(path, findings):
|
|
191
|
+
counts = {CRITICAL: 0, WARNING: 0, INFO: 0}
|
|
192
|
+
for sev, _, _ in findings:
|
|
193
|
+
counts[sev] += 1
|
|
194
|
+
print(f"mcp-schema-audit: {path}")
|
|
195
|
+
if not findings:
|
|
196
|
+
print(" ALL CLEAR — every tool schema is client-acceptable, negotiation is sane.")
|
|
197
|
+
return 0
|
|
198
|
+
for sev, code, msg in findings:
|
|
199
|
+
print(f" {SEV_SYM[sev]} {msg}")
|
|
200
|
+
parts = [f"{counts[s]} {s.lower()}" for s in (CRITICAL, WARNING, INFO) if counts[s]]
|
|
201
|
+
print(f"\n verdict: {', '.join(parts)}.")
|
|
202
|
+
if counts[CRITICAL]:
|
|
203
|
+
print(" strict clients will silently drop these tools or refuse the listing entirely.")
|
|
204
|
+
return 2
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def load_lines(path):
|
|
209
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
210
|
+
return [ln for ln in (l.strip() for l in f) if ln]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv=None):
|
|
214
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
215
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
216
|
+
print(__doc__)
|
|
217
|
+
return 0
|
|
218
|
+
return report(argv[0], audit(load_lines(argv[0])))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp-session-audit — audit a recorded MCP session for protocol hygiene failures.
|
|
3
|
+
|
|
4
|
+
MCP sessions fail in ways that never surface as crashes: a tool call whose
|
|
5
|
+
result was lost mid-batch poisons every later request with dangling
|
|
6
|
+
tool_call_ids (opencode #44852); an isError:true envelope gets swallowed and
|
|
7
|
+
the model reads the error text as output (zeroclaw #7122, claude-agent-sdk
|
|
8
|
+
#717/#564); an execution failure is delivered as a JSON-RPC error so the
|
|
9
|
+
model never learns why its call failed (memoria #258); a validator clobbers
|
|
10
|
+
isError back to success and inflates the success rate (IBM mcp-context-forge
|
|
11
|
+
#4202). All of these are visible in a recorded transcript — if something
|
|
12
|
+
actually looks.
|
|
13
|
+
|
|
14
|
+
This tool looks. Input: JSONL where each line is one recorded message:
|
|
15
|
+
{"direction":"request","id":1,"method":"tools/call","params":{...}}
|
|
16
|
+
{"direction":"response","id":1,"result":{...}} # or "error":{...}
|
|
17
|
+
(direction defaults to "response" when a line has a result/error and no
|
|
18
|
+
method; bare JSON-RPC batches and raw client/server logs are tolerated.)
|
|
19
|
+
|
|
20
|
+
Checks:
|
|
21
|
+
1. dangling tool call — a request with an id that never got a response
|
|
22
|
+
2. orphan response — a response whose id was never requested
|
|
23
|
+
3. duplicate response — two responses for the same id
|
|
24
|
+
4. isError swallowed — isError:true with no is_error flag upstream and
|
|
25
|
+
content that reads as ordinary output
|
|
26
|
+
5. error-channel confusion — execution failures delivered as JSON-RPC
|
|
27
|
+
errors (model never sees the text), or error text
|
|
28
|
+
inside a successful result with no isError flag
|
|
29
|
+
6. unknown tool — tools/call for a name absent from a recorded
|
|
30
|
+
tools/list result
|
|
31
|
+
|
|
32
|
+
Exit codes: 0 = clean, 1 = warnings only, 2 = any CRITICAL finding.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
CRITICAL = "CRITICAL"
|
|
39
|
+
WARNING = "WARNING"
|
|
40
|
+
INFO = "INFO"
|
|
41
|
+
|
|
42
|
+
ERROR_CODES = {
|
|
43
|
+
-32700: "parse error",
|
|
44
|
+
-32600: "invalid request",
|
|
45
|
+
-32601: "method not found",
|
|
46
|
+
-32602: "invalid params",
|
|
47
|
+
-32603: "internal error",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _err_text(result):
|
|
52
|
+
"""Extract text content from a CallToolResult-shaped result."""
|
|
53
|
+
parts = []
|
|
54
|
+
content = result.get("content")
|
|
55
|
+
if isinstance(content, list):
|
|
56
|
+
for block in content:
|
|
57
|
+
if isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
58
|
+
parts.append(block["text"])
|
|
59
|
+
return "\n".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def audit(lines):
|
|
63
|
+
"""Audit a list of parsed JSONL lines. Returns list of (severity, code, msg)."""
|
|
64
|
+
findings = []
|
|
65
|
+
requests = {} # id -> method
|
|
66
|
+
responded = set() # ids that have any response
|
|
67
|
+
error_responded = set()
|
|
68
|
+
known_tools = None
|
|
69
|
+
exec_failures_as_rpc = 0
|
|
70
|
+
|
|
71
|
+
for n, line in enumerate(lines, 1):
|
|
72
|
+
if isinstance(line, str):
|
|
73
|
+
try:
|
|
74
|
+
line = json.loads(line)
|
|
75
|
+
except (json.JSONDecodeError, ValueError):
|
|
76
|
+
findings.append((WARNING, "unparseable-line",
|
|
77
|
+
f"line {n}: not valid JSON, skipped"))
|
|
78
|
+
continue
|
|
79
|
+
if not isinstance(line, dict):
|
|
80
|
+
findings.append((WARNING, "unparseable-line",
|
|
81
|
+
f"line {n}: not an object, skipped"))
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
direction = line.get("direction")
|
|
85
|
+
rid = line.get("id")
|
|
86
|
+
method = line.get("method")
|
|
87
|
+
|
|
88
|
+
# sniff tools/list response for the known-tool set
|
|
89
|
+
if direction == "response" or (method is None and "result" in line):
|
|
90
|
+
result = line.get("result")
|
|
91
|
+
if isinstance(result, dict) and isinstance(result.get("tools"), list):
|
|
92
|
+
known_tools = {t.get("name") for t in result["tools"]
|
|
93
|
+
if isinstance(t, dict)}
|
|
94
|
+
if rid is not None and rid not in requests:
|
|
95
|
+
requests[rid] = "tools/list"
|
|
96
|
+
elif isinstance(result, dict) and "serverInfo" in result:
|
|
97
|
+
if rid is not None and rid not in requests:
|
|
98
|
+
requests[rid] = "initialize"
|
|
99
|
+
|
|
100
|
+
if direction == "request" or method is not None:
|
|
101
|
+
if method == "tools/list" and rid is not None:
|
|
102
|
+
requests[rid] = method
|
|
103
|
+
elif method == "tools/call":
|
|
104
|
+
if rid is None:
|
|
105
|
+
findings.append((WARNING, "notification-no-id",
|
|
106
|
+
f"line {n}: tools/call without an id — "
|
|
107
|
+
"fire-and-forget calls cannot be paired with results"))
|
|
108
|
+
else:
|
|
109
|
+
requests[rid] = method
|
|
110
|
+
params = line.get("params")
|
|
111
|
+
name = params.get("name") if isinstance(params, dict) else None
|
|
112
|
+
if known_tools is not None and name not in known_tools:
|
|
113
|
+
findings.append((CRITICAL, "unknown-tool",
|
|
114
|
+
f"line {n}: tools/call for '{name}' — not in the "
|
|
115
|
+
"session's tools/list result (client will hang or "
|
|
116
|
+
"get an unhandled error)"))
|
|
117
|
+
elif method is not None and rid is not None:
|
|
118
|
+
requests[rid] = method
|
|
119
|
+
|
|
120
|
+
# response shape: has result XOR error
|
|
121
|
+
has_result = "result" in line
|
|
122
|
+
has_error = "error" in line
|
|
123
|
+
if has_result and has_error:
|
|
124
|
+
findings.append((CRITICAL, "both-result-and-error",
|
|
125
|
+
f"line {n}: response carries both 'result' and 'error' — "
|
|
126
|
+
"receivers must pick one; behavior undefined"))
|
|
127
|
+
if not has_result and not has_error and method is None:
|
|
128
|
+
continue
|
|
129
|
+
if not has_result and not has_error:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if rid is None:
|
|
133
|
+
findings.append((WARNING, "response-no-id",
|
|
134
|
+
f"line {n}: response without an id — cannot be paired "
|
|
135
|
+
"with a request"))
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if rid in responded:
|
|
139
|
+
findings.append((CRITICAL, "duplicate-response",
|
|
140
|
+
f"line {n}: second response for id {rid} — clients "
|
|
141
|
+
"deserialize one; the other is lost or corrupts state"))
|
|
142
|
+
responded.add(rid)
|
|
143
|
+
|
|
144
|
+
if has_error:
|
|
145
|
+
error_responded.add(rid)
|
|
146
|
+
err = line["error"]
|
|
147
|
+
if not isinstance(err, dict) or "code" not in err or "message" not in err:
|
|
148
|
+
findings.append((CRITICAL, "malformed-error",
|
|
149
|
+
f"line {n}: 'error' is not a JSON-RPC error object "
|
|
150
|
+
"(needs code + message)"))
|
|
151
|
+
elif requests.get(rid) == "tools/call":
|
|
152
|
+
exec_failures_as_rpc += 1
|
|
153
|
+
|
|
154
|
+
if has_result and isinstance(line.get("result"), dict):
|
|
155
|
+
result = line["result"]
|
|
156
|
+
is_err = result.get("isError")
|
|
157
|
+
text = _err_text(result)
|
|
158
|
+
if is_err is True:
|
|
159
|
+
if not text.strip():
|
|
160
|
+
findings.append((WARNING, "iserror-empty",
|
|
161
|
+
f"line {n}: isError:true but content carries no "
|
|
162
|
+
"text — the model gets the failure with no detail "
|
|
163
|
+
"and cannot self-correct"))
|
|
164
|
+
else:
|
|
165
|
+
# success-shaped result whose text *reads* like an error
|
|
166
|
+
low = text.lower()
|
|
167
|
+
if text and (low.startswith("error") or "unknown tool" in low
|
|
168
|
+
or low.startswith("auth failed") or low.startswith("failed")
|
|
169
|
+
or " 403 " in low):
|
|
170
|
+
findings.append((WARNING, "error-as-text",
|
|
171
|
+
f"line {n}: result looks successful (no isError) "
|
|
172
|
+
f"but text reads as a failure: {text[:60]!r} — "
|
|
173
|
+
"clients that trust isError will read this as "
|
|
174
|
+
"output (mcp-era-probe channel-3 behavior)"))
|
|
175
|
+
|
|
176
|
+
# dangling / orphan pairing
|
|
177
|
+
for rid, method in sorted(requests.items()):
|
|
178
|
+
if rid not in responded:
|
|
179
|
+
sev = CRITICAL if method == "tools/call" else WARNING
|
|
180
|
+
findings.append((sev, "dangling-call",
|
|
181
|
+
f"request id {rid} ({method}) never got a response — "
|
|
182
|
+
"dangling tool_call_id poisons all later requests "
|
|
183
|
+
"(opencode #44852)"))
|
|
184
|
+
for rid in sorted(responded - set(requests)):
|
|
185
|
+
if rid not in error_responded or requests:
|
|
186
|
+
findings.append((WARNING, "orphan-response",
|
|
187
|
+
f"response for id {rid} but no matching request — "
|
|
188
|
+
"recorder dropped the request or ids desynced"))
|
|
189
|
+
|
|
190
|
+
if exec_failures_as_rpc >= 2:
|
|
191
|
+
findings.append((WARNING, "exec-failure-as-rpc-error",
|
|
192
|
+
f"{exec_failures_as_rpc} tools/call failures delivered as "
|
|
193
|
+
"JSON-RPC errors — execution failures belong in an isError "
|
|
194
|
+
"result so the model can self-correct (memoria #258)"))
|
|
195
|
+
|
|
196
|
+
return findings
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
SEV_ORDER = {CRITICAL: 0, WARNING: 1, INFO: 2}
|
|
200
|
+
SEV_SYM = {CRITICAL: "[CRITICAL]", WARNING: "[WARNING ]", INFO: "[INFO ]"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def report(path, findings):
|
|
204
|
+
lines = []
|
|
205
|
+
counts = {s: 0 for s in SEV_ORDER}
|
|
206
|
+
for sev, code, msg in findings:
|
|
207
|
+
counts[sev] += 1
|
|
208
|
+
lines.append(f" {SEV_SYM[sev]} {msg}")
|
|
209
|
+
print(f"mcp-session-audit: {path}")
|
|
210
|
+
if not lines:
|
|
211
|
+
print(" ALL CLEAR — no dangling calls, no swallowed errors, no channel confusion.")
|
|
212
|
+
return 0
|
|
213
|
+
for ln in lines:
|
|
214
|
+
print(ln)
|
|
215
|
+
parts = [f"{counts[s]} {s.lower()}" for s in (CRITICAL, WARNING, INFO) if counts[s]]
|
|
216
|
+
print(f"\n verdict: {', '.join(parts)}.")
|
|
217
|
+
if counts[CRITICAL]:
|
|
218
|
+
print(" the session will hang, poison later requests, or feed errors to the model as output.")
|
|
219
|
+
return 2
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_lines(path):
|
|
224
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
225
|
+
return [ln for ln in (l.strip() for l in f) if ln]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv=None):
|
|
229
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
230
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
231
|
+
print(__doc__)
|
|
232
|
+
return 0
|
|
233
|
+
path = argv[0]
|
|
234
|
+
findings = audit(load_lines(path))
|
|
235
|
+
return report(path, findings)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
sys.exit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcpmedic"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "One command, every MCP audit - session hygiene, wire shapes, name collisions. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [{ name = "ZidoCode" }]
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "audit", "doctor", "jsonrpc", "llm", "agent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
mcpmedic = "mcp_doctor:main"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
only-include = ["mcp_doctor.py", "mcp_session_audit.py", "mcp_schema_audit.py", "mcp_collision.py"]
|
|
28
|
+
sources = ["."]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Tests for mcp-doctor — umbrella CLI over session/schema/collision auditors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
import mcp_doctor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def jl(obj):
|
|
16
|
+
return json.dumps(obj)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
INIT_OK = {"direction": "response", "id": 1, "result": {
|
|
20
|
+
"protocolVersion": "2025-11-25", "serverInfo": {"name": "srv", "version": "1.0"}}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write(path, lines):
|
|
24
|
+
with open(path, "w") as fh:
|
|
25
|
+
fh.write("\n".join(lines))
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestCLI(unittest.TestCase):
|
|
30
|
+
def setUp(self):
|
|
31
|
+
self.d = tempfile.mkdtemp()
|
|
32
|
+
self.clean = write(os.path.join(self.d, "clean.jsonl"), [
|
|
33
|
+
jl(INIT_OK),
|
|
34
|
+
jl({"direction": "request", "id": 2, "method": "tools/call",
|
|
35
|
+
"params": {"name": "t"}}),
|
|
36
|
+
jl({"direction": "response", "id": 2,
|
|
37
|
+
"result": {"content": [{"type": "text", "text": "ok"}]}}),
|
|
38
|
+
jl({"direction": "response", "id": 3, "result": {"tools": [
|
|
39
|
+
{"name": "t", "inputSchema": {"type": "object", "properties": {}}}]}}),
|
|
40
|
+
])
|
|
41
|
+
self.dirty = write(os.path.join(self.d, "dirty.jsonl"), [
|
|
42
|
+
jl(INIT_OK),
|
|
43
|
+
jl({"direction": "request", "id": 2, "method": "tools/call",
|
|
44
|
+
"params": {"name": "t"}}),
|
|
45
|
+
# dangling: no response for id 2
|
|
46
|
+
jl({"direction": "response", "id": 3, "result": {"tools": [
|
|
47
|
+
{"name": "t", "inputSchema": {}} # schema-empty
|
|
48
|
+
]}}),
|
|
49
|
+
])
|
|
50
|
+
self.script = os.path.join(os.path.dirname(mcp_doctor.__file__), "mcp_doctor.py")
|
|
51
|
+
|
|
52
|
+
def _run(self, *argv):
|
|
53
|
+
return subprocess.run([sys.executable, self.script, *argv],
|
|
54
|
+
capture_output=True, text=True)
|
|
55
|
+
|
|
56
|
+
def test_help(self):
|
|
57
|
+
r = self._run("--help")
|
|
58
|
+
self.assertEqual(r.returncode, 0)
|
|
59
|
+
for cmd in ("session", "schema", "check", "collide"):
|
|
60
|
+
self.assertIn(cmd, r.stdout)
|
|
61
|
+
|
|
62
|
+
def test_session_clean(self):
|
|
63
|
+
r = self._run("session", self.clean)
|
|
64
|
+
self.assertEqual(r.returncode, 0)
|
|
65
|
+
|
|
66
|
+
def test_session_dirty(self):
|
|
67
|
+
r = self._run("session", self.dirty)
|
|
68
|
+
self.assertEqual(r.returncode, 2)
|
|
69
|
+
self.assertIn("dangling", r.stdout)
|
|
70
|
+
|
|
71
|
+
def test_schema_dirty(self):
|
|
72
|
+
r = self._run("schema", self.dirty)
|
|
73
|
+
self.assertEqual(r.returncode, 2)
|
|
74
|
+
self.assertIn("gitlab-mcp #286", r.stdout)
|
|
75
|
+
|
|
76
|
+
def test_check_clean(self):
|
|
77
|
+
r = self._run("check", self.clean)
|
|
78
|
+
self.assertEqual(r.returncode, 0)
|
|
79
|
+
|
|
80
|
+
def test_check_dirty_combines(self):
|
|
81
|
+
r = self._run("check", self.dirty)
|
|
82
|
+
self.assertEqual(r.returncode, 2)
|
|
83
|
+
# both audit families fire
|
|
84
|
+
self.assertIn("dangling", r.stdout)
|
|
85
|
+
self.assertIn("inputSchema", r.stdout)
|
|
86
|
+
|
|
87
|
+
def test_check_counts(self):
|
|
88
|
+
r = self._run("check", self.dirty)
|
|
89
|
+
self.assertIn("verdict:", r.stdout)
|
|
90
|
+
|
|
91
|
+
def test_no_args_exits_nonzero(self):
|
|
92
|
+
r = self._run()
|
|
93
|
+
self.assertNotEqual(r.returncode, 0)
|
|
94
|
+
|
|
95
|
+
def test_version(self):
|
|
96
|
+
r = self._run("--version")
|
|
97
|
+
self.assertEqual(r.returncode, 0)
|
|
98
|
+
self.assertIn("0.1.0", r.stdout)
|
|
99
|
+
|
|
100
|
+
def test_modules_are_same_auditors(self):
|
|
101
|
+
# vendored modules must be importable and functional
|
|
102
|
+
self.assertTrue(hasattr(mcp_doctor.mcp_session_audit, "audit"))
|
|
103
|
+
self.assertTrue(hasattr(mcp_doctor.mcp_schema_audit, "audit"))
|
|
104
|
+
f = mcp_doctor.mcp_session_audit.audit([jl({"direction": "request", "id": 1,
|
|
105
|
+
"method": "tools/call", "params": {}})])
|
|
106
|
+
self.assertTrue(f)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
unittest.main()
|