mcp-diff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_diff/__init__.py +3 -0
- mcp_diff/cli.py +304 -0
- mcp_diff/client.py +145 -0
- mcp_diff/diff.py +261 -0
- mcp_diff-0.1.0.dist-info/METADATA +122 -0
- mcp_diff-0.1.0.dist-info/RECORD +8 -0
- mcp_diff-0.1.0.dist-info/WHEEL +4 -0
- mcp_diff-0.1.0.dist-info/entry_points.txt +2 -0
mcp_diff/__init__.py
ADDED
mcp_diff/cli.py
ADDED
|
@@ -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()
|
mcp_diff/client.py
ADDED
|
@@ -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()]
|
mcp_diff/diff.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Schema diff engine for mcp-diff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SEVERITY_BREAKING = "breaking"
|
|
11
|
+
SEVERITY_WARNING = "warning"
|
|
12
|
+
SEVERITY_INFO = "info"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Change:
|
|
17
|
+
"""Represents a single detected change between two schemas."""
|
|
18
|
+
|
|
19
|
+
kind: str
|
|
20
|
+
"""
|
|
21
|
+
One of: 'removed', 'added', 'description_changed',
|
|
22
|
+
'param_added_required', 'param_removed', 'param_type_changed',
|
|
23
|
+
'param_description_changed', 'param_added_optional'.
|
|
24
|
+
"""
|
|
25
|
+
tool: str
|
|
26
|
+
"""The tool name this change applies to."""
|
|
27
|
+
param: str | None
|
|
28
|
+
"""Param name, or None for tool-level changes."""
|
|
29
|
+
severity: str
|
|
30
|
+
"""One of: 'breaking', 'warning', 'info'."""
|
|
31
|
+
detail: str
|
|
32
|
+
"""Human-readable description of the change."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_tool(tool: dict) -> dict:
|
|
36
|
+
"""Return a canonical dict for comparison (sorted keys, stable repr)."""
|
|
37
|
+
return {
|
|
38
|
+
"name": tool.get("name", ""),
|
|
39
|
+
"description": tool.get("description", ""),
|
|
40
|
+
"inputSchema": tool.get("inputSchema", {}),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_params(tool: dict) -> dict[str, dict]:
|
|
45
|
+
"""Extract parameters from a tool's inputSchema as {name: schema}."""
|
|
46
|
+
schema = tool.get("inputSchema", {})
|
|
47
|
+
props = schema.get("properties", {})
|
|
48
|
+
return {k: dict(v) for k, v in sorted(props.items())}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_required(tool: dict) -> set[str]:
|
|
52
|
+
"""Return the set of required param names for a tool."""
|
|
53
|
+
schema = tool.get("inputSchema", {})
|
|
54
|
+
return set(schema.get("required", []))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def classify_changes(old_tools: list[dict], new_tools: list[dict]) -> list[Change]:
|
|
58
|
+
"""Compare two lists of MCP tool schemas and return all detected changes.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
old_tools: Tool list from the lockfile (baseline).
|
|
62
|
+
new_tools: Tool list from the live server (current).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of Change objects sorted by severity then tool name.
|
|
66
|
+
"""
|
|
67
|
+
changes: list[Change] = []
|
|
68
|
+
|
|
69
|
+
old_map = {t["name"]: _normalize_tool(t) for t in old_tools}
|
|
70
|
+
new_map = {t["name"]: _normalize_tool(t) for t in new_tools}
|
|
71
|
+
|
|
72
|
+
old_names = set(old_map)
|
|
73
|
+
new_names = set(new_map)
|
|
74
|
+
|
|
75
|
+
# Removed tools — breaking
|
|
76
|
+
for name in sorted(old_names - new_names):
|
|
77
|
+
changes.append(Change(
|
|
78
|
+
kind="removed",
|
|
79
|
+
tool=name,
|
|
80
|
+
param=None,
|
|
81
|
+
severity=SEVERITY_BREAKING,
|
|
82
|
+
detail=f"Tool '{name}' was removed.",
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
# Added tools — info
|
|
86
|
+
for name in sorted(new_names - old_names):
|
|
87
|
+
changes.append(Change(
|
|
88
|
+
kind="added",
|
|
89
|
+
tool=name,
|
|
90
|
+
param=None,
|
|
91
|
+
severity=SEVERITY_INFO,
|
|
92
|
+
detail=f"Tool '{name}' was added.",
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
# Changed tools — inspect each shared tool
|
|
96
|
+
for name in sorted(old_names & new_names):
|
|
97
|
+
old_tool = old_map[name]
|
|
98
|
+
new_tool = new_map[name]
|
|
99
|
+
|
|
100
|
+
# Description changed — warning (description IS the behavioral contract)
|
|
101
|
+
old_desc = old_tool.get("description", "")
|
|
102
|
+
new_desc = new_tool.get("description", "")
|
|
103
|
+
if old_desc != new_desc:
|
|
104
|
+
changes.append(Change(
|
|
105
|
+
kind="description_changed",
|
|
106
|
+
tool=name,
|
|
107
|
+
param=None,
|
|
108
|
+
severity=SEVERITY_WARNING,
|
|
109
|
+
detail=f"Tool description changed.\n was: {old_desc!r}\n now: {new_desc!r}",
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
# Param-level diff
|
|
113
|
+
old_params = _get_params(old_tool)
|
|
114
|
+
new_params = _get_params(new_tool)
|
|
115
|
+
old_required = _get_required(old_tool)
|
|
116
|
+
new_required = _get_required(new_tool)
|
|
117
|
+
|
|
118
|
+
old_param_names = set(old_params)
|
|
119
|
+
new_param_names = set(new_params)
|
|
120
|
+
|
|
121
|
+
# Removed params — breaking
|
|
122
|
+
for pname in sorted(old_param_names - new_param_names):
|
|
123
|
+
changes.append(Change(
|
|
124
|
+
kind="param_removed",
|
|
125
|
+
tool=name,
|
|
126
|
+
param=pname,
|
|
127
|
+
severity=SEVERITY_BREAKING,
|
|
128
|
+
detail=f"Parameter '{pname}' was removed from tool '{name}'.",
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
# Added params
|
|
132
|
+
for pname in sorted(new_param_names - old_param_names):
|
|
133
|
+
if pname in new_required:
|
|
134
|
+
# New required param — breaking (callers must now supply it)
|
|
135
|
+
changes.append(Change(
|
|
136
|
+
kind="param_added_required",
|
|
137
|
+
tool=name,
|
|
138
|
+
param=pname,
|
|
139
|
+
severity=SEVERITY_BREAKING,
|
|
140
|
+
detail=(
|
|
141
|
+
f"Required parameter '{pname}' was added to tool '{name}'. "
|
|
142
|
+
"Existing callers will now fail."
|
|
143
|
+
),
|
|
144
|
+
))
|
|
145
|
+
else:
|
|
146
|
+
changes.append(Change(
|
|
147
|
+
kind="param_added_optional",
|
|
148
|
+
tool=name,
|
|
149
|
+
param=pname,
|
|
150
|
+
severity=SEVERITY_INFO,
|
|
151
|
+
detail=f"Optional parameter '{pname}' was added to tool '{name}'.",
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
# Changed params
|
|
155
|
+
for pname in sorted(old_param_names & new_param_names):
|
|
156
|
+
old_p = old_params[pname]
|
|
157
|
+
new_p = new_params[pname]
|
|
158
|
+
|
|
159
|
+
# Type changed — breaking
|
|
160
|
+
old_type = old_p.get("type")
|
|
161
|
+
new_type = new_p.get("type")
|
|
162
|
+
if old_type != new_type:
|
|
163
|
+
changes.append(Change(
|
|
164
|
+
kind="param_type_changed",
|
|
165
|
+
tool=name,
|
|
166
|
+
param=pname,
|
|
167
|
+
severity=SEVERITY_BREAKING,
|
|
168
|
+
detail=(
|
|
169
|
+
f"Parameter '{pname}' type changed: {old_type!r} → {new_type!r} "
|
|
170
|
+
f"in tool '{name}'."
|
|
171
|
+
),
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
# Description changed — warning
|
|
175
|
+
old_pdesc = old_p.get("description", "")
|
|
176
|
+
new_pdesc = new_p.get("description", "")
|
|
177
|
+
if old_pdesc != new_pdesc:
|
|
178
|
+
changes.append(Change(
|
|
179
|
+
kind="param_description_changed",
|
|
180
|
+
tool=name,
|
|
181
|
+
param=pname,
|
|
182
|
+
severity=SEVERITY_WARNING,
|
|
183
|
+
detail=(
|
|
184
|
+
f"Parameter '{pname}' description changed in tool '{name}'.\n"
|
|
185
|
+
f" was: {old_pdesc!r}\n now: {new_pdesc!r}"
|
|
186
|
+
),
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
# Sort: breaking first, then warning, then info; then by tool name
|
|
190
|
+
severity_order = {SEVERITY_BREAKING: 0, SEVERITY_WARNING: 1, SEVERITY_INFO: 2}
|
|
191
|
+
changes.sort(key=lambda c: (severity_order.get(c.severity, 9), c.tool, c.param or ""))
|
|
192
|
+
return changes
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def has_breaking(changes: list[Change]) -> bool:
|
|
196
|
+
"""Return True if any change is severity 'breaking'."""
|
|
197
|
+
return any(c.severity == SEVERITY_BREAKING for c in changes)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def serialize_lockfile(tools: list[dict], command: list[str]) -> dict:
|
|
201
|
+
"""Build the lockfile dict from a tool list."""
|
|
202
|
+
import datetime
|
|
203
|
+
return {
|
|
204
|
+
"version": "1",
|
|
205
|
+
"created_at": datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
206
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
207
|
+
),
|
|
208
|
+
"command": " ".join(command),
|
|
209
|
+
"tools": [_normalize_tool(t) for t in sorted(tools, key=lambda t: t.get("name", ""))],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def deserialize_lockfile(data: dict) -> list[dict]:
|
|
214
|
+
"""Extract tool list from a parsed lockfile dict."""
|
|
215
|
+
return data.get("tools", [])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def format_changes_text(changes: list[Change], color: bool = True) -> str:
|
|
219
|
+
"""Format changes as human-readable colored text."""
|
|
220
|
+
if not changes:
|
|
221
|
+
return _c("No changes detected.", "\033[32m", color)
|
|
222
|
+
|
|
223
|
+
lines = []
|
|
224
|
+
for c in changes:
|
|
225
|
+
if c.severity == SEVERITY_BREAKING:
|
|
226
|
+
prefix = _c("[BREAKING]", "\033[31m", color)
|
|
227
|
+
elif c.severity == SEVERITY_WARNING:
|
|
228
|
+
prefix = _c("[WARNING] ", "\033[33m", color)
|
|
229
|
+
else:
|
|
230
|
+
prefix = _c("[INFO] ", "\033[32m", color)
|
|
231
|
+
|
|
232
|
+
loc = f"{c.tool}"
|
|
233
|
+
if c.param:
|
|
234
|
+
loc += f".{c.param}"
|
|
235
|
+
lines.append(f"{prefix} {loc}: {c.detail}")
|
|
236
|
+
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def format_changes_json(changes: list[Change]) -> str:
|
|
241
|
+
"""Format changes as JSON."""
|
|
242
|
+
return json.dumps(
|
|
243
|
+
[
|
|
244
|
+
{
|
|
245
|
+
"kind": c.kind,
|
|
246
|
+
"tool": c.tool,
|
|
247
|
+
"param": c.param,
|
|
248
|
+
"severity": c.severity,
|
|
249
|
+
"detail": c.detail,
|
|
250
|
+
}
|
|
251
|
+
for c in changes
|
|
252
|
+
],
|
|
253
|
+
indent=2,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _c(text: str, code: str, color: bool) -> str:
|
|
258
|
+
"""Wrap text in ANSI color code if color is enabled."""
|
|
259
|
+
if not color:
|
|
260
|
+
return text
|
|
261
|
+
return f"{code}{text}\033[0m"
|
|
@@ -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,8 @@
|
|
|
1
|
+
mcp_diff/__init__.py,sha256=y7fMxClBwTAP4qIvXrnq0YQ4vqhzpf0Jy86cjADkDOA,104
|
|
2
|
+
mcp_diff/cli.py,sha256=Cfwp-66VqlLTB7TzTAUKHhhr4FtbhTDzFl6A47P06WI,9660
|
|
3
|
+
mcp_diff/client.py,sha256=SGBQCmpdL8MCX2HtfaE-VN66ZB43h9jGw_ySmprEwiA,4895
|
|
4
|
+
mcp_diff/diff.py,sha256=1upYN0REpsJANhTx1Z4lAWfqNOZXS5Z29eOK5_7xuFM,8632
|
|
5
|
+
mcp_diff-0.1.0.dist-info/METADATA,sha256=NFfzwZCkSCpm3eEDX3y65EHOzMw_m9yQmgIbXfVwifM,3604
|
|
6
|
+
mcp_diff-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
mcp_diff-0.1.0.dist-info/entry_points.txt,sha256=EQxYnQXcsDOHkpA_ja7Gt5E6HGfGGkUhnl2F6cX7O_g,47
|
|
8
|
+
mcp_diff-0.1.0.dist-info/RECORD,,
|