mcp-diff 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,6 @@
1
+ products/signal-intel/.state.json
2
+ products/signal-intel/.env
3
+ *.env
4
+ .claude/worktrees/
5
+ __pycache__/
6
+ *.pyc
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-diff
3
+ Version: 0.1.0
4
+ Summary: Schema lockfile and breaking-change detector for MCP servers — like package-lock.json for MCP
5
+ License: MIT
6
+ Keywords: breaking-changes,ci,diff,mcp,model-context-protocol,schema
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mcp-diff
18
+
19
+ Schema lockfile and breaking-change detector for MCP servers.
20
+
21
+ **The problem:** MCP servers serve tool schemas at runtime. When a description changes, agent behavior changes silently — no diff, no CI failure, no warning.
22
+
23
+ **The solution:** Commit a `mcp-schema.lock` to git. Fail CI on breaking changes.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install mcp-diff
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Snapshot your server's current schema
35
+ mcp-diff snapshot python3 my_server.py
36
+
37
+ # Check for breaking changes (exits 1 if found)
38
+ mcp-diff check python3 my_server.py
39
+
40
+ # Human-readable report (always exits 0)
41
+ mcp-diff report python3 my_server.py
42
+ ```
43
+
44
+ ## Example output
45
+
46
+ ```
47
+ mcp-diff check python3 my_server.py
48
+
49
+ [BREAKING] read_file: Tool 'read_file' was removed.
50
+ [BREAKING] search_files.pattern: Parameter 'pattern' type changed: 'string' → 'array' in tool 'search_files'.
51
+ [WARNING] search_files: Tool description changed.
52
+ was: 'Search for files matching a pattern.'
53
+ now: 'Search files. Use glob patterns.'
54
+ [INFO] write_file: Tool 'write_file' was added.
55
+
56
+ Found 2 breaking, 1 warning, 1 info changes.
57
+ ```
58
+
59
+ ## Change severity
60
+
61
+ | Severity | When | CI impact |
62
+ |---|---|---|
63
+ | **breaking** | Tool removed, required param added/removed, param type changed | exits 1 |
64
+ | **warning** | Tool or param description changed (descriptions are behavioral contracts for LLMs) | exits 0 |
65
+ | **info** | Tool added, optional param added | exits 0 |
66
+
67
+ ## CI integration (GitHub Actions)
68
+
69
+ ```yaml
70
+ - name: Snapshot MCP schema
71
+ run: mcp-diff snapshot python3 my_server.py
72
+ # Commit mcp-schema.lock to your repo
73
+
74
+ - name: Check for breaking changes
75
+ run: mcp-diff check python3 my_server.py
76
+ # Exits 1 and fails the build if breaking changes are detected
77
+ ```
78
+
79
+ ## Lockfile format
80
+
81
+ ```json
82
+ {
83
+ "version": "1",
84
+ "created_at": "2026-03-22T03:00:00Z",
85
+ "command": "python3 my_server.py",
86
+ "tools": [
87
+ {
88
+ "name": "search_files",
89
+ "description": "Search for files matching a pattern",
90
+ "inputSchema": { "..." : "..." }
91
+ }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ Commit `mcp-schema.lock` to git. The diff in your PR is the schema diff.
97
+
98
+ ## Options
99
+
100
+ ```
101
+ mcp-diff snapshot [--output PATH] <command...>
102
+ mcp-diff check [--lockfile PATH] [--json] [--no-color] <command...>
103
+ mcp-diff report [--lockfile PATH] [--no-color] <command...>
104
+ ```
105
+
106
+ ## Exit codes
107
+
108
+ | Code | Meaning |
109
+ |---|---|
110
+ | 0 | Clean (no breaking changes) |
111
+ | 1 | Breaking changes detected |
112
+ | 2 | Error (missing lockfile, server failed to start) |
113
+
114
+ ## Part of the MCP developer toolkit
115
+
116
+ - [agent-friend](https://github.com/0-co/agent-friend) — schema quality linter
117
+ - [mcp-patch](https://github.com/0-co/mcp-patch) — AST security scanner
118
+ - [mcp-pytest](https://github.com/0-co/mcp-test) — testing framework
119
+ - [mcp-snoop](https://github.com/0-co/mcp-snoop) — stdio debugger
120
+ - **mcp-diff** — schema lockfile and breaking-change detector
121
+
122
+ Source: [github.com/0-co/mcp-diff](https://github.com/0-co/mcp-diff)
@@ -0,0 +1,106 @@
1
+ # mcp-diff
2
+
3
+ Schema lockfile and breaking-change detector for MCP servers.
4
+
5
+ **The problem:** MCP servers serve tool schemas at runtime. When a description changes, agent behavior changes silently — no diff, no CI failure, no warning.
6
+
7
+ **The solution:** Commit a `mcp-schema.lock` to git. Fail CI on breaking changes.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install mcp-diff
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Snapshot your server's current schema
19
+ mcp-diff snapshot python3 my_server.py
20
+
21
+ # Check for breaking changes (exits 1 if found)
22
+ mcp-diff check python3 my_server.py
23
+
24
+ # Human-readable report (always exits 0)
25
+ mcp-diff report python3 my_server.py
26
+ ```
27
+
28
+ ## Example output
29
+
30
+ ```
31
+ mcp-diff check python3 my_server.py
32
+
33
+ [BREAKING] read_file: Tool 'read_file' was removed.
34
+ [BREAKING] search_files.pattern: Parameter 'pattern' type changed: 'string' → 'array' in tool 'search_files'.
35
+ [WARNING] search_files: Tool description changed.
36
+ was: 'Search for files matching a pattern.'
37
+ now: 'Search files. Use glob patterns.'
38
+ [INFO] write_file: Tool 'write_file' was added.
39
+
40
+ Found 2 breaking, 1 warning, 1 info changes.
41
+ ```
42
+
43
+ ## Change severity
44
+
45
+ | Severity | When | CI impact |
46
+ |---|---|---|
47
+ | **breaking** | Tool removed, required param added/removed, param type changed | exits 1 |
48
+ | **warning** | Tool or param description changed (descriptions are behavioral contracts for LLMs) | exits 0 |
49
+ | **info** | Tool added, optional param added | exits 0 |
50
+
51
+ ## CI integration (GitHub Actions)
52
+
53
+ ```yaml
54
+ - name: Snapshot MCP schema
55
+ run: mcp-diff snapshot python3 my_server.py
56
+ # Commit mcp-schema.lock to your repo
57
+
58
+ - name: Check for breaking changes
59
+ run: mcp-diff check python3 my_server.py
60
+ # Exits 1 and fails the build if breaking changes are detected
61
+ ```
62
+
63
+ ## Lockfile format
64
+
65
+ ```json
66
+ {
67
+ "version": "1",
68
+ "created_at": "2026-03-22T03:00:00Z",
69
+ "command": "python3 my_server.py",
70
+ "tools": [
71
+ {
72
+ "name": "search_files",
73
+ "description": "Search for files matching a pattern",
74
+ "inputSchema": { "..." : "..." }
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ Commit `mcp-schema.lock` to git. The diff in your PR is the schema diff.
81
+
82
+ ## Options
83
+
84
+ ```
85
+ mcp-diff snapshot [--output PATH] <command...>
86
+ mcp-diff check [--lockfile PATH] [--json] [--no-color] <command...>
87
+ mcp-diff report [--lockfile PATH] [--no-color] <command...>
88
+ ```
89
+
90
+ ## Exit codes
91
+
92
+ | Code | Meaning |
93
+ |---|---|
94
+ | 0 | Clean (no breaking changes) |
95
+ | 1 | Breaking changes detected |
96
+ | 2 | Error (missing lockfile, server failed to start) |
97
+
98
+ ## Part of the MCP developer toolkit
99
+
100
+ - [agent-friend](https://github.com/0-co/agent-friend) — schema quality linter
101
+ - [mcp-patch](https://github.com/0-co/mcp-patch) — AST security scanner
102
+ - [mcp-pytest](https://github.com/0-co/mcp-test) — testing framework
103
+ - [mcp-snoop](https://github.com/0-co/mcp-snoop) — stdio debugger
104
+ - **mcp-diff** — schema lockfile and breaking-change detector
105
+
106
+ Source: [github.com/0-co/mcp-diff](https://github.com/0-co/mcp-diff)
@@ -0,0 +1,3 @@
1
+ """mcp-diff — Schema lockfile and breaking-change detector for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,304 @@
1
+ """CLI entry point for mcp-diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .diff import (
11
+ classify_changes,
12
+ deserialize_lockfile,
13
+ format_changes_json,
14
+ format_changes_text,
15
+ has_breaking,
16
+ serialize_lockfile,
17
+ )
18
+
19
+ DEFAULT_LOCKFILE = "mcp-schema.lock"
20
+
21
+
22
+ def _load_lockfile(path: str) -> dict:
23
+ """Load and parse a lockfile. Exits with code 2 on failure."""
24
+ p = Path(path)
25
+ if not p.exists():
26
+ print(
27
+ f"Error: lockfile not found: {path}\n"
28
+ "Run 'mcp-diff snapshot <command...>' first.",
29
+ file=sys.stderr,
30
+ )
31
+ sys.exit(2)
32
+ try:
33
+ return json.loads(p.read_text())
34
+ except json.JSONDecodeError as exc:
35
+ print(f"Error: invalid lockfile JSON: {exc}", file=sys.stderr)
36
+ sys.exit(2)
37
+
38
+
39
+ def _fetch_tools(command: list[str]) -> list[dict]:
40
+ """Start the MCP server and fetch its tool list. Exits with code 2 on failure."""
41
+ from .client import MCPClient, MCPError
42
+
43
+ try:
44
+ with MCPClient(command) as client:
45
+ return client.list_tools()
46
+ except MCPError as exc:
47
+ print(f"Error: failed to connect to MCP server: {exc}", file=sys.stderr)
48
+ sys.exit(2)
49
+ except FileNotFoundError:
50
+ print(
51
+ f"Error: command not found: {command[0]!r}. Check your command.",
52
+ file=sys.stderr,
53
+ )
54
+ sys.exit(2)
55
+ except Exception as exc: # noqa: BLE001
56
+ print(f"Error: unexpected failure starting server: {exc}", file=sys.stderr)
57
+ sys.exit(2)
58
+
59
+
60
+ def cmd_snapshot(args: argparse.Namespace) -> int:
61
+ """Snapshot the current schema to a lockfile."""
62
+ if not args.command:
63
+ print("Error: no command given. Usage: mcp-diff snapshot <command...>", file=sys.stderr)
64
+ return 2
65
+
66
+ tools = _fetch_tools(args.command)
67
+ lockfile = serialize_lockfile(tools, args.command)
68
+
69
+ out_path = args.output or DEFAULT_LOCKFILE
70
+ Path(out_path).write_text(json.dumps(lockfile, indent=2) + "\n")
71
+ print(f"Snapshot saved: {len(tools)} tool{'s' if len(tools) != 1 else ''} \u2192 {out_path}")
72
+ return 0
73
+
74
+
75
+ def cmd_check(args: argparse.Namespace) -> int:
76
+ """Check for breaking changes against the lockfile."""
77
+ if not args.command:
78
+ print("Error: no command given. Usage: mcp-diff check <command...>", file=sys.stderr)
79
+ return 2
80
+
81
+ lockfile_path = args.lockfile or DEFAULT_LOCKFILE
82
+ lock_data = _load_lockfile(lockfile_path)
83
+ old_tools = deserialize_lockfile(lock_data)
84
+
85
+ new_tools = _fetch_tools(args.command)
86
+ changes = classify_changes(old_tools, new_tools)
87
+
88
+ use_color = not args.no_color and sys.stderr.isatty()
89
+
90
+ if args.json:
91
+ print(format_changes_json(changes))
92
+ else:
93
+ output = format_changes_text(changes, color=use_color)
94
+ print(output, file=sys.stderr)
95
+
96
+ if changes:
97
+ breaking = [c for c in changes if c.severity == "breaking"]
98
+ warnings = [c for c in changes if c.severity == "warning"]
99
+ info = [c for c in changes if c.severity == "info"]
100
+ summary_parts = []
101
+ if breaking:
102
+ label = "\033[31mbreaking\033[0m" if use_color else "breaking"
103
+ summary_parts.append(f"{len(breaking)} {label}")
104
+ if warnings:
105
+ label = "\033[33mwarning\033[0m" if use_color else "warning"
106
+ summary_parts.append(f"{len(warnings)} {label}")
107
+ if info:
108
+ label = "\033[32minfo\033[0m" if use_color else "info"
109
+ summary_parts.append(f"{len(info)} {label}")
110
+ print(f"\nFound {', '.join(summary_parts)} change{'s' if len(changes) != 1 else ''}.",
111
+ file=sys.stderr)
112
+ else:
113
+ ok = "\033[32mOK\033[0m" if use_color else "OK"
114
+ print(f"{ok} No changes detected.", file=sys.stderr)
115
+
116
+ return 1 if has_breaking(changes) else 0
117
+
118
+
119
+ def cmd_report(args: argparse.Namespace) -> int:
120
+ """Generate a verbose report; always exits 0."""
121
+ if not args.command:
122
+ print("Error: no command given. Usage: mcp-diff report <command...>", file=sys.stderr)
123
+ return 2
124
+
125
+ lockfile_path = args.lockfile or DEFAULT_LOCKFILE
126
+ lock_data = _load_lockfile(lockfile_path)
127
+ old_tools = deserialize_lockfile(lock_data)
128
+
129
+ new_tools = _fetch_tools(args.command)
130
+ changes = classify_changes(old_tools, new_tools)
131
+
132
+ use_color = not args.no_color and sys.stdout.isatty()
133
+
134
+ # Header
135
+ print("mcp-diff report")
136
+ print("=" * 60)
137
+ print(f"Lockfile : {lockfile_path}")
138
+ print(f"Created : {lock_data.get('created_at', 'unknown')}")
139
+ print(f"Command : {lock_data.get('command', 'unknown')}")
140
+ print(f"Baseline : {len(old_tools)} tool{'s' if len(old_tools) != 1 else ''}")
141
+ print(f"Current : {len(new_tools)} tool{'s' if len(new_tools) != 1 else ''}")
142
+ print()
143
+
144
+ if not changes:
145
+ ok = "\033[32m\u2713 No changes detected.\033[0m" if use_color else "OK No changes detected."
146
+ print(ok)
147
+ return 0
148
+
149
+ breaking = [c for c in changes if c.severity == "breaking"]
150
+ warnings = [c for c in changes if c.severity == "warning"]
151
+ info = [c for c in changes if c.severity == "info"]
152
+
153
+ if breaking:
154
+ hdr = "\033[31mBreaking changes\033[0m" if use_color else "Breaking changes"
155
+ print(f"{hdr} ({len(breaking)})")
156
+ print("-" * 40)
157
+ for c in breaking:
158
+ loc = f"{c.tool}" + (f".{c.param}" if c.param else "")
159
+ print(f" [{c.kind}] {loc}")
160
+ for line in c.detail.splitlines():
161
+ print(f" {line}")
162
+ print()
163
+
164
+ if warnings:
165
+ hdr = "\033[33mWarnings\033[0m" if use_color else "Warnings"
166
+ print(f"{hdr} ({len(warnings)})")
167
+ print("-" * 40)
168
+ for c in warnings:
169
+ loc = f"{c.tool}" + (f".{c.param}" if c.param else "")
170
+ print(f" [{c.kind}] {loc}")
171
+ for line in c.detail.splitlines():
172
+ print(f" {line}")
173
+ print()
174
+
175
+ if info:
176
+ hdr = "\033[32mInformational\033[0m" if use_color else "Informational"
177
+ print(f"{hdr} ({len(info)})")
178
+ print("-" * 40)
179
+ for c in info:
180
+ loc = f"{c.tool}" + (f".{c.param}" if c.param else "")
181
+ print(f" [{c.kind}] {loc}")
182
+ for line in c.detail.splitlines():
183
+ print(f" {line}")
184
+ print()
185
+
186
+ print("=" * 60)
187
+ parts = []
188
+ if breaking:
189
+ parts.append(f"{len(breaking)} breaking")
190
+ if warnings:
191
+ parts.append(f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}")
192
+ if info:
193
+ parts.append(f"{len(info)} info")
194
+ print(f"Summary: {', '.join(parts)}")
195
+
196
+ return 0
197
+
198
+
199
+ def build_parser() -> argparse.ArgumentParser:
200
+ parser = argparse.ArgumentParser(
201
+ prog="mcp-diff",
202
+ description="Schema lockfile and breaking-change detector for MCP servers.",
203
+ )
204
+ parser.add_argument(
205
+ "--version", action="version", version="mcp-diff 0.1.0"
206
+ )
207
+ sub = parser.add_subparsers(dest="subcommand", metavar="<command>")
208
+
209
+ # ---- snapshot ----
210
+ snap = sub.add_parser(
211
+ "snapshot",
212
+ help="Snapshot an MCP server's schema to a lockfile.",
213
+ description="Start the MCP server, fetch its tool list, and save to a lockfile.",
214
+ )
215
+ snap.add_argument(
216
+ "command",
217
+ nargs=argparse.REMAINDER,
218
+ metavar="command",
219
+ help="Command to start the MCP server (e.g. python3 my_server.py).",
220
+ )
221
+ snap.add_argument(
222
+ "--output", "-o",
223
+ metavar="PATH",
224
+ help=f"Output lockfile path (default: {DEFAULT_LOCKFILE}).",
225
+ )
226
+
227
+ # ---- check ----
228
+ chk = sub.add_parser(
229
+ "check",
230
+ help="Check for breaking changes (exits 1 if found).",
231
+ description=(
232
+ "Compare the live MCP server schema against the lockfile. "
233
+ "Exits 1 if breaking changes are found, 0 if clean."
234
+ ),
235
+ )
236
+ chk.add_argument(
237
+ "command",
238
+ nargs=argparse.REMAINDER,
239
+ metavar="command",
240
+ help="Command to start the MCP server.",
241
+ )
242
+ chk.add_argument(
243
+ "--lockfile", "-l",
244
+ metavar="PATH",
245
+ help=f"Lockfile path (default: {DEFAULT_LOCKFILE}).",
246
+ )
247
+ chk.add_argument(
248
+ "--json",
249
+ action="store_true",
250
+ help="Output changes as JSON to stdout.",
251
+ )
252
+ chk.add_argument(
253
+ "--no-color",
254
+ action="store_true",
255
+ help="Disable ANSI color output.",
256
+ )
257
+
258
+ # ---- report ----
259
+ rep = sub.add_parser(
260
+ "report",
261
+ help="Verbose change report (always exits 0).",
262
+ description="Same as check but always exits 0 and prints a detailed report.",
263
+ )
264
+ rep.add_argument(
265
+ "command",
266
+ nargs=argparse.REMAINDER,
267
+ metavar="command",
268
+ help="Command to start the MCP server.",
269
+ )
270
+ rep.add_argument(
271
+ "--lockfile", "-l",
272
+ metavar="PATH",
273
+ help=f"Lockfile path (default: {DEFAULT_LOCKFILE}).",
274
+ )
275
+ rep.add_argument(
276
+ "--no-color",
277
+ action="store_true",
278
+ help="Disable ANSI color output.",
279
+ )
280
+
281
+ return parser
282
+
283
+
284
+ def main() -> None:
285
+ parser = build_parser()
286
+ args = parser.parse_args()
287
+
288
+ if not args.subcommand:
289
+ parser.print_help()
290
+ sys.exit(0)
291
+
292
+ if args.subcommand == "snapshot":
293
+ sys.exit(cmd_snapshot(args))
294
+ elif args.subcommand == "check":
295
+ sys.exit(cmd_check(args))
296
+ elif args.subcommand == "report":
297
+ sys.exit(cmd_report(args))
298
+ else:
299
+ parser.print_help()
300
+ sys.exit(1)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()
@@ -0,0 +1,145 @@
1
+ """MCP client for mcp-diff — connects to a server via stdio."""
2
+
3
+ import json
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ from typing import Any
8
+
9
+
10
+ class MCPError(Exception):
11
+ """Raised when an MCP call fails or times out."""
12
+
13
+
14
+ class MCPClient:
15
+ """Stdio client for connecting to MCP servers.
16
+
17
+ Usage::
18
+
19
+ server = MCPClient(["python", "my_server.py"])
20
+ tools = server.list_tools()
21
+ server.close()
22
+
23
+ Or as a context manager::
24
+
25
+ with MCPClient(["python", "my_server.py"]) as server:
26
+ tools = server.list_tools()
27
+ """
28
+
29
+ def __init__(self, command: list[str] | str, timeout: float = 30.0):
30
+ """Start the MCP server process and initialize the session.
31
+
32
+ Args:
33
+ command: Command to start the server (list or shell string).
34
+ timeout: Default timeout in seconds for each call.
35
+ """
36
+ if isinstance(command, str):
37
+ command = command.split()
38
+ self.command = command
39
+ self.timeout = timeout
40
+ self._msg_id = 0
41
+ self._process: subprocess.Popen | None = None
42
+ self._lock = threading.Lock()
43
+ self._start()
44
+
45
+ # ------------------------------------------------------------------
46
+ # Lifecycle
47
+ # ------------------------------------------------------------------
48
+
49
+ def _start(self) -> None:
50
+ self._process = subprocess.Popen(
51
+ self.command,
52
+ stdin=subprocess.PIPE,
53
+ stdout=subprocess.PIPE,
54
+ stderr=subprocess.PIPE,
55
+ text=True,
56
+ bufsize=1,
57
+ )
58
+ # Initialize the MCP session
59
+ self._call_raw("initialize", {
60
+ "protocolVersion": "2024-11-05",
61
+ "capabilities": {},
62
+ "clientInfo": {"name": "mcp-diff", "version": "0.1.0"},
63
+ })
64
+ # Send initialized notification (no response expected)
65
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
66
+
67
+ def close(self) -> None:
68
+ """Terminate the server process."""
69
+ if self._process and self._process.poll() is None:
70
+ self._process.stdin.close()
71
+ try:
72
+ self._process.wait(timeout=5)
73
+ except subprocess.TimeoutExpired:
74
+ self._process.kill()
75
+
76
+ def __enter__(self):
77
+ return self
78
+
79
+ def __exit__(self, *_):
80
+ self.close()
81
+
82
+ # ------------------------------------------------------------------
83
+ # Low-level JSON-RPC
84
+ # ------------------------------------------------------------------
85
+
86
+ def _next_id(self) -> int:
87
+ with self._lock:
88
+ self._msg_id += 1
89
+ return self._msg_id
90
+
91
+ def _send(self, msg: dict) -> None:
92
+ line = json.dumps(msg) + "\n"
93
+ self._process.stdin.write(line)
94
+ self._process.stdin.flush()
95
+
96
+ def _recv(self, deadline: float) -> dict:
97
+ """Read lines until we get a JSON-RPC response (has 'id')."""
98
+ import select
99
+ while True:
100
+ if time.time() > deadline:
101
+ raise MCPError("Timeout waiting for server response")
102
+ ready, _, _ = select.select([self._process.stdout], [], [], 0.1)
103
+ if not ready:
104
+ if self._process.poll() is not None:
105
+ stderr = self._process.stderr.read()
106
+ raise MCPError(f"Server exited unexpectedly. Stderr: {stderr[:500]}")
107
+ continue
108
+ line = self._process.stdout.readline()
109
+ if not line:
110
+ raise MCPError("Server closed stdout")
111
+ try:
112
+ msg = json.loads(line)
113
+ if "id" in msg:
114
+ return msg
115
+ # Notification or log — skip
116
+ except json.JSONDecodeError:
117
+ pass # Skip non-JSON lines (server log output etc.)
118
+
119
+ def _call_raw(self, method: str, params: dict) -> dict:
120
+ """Send a JSON-RPC request and return the raw response."""
121
+ req_id = self._next_id()
122
+ msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
123
+ self._send(msg)
124
+ deadline = time.time() + self.timeout
125
+ response = self._recv(deadline)
126
+ if "error" in response:
127
+ raise MCPError(f"RPC error: {response['error']}")
128
+ return response.get("result", {})
129
+
130
+ # ------------------------------------------------------------------
131
+ # Public API
132
+ # ------------------------------------------------------------------
133
+
134
+ def list_tools(self) -> list[dict]:
135
+ """Return the list of tools the server exposes.
136
+
137
+ Returns:
138
+ List of tool dicts with 'name', 'description', 'inputSchema'.
139
+ """
140
+ result = self._call_raw("tools/list", {})
141
+ return result.get("tools", [])
142
+
143
+ def tool_names(self) -> list[str]:
144
+ """Return just the tool names."""
145
+ return [t["name"] for t in self.list_tools()]