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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/mcpmedic?cacheSeconds=300)](https://pypi.org/project/mcpmedic/)
22
+ [![CI](https://github.com/ZidoCode/mcp-doctor/actions/workflows/ci.yml/badge.svg)](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.
@@ -0,0 +1,48 @@
1
+ # mcp-doctor
2
+
3
+ **One command, every MCP audit — session hygiene, wire shapes, name collisions.**
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/mcpmedic?cacheSeconds=300)](https://pypi.org/project/mcpmedic/)
6
+ [![CI](https://github.com/ZidoCode/mcp-doctor/actions/workflows/ci.yml/badge.svg)](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,11 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with:
10
+ python-version: '3.11'
11
+ - run: pip install pytest && pytest -q
@@ -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()