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.
- mcp_diff-0.1.0/.gitignore +6 -0
- mcp_diff-0.1.0/PKG-INFO +122 -0
- mcp_diff-0.1.0/README.md +106 -0
- mcp_diff-0.1.0/mcp_diff/__init__.py +3 -0
- mcp_diff-0.1.0/mcp_diff/cli.py +304 -0
- mcp_diff-0.1.0/mcp_diff/client.py +145 -0
- mcp_diff-0.1.0/mcp_diff/diff.py +261 -0
- mcp_diff-0.1.0/pyproject.toml +28 -0
- mcp_diff-0.1.0/tests/__init__.py +0 -0
- mcp_diff-0.1.0/tests/test_diff.py +592 -0
mcp_diff-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
mcp_diff-0.1.0/README.md
ADDED
|
@@ -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,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()]
|