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 ADDED
@@ -0,0 +1,3 @@
1
+ """mcp-diff — Schema lockfile and breaking-change detector for MCP servers."""
2
+
3
+ __version__ = "0.1.0"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-diff = mcp_diff.cli:main