mcp_diff 0.1.0
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.
- checksums.yaml +7 -0
- data/README.md +81 -0
- data/exe/mcp-diff +10 -0
- data/lib/mcp_diff/cli.rb +269 -0
- data/lib/mcp_diff/client.rb +93 -0
- data/lib/mcp_diff/config.rb +106 -0
- data/lib/mcp_diff/core/canonical.rb +43 -0
- data/lib/mcp_diff/core/classify.rb +40 -0
- data/lib/mcp_diff/core/diff.rb +80 -0
- data/lib/mcp_diff/core/normalize.rb +170 -0
- data/lib/mcp_diff/core/rule.rb +28 -0
- data/lib/mcp_diff/core/rules.rb +299 -0
- data/lib/mcp_diff/core/schema_diff.rb +145 -0
- data/lib/mcp_diff/render.rb +90 -0
- data/lib/mcp_diff/version.rb +5 -0
- data/lib/mcp_diff.rb +25 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d20d7f13aa54a40862cb9ea199572103518e6eabf8b5c02aa4933577f5d6b892
|
|
4
|
+
data.tar.gz: b02ae4c511d28b2ce69d5f08940fc51469747f869bab4ec8d947c97507b0dd6f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 33bf269d6727eb2d17267aca121692e1a520cc590ac5d4489a6f198d5a605342064704081ac6bf78b1bed661fc4b905c374cda0f443abdc7fe801f2657e5b25d
|
|
7
|
+
data.tar.gz: d69c25fa290c62f9ec81fd315029c377a3ac2b8e7aeee29444212c40e61f0ab76a22df3b6cb8a6eed8ad0fb1e43da5a60961af8c25b79530a50971b0ba232af9
|
data/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# mcp-diff — Ruby port (in progress)
|
|
2
|
+
|
|
3
|
+
Parallel port of mcp-diff to Ruby, on the `ruby-port` branch. The TypeScript
|
|
4
|
+
packages under `../packages` stay the shippable version until this reaches
|
|
5
|
+
feature parity (24 rules + CLI + Action + green tests).
|
|
6
|
+
|
|
7
|
+
## Decisions driving this port
|
|
8
|
+
|
|
9
|
+
- **Language: Ruby** (user decision, supersedes the TS default for this branch).
|
|
10
|
+
- **MCP SDK: the official `mcp` gem** (`modelcontextprotocol/ruby-sdk`) — honors
|
|
11
|
+
the project's "anchor only to the official SDK" rule. Its client subsystem
|
|
12
|
+
(`MCP::Client` + `MCP::Client::Stdio` / `http`) covers the full capture
|
|
13
|
+
surface: connect/InitializeResult (capabilities, protocolVersion, serverInfo,
|
|
14
|
+
instructions), `#tools` / `#resources` / `#resource_templates` / `#prompts`,
|
|
15
|
+
all auto-paginated.
|
|
16
|
+
- **Distribution: RubyGems** (`gem install`) + a Ruby-based GitHub Action —
|
|
17
|
+
replaces the TS `npx` flow. (Verify the gem name `mcp_diff` / `mcp-diff` on
|
|
18
|
+
RubyGems, mirroring the npm-naming saga.)
|
|
19
|
+
|
|
20
|
+
## Module boundaries (mirror the TS split; keep core pure)
|
|
21
|
+
|
|
22
|
+
- `McpDiff::Core` — normalize → diff → classify + rule catalog. **Zero I/O.**
|
|
23
|
+
- `McpDiff::Client` — capture over the official `mcp` gem. The only server I/O.
|
|
24
|
+
- `McpDiff::Cli` — init/snapshot/check/update/diff/explain; exit codes 0/1/2.
|
|
25
|
+
|
|
26
|
+
## Spike result (verified — Ruby 3.3.12, `mcp` 1.0.0)
|
|
27
|
+
|
|
28
|
+
Capture works and is faithful. **Gotcha found & handled:** the gem's public
|
|
29
|
+
`#tools`/`#resources`/… wrappers deserialize into objects that DROP `annotations`
|
|
30
|
+
and `title` — both part of mcp-diff's contract (BEHAVIOR class). The capture
|
|
31
|
+
layer reads the raw JSON one layer down via `MCP::Client`'s own private
|
|
32
|
+
`request` (full JSON-RPC envelope + id correlation), so nothing is lost.
|
|
33
|
+
`capture_test.rb` locks this in (asserts `add_doc`'s annotations survive).
|
|
34
|
+
|
|
35
|
+
## Done on this branch (green — 35 tests / 94 assertions)
|
|
36
|
+
|
|
37
|
+
- `McpDiff::Core::Canonical` + `Normalize` — deterministic snapshot + contractHash.
|
|
38
|
+
- `McpDiff::Client.capture` — capability-gated, cursor-paginated capture over the
|
|
39
|
+
official gem (stdio verified against two real servers; HTTP wired via
|
|
40
|
+
`MCP::Client::HTTP`, not yet exercised by a test).
|
|
41
|
+
- `Core::Diff` + `Classify` + `SchemaDiff` (D9) + the **full 24-rule catalog** —
|
|
42
|
+
verified against the SAME fixtures the TS suite uses (`test/fixtures/`).
|
|
43
|
+
- `Cli` — init/snapshot/check/update/diff/explain/rules, exit codes 0/1/2,
|
|
44
|
+
pretty/JSON/MD. Output is byte-identical to the TS CLI (verified on the demo).
|
|
45
|
+
- `exe/mcp-diff`, gemspec builds clean (`mcp_diff-0.1.0.gem`).
|
|
46
|
+
|
|
47
|
+
## Done since (green — full suite: 45 tests / 157 assertions)
|
|
48
|
+
|
|
49
|
+
- `test/schema_diff_test.rb` — the D9 variance primitives (port of the TS units).
|
|
50
|
+
- `test/catalog_test.rb` — D8 no-drift: the catalog vs context/RULES.md.
|
|
51
|
+
- `action/action.yml` — Ruby-native composite Action (setup-ruby → `gem install
|
|
52
|
+
mcp_diff` → `mcp-diff --md check` → PR comment + gate).
|
|
53
|
+
- `.github/workflows/ruby-ci.yml` — runs the Ruby suite on CI.
|
|
54
|
+
|
|
55
|
+
## Release artifact validated
|
|
56
|
+
|
|
57
|
+
Built + installed the gem and ran the **installed** `mcp-diff` binary end-to-end:
|
|
58
|
+
`init` (live capture) wrote config + lockfile, `check` returned "✓ contract
|
|
59
|
+
unchanged" (exit 0). So `gem install mcp_diff` yields a working CLI.
|
|
60
|
+
|
|
61
|
+
## Standalone
|
|
62
|
+
|
|
63
|
+
`test/fixtures/server.rb` is a hand-rolled MCP stdio server mirroring the TS
|
|
64
|
+
fixture — the whole suite runs with **only Ruby** (no Node), and `ruby-ci.yml`
|
|
65
|
+
reflects that.
|
|
66
|
+
|
|
67
|
+
## Remaining before a Ruby release
|
|
68
|
+
|
|
69
|
+
- **Publish to RubyGems** — the name `mcp_diff` (and `mcp-diff`) is FREE on
|
|
70
|
+
RubyGems (verified), so no npm-style workaround. The release is wired:
|
|
71
|
+
`.github/workflows/ruby-release.yml` builds + `gem push` on a `v*` tag. It
|
|
72
|
+
needs the repo secret **`RUBYGEMS_API_KEY`** (your RubyGems API key — the
|
|
73
|
+
analogue of NPM_TOKEN). To ship: add the secret, then `git tag v0.1.0 && git push --tags`.
|
|
74
|
+
- Nice-to-have: split rules into one-file-per-rule (contributor funnel); an
|
|
75
|
+
HTTP-transport capture test.
|
|
76
|
+
|
|
77
|
+
## Port stream tracking
|
|
78
|
+
|
|
79
|
+
Mirrors `../context/STREAMS.md` S1–S5. Formal D-decisions land in `context/`
|
|
80
|
+
once the spike validates the foundation (not before — the rewrite isn't final
|
|
81
|
+
until capture is proven in Ruby).
|
data/exe/mcp-diff
ADDED
data/lib/mcp_diff/cli.rb
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "render"
|
|
7
|
+
|
|
8
|
+
module McpDiff
|
|
9
|
+
# Command dispatcher: init/snapshot/check/update/diff/explain/rules, with
|
|
10
|
+
# pretty/JSON/MD output and the 0/1/2 exit-code contract. Port of
|
|
11
|
+
# cli/src/index.ts. run() returns the process exit code.
|
|
12
|
+
module Cli
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def run(argv)
|
|
16
|
+
globals, command, args, opts = parse(argv)
|
|
17
|
+
return 0 if globals[:handled]
|
|
18
|
+
|
|
19
|
+
dispatch(command, args, globals, opts)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
warn "✗ #{e.message}"
|
|
22
|
+
1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(argv)
|
|
26
|
+
globals = { config: Config::DEFAULT_CONFIG_PATH, profile: nil, json: false, md: false, handled: false }
|
|
27
|
+
opts = {}
|
|
28
|
+
command = nil
|
|
29
|
+
args = []
|
|
30
|
+
i = 0
|
|
31
|
+
while i < argv.length
|
|
32
|
+
a = argv[i]
|
|
33
|
+
case a
|
|
34
|
+
when "-c", "--config" then globals[:config] = argv[i += 1]
|
|
35
|
+
when "-p", "--profile" then globals[:profile] = argv[i += 1]
|
|
36
|
+
when "--json" then globals[:json] = true
|
|
37
|
+
when "--md" then globals[:md] = true
|
|
38
|
+
when "--stdio" then opts[:stdio] = argv[i += 1]
|
|
39
|
+
when "--url" then opts[:url] = argv[i += 1]
|
|
40
|
+
when "-V", "--version" then (puts VERSION; globals[:handled] = true)
|
|
41
|
+
when "-h", "--help" then (puts help_text; globals[:handled] = true)
|
|
42
|
+
else
|
|
43
|
+
if command.nil? && !a.start_with?("-")
|
|
44
|
+
command = a
|
|
45
|
+
else
|
|
46
|
+
args << a
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
i += 1
|
|
50
|
+
end
|
|
51
|
+
[globals, command, args, opts]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dispatch(command, args, globals, opts)
|
|
55
|
+
case command
|
|
56
|
+
when "init" then cmd_init(globals, opts)
|
|
57
|
+
when "snapshot" then cmd_snapshot(globals)
|
|
58
|
+
when "check" then return cmd_check(globals)
|
|
59
|
+
when "update" then cmd_update(globals)
|
|
60
|
+
when "diff" then return cmd_diff(globals, args[0], args[1])
|
|
61
|
+
when "explain" then cmd_explain(args[0])
|
|
62
|
+
when "rules" then cmd_rules(globals)
|
|
63
|
+
when nil then (puts help_text)
|
|
64
|
+
else raise "unknown command \"#{command}\" — run: mcp-diff --help"
|
|
65
|
+
end
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ── shared helpers ──────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def globals_config(globals)
|
|
72
|
+
Config.load_config(globals[:config], globals[:profile])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def output_format(globals)
|
|
76
|
+
raise "choose one output format — pass --json or --md, not both" if globals[:json] && globals[:md]
|
|
77
|
+
return "json" if globals[:json]
|
|
78
|
+
return "md" if globals[:md]
|
|
79
|
+
|
|
80
|
+
"pretty"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def lockfile_path(cfg)
|
|
84
|
+
cfg["lockfile"] || Config::DEFAULT_LOCKFILE
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def capture_options_from(cfg)
|
|
88
|
+
server = cfg["server"] || {}
|
|
89
|
+
opts = {}
|
|
90
|
+
opts[:timeout_ms] = cfg["timeoutMs"] if cfg["timeoutMs"]
|
|
91
|
+
if server["stdio"]
|
|
92
|
+
parts = Config.shell_split(server["stdio"])
|
|
93
|
+
command = parts.shift
|
|
94
|
+
raise "config server.stdio is empty" if command.nil?
|
|
95
|
+
|
|
96
|
+
opts[:stdio] = { command: command, args: parts }
|
|
97
|
+
opts[:stdio][:env] = cfg["env"] if cfg["env"]
|
|
98
|
+
elsif server["url"]
|
|
99
|
+
opts[:url] = server["url"]
|
|
100
|
+
else
|
|
101
|
+
raise "config has no server target — set server.stdio or server.url"
|
|
102
|
+
end
|
|
103
|
+
opts
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def live_snapshot(cfg)
|
|
107
|
+
raw = Client.capture(**capture_options_from(cfg))
|
|
108
|
+
Core::Normalize.normalize(raw, ignore: cfg["ignore"] || [])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def read_lockfile(path, verify: true)
|
|
112
|
+
text = begin
|
|
113
|
+
File.read(path)
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
raise "cannot read lockfile \"#{path}\" — run: npx @mcp-diff/cli snapshot"
|
|
116
|
+
end
|
|
117
|
+
data = begin
|
|
118
|
+
JSON.parse(text)
|
|
119
|
+
rescue JSON::ParserError => e
|
|
120
|
+
raise "lockfile \"#{path}\" is not valid JSON (#{e.message}) — run: npx @mcp-diff/cli snapshot"
|
|
121
|
+
end
|
|
122
|
+
snapshot = Core::Normalize.parse_snapshot(data)
|
|
123
|
+
if verify
|
|
124
|
+
body = snapshot.reject { |k, _| k == "contractHash" }
|
|
125
|
+
if Core::Canonical.contract_hash_of(body) != snapshot["contractHash"]
|
|
126
|
+
raise "lockfile \"#{path}\" does not match its contractHash (hand-edited or bad merge?) — " \
|
|
127
|
+
"run: npx @mcp-diff/cli update"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
snapshot
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_snapshot_file(path)
|
|
134
|
+
text = begin
|
|
135
|
+
File.read(path)
|
|
136
|
+
rescue SystemCallError
|
|
137
|
+
raise "cannot read snapshot file \"#{path}\""
|
|
138
|
+
end
|
|
139
|
+
begin
|
|
140
|
+
Core::Normalize.parse_snapshot(JSON.parse(text))
|
|
141
|
+
rescue JSON::ParserError => e
|
|
142
|
+
raise "snapshot file \"#{path}\": #{e.message}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_lockfile(path, snapshot)
|
|
147
|
+
File.write(path, Core::Canonical.canonical_stringify(snapshot))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── commands ────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def cmd_init(globals, opts)
|
|
153
|
+
config_path = globals[:config]
|
|
154
|
+
raise "#{config_path} already exists — delete it first to re-init" if File.exist?(config_path)
|
|
155
|
+
|
|
156
|
+
server = init_server_target(opts)
|
|
157
|
+
cfg = { "server" => server, "strict" => "breaking" }
|
|
158
|
+
write_lockfile(config_path, cfg)
|
|
159
|
+
puts "wrote #{config_path}"
|
|
160
|
+
snapshot = live_snapshot(cfg)
|
|
161
|
+
write_lockfile(lockfile_path(cfg), snapshot)
|
|
162
|
+
puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools) — commit both files"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def init_server_target(opts)
|
|
166
|
+
if opts[:url]
|
|
167
|
+
valid = begin
|
|
168
|
+
u = URI.parse(opts[:url])
|
|
169
|
+
u.scheme && u.host
|
|
170
|
+
rescue URI::InvalidURIError
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
raise "\"#{opts[:url]}\" is not a valid URL — expected e.g. http://localhost:3000/mcp" unless valid
|
|
174
|
+
|
|
175
|
+
{ "url" => opts[:url] }
|
|
176
|
+
elsif opts[:stdio]
|
|
177
|
+
{ "stdio" => opts[:stdio] }
|
|
178
|
+
else
|
|
179
|
+
detected = Config.detect_mcp_json
|
|
180
|
+
raise "no server target — pass --stdio \"<command>\" or --url <url> (no .mcp.json found to auto-detect)" if detected.nil?
|
|
181
|
+
|
|
182
|
+
puts "detected server from .mcp.json: #{detected['stdio']}"
|
|
183
|
+
detected
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def cmd_snapshot(globals)
|
|
188
|
+
cfg = globals_config(globals)
|
|
189
|
+
snapshot = live_snapshot(cfg)
|
|
190
|
+
write_lockfile(lockfile_path(cfg), snapshot)
|
|
191
|
+
puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools)"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def cmd_check(globals)
|
|
195
|
+
cfg = globals_config(globals)
|
|
196
|
+
locked = read_lockfile(lockfile_path(cfg))
|
|
197
|
+
live = live_snapshot(cfg)
|
|
198
|
+
if locked["contractHash"] == live["contractHash"]
|
|
199
|
+
puts Render.render(output_format(globals), [], 0)
|
|
200
|
+
return 0
|
|
201
|
+
end
|
|
202
|
+
findings = Core::Classify.classify(Core::Diff.diff(locked, live))
|
|
203
|
+
exit_code = Core::Classify.compute_exit_code(findings, cfg["strict"] || "breaking")
|
|
204
|
+
puts Render.render(output_format(globals), findings, exit_code)
|
|
205
|
+
if findings.empty?
|
|
206
|
+
puts "ℹ contract hash changed but no rule classified the change (compatible) — run: mcp-diff update"
|
|
207
|
+
end
|
|
208
|
+
exit_code
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cmd_update(globals)
|
|
212
|
+
cfg = globals_config(globals)
|
|
213
|
+
path = lockfile_path(cfg)
|
|
214
|
+
live = live_snapshot(cfg)
|
|
215
|
+
if File.exist?(path)
|
|
216
|
+
locked = read_lockfile(path, verify: false)
|
|
217
|
+
findings = Core::Classify.classify(Core::Diff.diff(locked, live))
|
|
218
|
+
puts Render.render(output_format(globals), findings, 0) unless findings.empty?
|
|
219
|
+
end
|
|
220
|
+
write_lockfile(path, live)
|
|
221
|
+
puts "updated #{path}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def cmd_diff(globals, before_path, after_path)
|
|
225
|
+
raise "diff needs two snapshot files: mcp-diff diff <before> <after>" if before_path.nil? || after_path.nil?
|
|
226
|
+
|
|
227
|
+
before = read_snapshot_file(before_path)
|
|
228
|
+
after = read_snapshot_file(after_path)
|
|
229
|
+
findings = Core::Classify.classify(Core::Diff.diff(before, after))
|
|
230
|
+
exit_code = Core::Classify.compute_exit_code(findings)
|
|
231
|
+
puts Render.render(output_format(globals), findings, exit_code)
|
|
232
|
+
exit_code
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def cmd_explain(rule_id)
|
|
236
|
+
entry = Core::Rules::CATALOG.find { |r| r["id"] == rule_id }
|
|
237
|
+
unless entry
|
|
238
|
+
known = Core::Rules::CATALOG.map { |r| r["id"] }.join("\n ")
|
|
239
|
+
raise "unknown rule \"#{rule_id}\" — known rules:\n #{known}"
|
|
240
|
+
end
|
|
241
|
+
puts "#{entry['id']} [#{entry['class'].upcase}]\n\n#{entry['description']}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def cmd_rules(globals)
|
|
245
|
+
if globals[:json]
|
|
246
|
+
puts JSON.pretty_generate(Core::Rules::CATALOG)
|
|
247
|
+
return
|
|
248
|
+
end
|
|
249
|
+
Core::Rules::CATALOG.each { |r| puts "#{r['class'].upcase.ljust(9)} #{r['id']}" }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def help_text
|
|
253
|
+
<<~HELP
|
|
254
|
+
mcp-diff — lockfile + CI gate for your MCP server's contract
|
|
255
|
+
|
|
256
|
+
Usage: mcp-diff [--config <path>] [--profile <name>] [--json|--md] <command>
|
|
257
|
+
|
|
258
|
+
Commands:
|
|
259
|
+
init [--stdio "<cmd>" | --url <url>] write config + first lockfile (auto-detects .mcp.json)
|
|
260
|
+
snapshot (re)write mcp.lock.json
|
|
261
|
+
check live server vs lockfile; exit 0/1/2
|
|
262
|
+
update accept changes, rewrite lockfile
|
|
263
|
+
diff <a.json> <b.json> compare two snapshots
|
|
264
|
+
explain <rule-id> explain one rule
|
|
265
|
+
rules list the rule catalog
|
|
266
|
+
HELP
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require "mcp"
|
|
5
|
+
|
|
6
|
+
module McpDiff
|
|
7
|
+
# Capture an MCP server's complete public surface over the official `mcp` gem.
|
|
8
|
+
# The ONLY component that talks to servers (keeps Core pure). Port of
|
|
9
|
+
# client/src/index.ts.
|
|
10
|
+
#
|
|
11
|
+
# Note: the gem's `MCP::Client#tools/#resources/...` wrap results into objects
|
|
12
|
+
# that DROP `annotations` and `title`. Those are part of the contract (BEHAVIOR
|
|
13
|
+
# class), so we read the raw JSON one layer down via the client's own private
|
|
14
|
+
# `request` (full JSON-RPC envelope + id correlation, nothing dropped). This is
|
|
15
|
+
# covered by capture_test.rb — if a future `mcp` version changes it, that fails.
|
|
16
|
+
module Client
|
|
17
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# stdio: { command:, args: [], env: {} } | url: "http://…/mcp"
|
|
22
|
+
# Returns a RawCapture Hash (string keys) matching Core's normalize input.
|
|
23
|
+
def capture(stdio: nil, url: nil, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
24
|
+
transport = make_transport(stdio: stdio, url: url)
|
|
25
|
+
client = MCP::Client.new(transport: transport)
|
|
26
|
+
|
|
27
|
+
Timeout.timeout(timeout_ms / 1000.0, nil, "server did not respond within #{timeout_ms}ms") do
|
|
28
|
+
client.connect
|
|
29
|
+
info = as_hash(client.server_info)
|
|
30
|
+
caps = as_hash(info["capabilities"])
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"protocolVersion" => info["protocolVersion"],
|
|
34
|
+
"serverInfo" => info["serverInfo"],
|
|
35
|
+
"capabilities" => caps,
|
|
36
|
+
"instructions" => info["instructions"],
|
|
37
|
+
"tools" => caps["tools"] ? raw_list(client, "tools/list", "tools") : [],
|
|
38
|
+
"resources" => caps["resources"] ? raw_list(client, "resources/list", "resources") : [],
|
|
39
|
+
"resourceTemplates" => caps["resources"] ? raw_list(client, "resources/templates/list", "resourceTemplates") : [],
|
|
40
|
+
"prompts" => caps["prompts"] ? raw_list(client, "prompts/list", "prompts") : []
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
rescue Timeout::Error
|
|
44
|
+
raise
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
raise "cannot capture server (#{target_desc(stdio, url)}) — #{e.message} — " \
|
|
47
|
+
"check the command/URL and that the server builds & starts"
|
|
48
|
+
ensure
|
|
49
|
+
transport.close if transport.respond_to?(:close)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Follow nextCursor to the end, reading raw hashes. Guards against a server
|
|
53
|
+
# that returns a repeating cursor (would loop forever).
|
|
54
|
+
def raw_list(client, method, key)
|
|
55
|
+
items = []
|
|
56
|
+
cursor = nil
|
|
57
|
+
seen = {}
|
|
58
|
+
loop do
|
|
59
|
+
params = cursor ? { cursor: cursor } : {}
|
|
60
|
+
result = as_hash(client.send(:request, method: method, params: params)["result"])
|
|
61
|
+
items.concat(result[key] || [])
|
|
62
|
+
cursor = result["nextCursor"]
|
|
63
|
+
break if cursor.nil?
|
|
64
|
+
raise "server returned a repeating pagination cursor" if seen[cursor]
|
|
65
|
+
|
|
66
|
+
seen[cursor] = true
|
|
67
|
+
end
|
|
68
|
+
items
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def make_transport(stdio:, url:)
|
|
72
|
+
if stdio
|
|
73
|
+
MCP::Client::Stdio.new(command: stdio[:command], args: stdio[:args] || [], env: stdio[:env])
|
|
74
|
+
elsif url
|
|
75
|
+
MCP::Client::HTTP.new(url: url)
|
|
76
|
+
else
|
|
77
|
+
raise "no server target: provide either stdio or url"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def target_desc(stdio, url)
|
|
82
|
+
if stdio
|
|
83
|
+
%(stdio: "#{[stdio[:command], *(stdio[:args] || [])].join(' ')}")
|
|
84
|
+
else
|
|
85
|
+
url || "no target"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def as_hash(value)
|
|
90
|
+
value.is_a?(Hash) ? value : {}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module McpDiff
|
|
7
|
+
# Config loading + validation (port of cli/src/config.ts). Actionable error
|
|
8
|
+
# messages: every failure says how to fix it. Config uses string keys (JSON).
|
|
9
|
+
module Config
|
|
10
|
+
DEFAULT_CONFIG_PATH = "mcp-diff.config.json"
|
|
11
|
+
DEFAULT_LOCKFILE = "mcp.lock.json"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def load_config(path, profile = nil)
|
|
16
|
+
text = read_config_text(path)
|
|
17
|
+
begin
|
|
18
|
+
config = JSON.parse(text)
|
|
19
|
+
rescue JSON::ParserError => e
|
|
20
|
+
raise "config \"#{path}\" is not valid JSON: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
config = apply_profile(config, profile) unless profile.nil?
|
|
23
|
+
validate_config(path, config)
|
|
24
|
+
config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read_config_text(path)
|
|
28
|
+
File.read(path)
|
|
29
|
+
rescue SystemCallError
|
|
30
|
+
raise "cannot read config \"#{path}\" — run: npx @mcp-diff/cli init"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_profile(config, profile)
|
|
34
|
+
overlay = (config["profiles"] || {})[profile]
|
|
35
|
+
if overlay.nil?
|
|
36
|
+
known = (config["profiles"] || {}).keys.join(", ")
|
|
37
|
+
known = "none defined" if known.empty?
|
|
38
|
+
raise "unknown profile \"#{profile}\" — known profiles: #{known}"
|
|
39
|
+
end
|
|
40
|
+
merged = config.merge(overlay)
|
|
41
|
+
merged["env"] = (config["env"] || {}).merge(overlay["env"] || {})
|
|
42
|
+
merged["server"] = overlay["server"] || config["server"]
|
|
43
|
+
merged
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_config(path, config)
|
|
47
|
+
invalid = ->(msg) { raise "config \"#{path}\": #{msg}" }
|
|
48
|
+
validate_server(config["server"], invalid) if config.key?("server")
|
|
49
|
+
invalid.call('"lockfile" must be a string path') if config.key?("lockfile") && !config["lockfile"].is_a?(String)
|
|
50
|
+
validate_timeout(config["timeoutMs"], invalid) if config.key?("timeoutMs")
|
|
51
|
+
validate_ignore(config["ignore"], invalid) if config.key?("ignore")
|
|
52
|
+
if config.key?("strict") && !%w[breaking behavior].include?(config["strict"])
|
|
53
|
+
invalid.call('"strict" must be "breaking" or "behavior"')
|
|
54
|
+
end
|
|
55
|
+
validate_env(config["env"], invalid) if config.key?("env")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_server(server, invalid)
|
|
59
|
+
invalid.call('"server" must be an object with "stdio" or "url"') unless server.is_a?(Hash)
|
|
60
|
+
if server.key?("stdio") && !server["stdio"].is_a?(String)
|
|
61
|
+
invalid.call('"server.stdio" must be a string command line')
|
|
62
|
+
end
|
|
63
|
+
return unless server.key?("url")
|
|
64
|
+
|
|
65
|
+
invalid.call('"server.url" must be a string') unless server["url"].is_a?(String)
|
|
66
|
+
begin
|
|
67
|
+
u = URI.parse(server["url"])
|
|
68
|
+
invalid.call("\"server.url\" is not a valid URL: #{server['url']}") unless u.scheme && u.host
|
|
69
|
+
rescue URI::InvalidURIError
|
|
70
|
+
invalid.call("\"server.url\" is not a valid URL: #{server['url']}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_timeout(value, invalid)
|
|
75
|
+
invalid.call('"timeoutMs" must be a positive number') unless value.is_a?(Numeric) && value.finite? && value.positive?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_ignore(value, invalid)
|
|
79
|
+
invalid.call('"ignore" must be an array of string patterns') unless value.is_a?(Array) && value.all?(String)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_env(value, invalid)
|
|
83
|
+
ok = value.is_a?(Hash) && value.values.all?(String)
|
|
84
|
+
invalid.call('"env" must be an object of string values') unless ok
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Split a command line into argv, honoring single/double quotes.
|
|
88
|
+
def shell_split(command)
|
|
89
|
+
command.scan(/"([^"]*)"|'([^']*)'|(\S+)/).map { |dq, sq, bare| dq || sq || bare }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def detect_mcp_json
|
|
93
|
+
return nil unless File.exist?(".mcp.json")
|
|
94
|
+
|
|
95
|
+
parsed = JSON.parse(File.read(".mcp.json"))
|
|
96
|
+
servers = parsed["mcpServers"] || {}
|
|
97
|
+
_name, server = servers.first
|
|
98
|
+
return nil unless server.is_a?(Hash) && server["command"].is_a?(String)
|
|
99
|
+
|
|
100
|
+
args = (server["args"] || []).map { |a| a.match?(/\s/) ? "\"#{a}\"" : a }.join(" ")
|
|
101
|
+
{ "stdio" => args.empty? ? server["command"] : "#{server['command']} #{args}" }
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module McpDiff
|
|
7
|
+
module Core
|
|
8
|
+
# Deterministic serialization: sorted keys, array order preserved (it is
|
|
9
|
+
# meaningful), sha256 contract hash. Port of core/src/canonical.ts (D11).
|
|
10
|
+
module Canonical
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Recursively sort object keys; stringify keys so string/symbol inputs
|
|
14
|
+
# canonicalize identically. Array order is preserved.
|
|
15
|
+
def sort_keys_deep(value)
|
|
16
|
+
case value
|
|
17
|
+
when Array
|
|
18
|
+
value.map { |v| sort_keys_deep(v) }
|
|
19
|
+
when Hash
|
|
20
|
+
value.map { |k, v| [k.to_s, sort_keys_deep(v)] }.sort_by(&:first).to_h
|
|
21
|
+
else
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Canonical lockfile text: sorted keys, 2-space indent, trailing newline.
|
|
27
|
+
def canonical_stringify(value)
|
|
28
|
+
"#{JSON.pretty_generate(sort_keys_deep(value))}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Structural equality via canonical serialization (array order preserved).
|
|
32
|
+
def deep_equal(a, b)
|
|
33
|
+
JSON.generate(sort_keys_deep(a)) == JSON.generate(sort_keys_deep(b))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# sha256 of the snapshot's canonical (compact) form, hash field excluded.
|
|
37
|
+
def contract_hash_of(snapshot_without_hash)
|
|
38
|
+
compact = JSON.generate(sort_keys_deep(snapshot_without_hash))
|
|
39
|
+
"sha256-#{Digest::SHA256.hexdigest(compact)}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules"
|
|
4
|
+
|
|
5
|
+
module McpDiff
|
|
6
|
+
module Core
|
|
7
|
+
# Apply the ruleset to a set of changes, then compute the exit code.
|
|
8
|
+
# Port of core/src/classify.ts.
|
|
9
|
+
module Classify
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
CLASS_RANK = { "breaking" => 0, "behavior" => 1, "compat" => 2, "info" => 3 }.freeze
|
|
13
|
+
|
|
14
|
+
# Sorted worst-first, then by rule, target, message (deterministic order).
|
|
15
|
+
def classify(changes, rules = Rules::ALL)
|
|
16
|
+
findings = []
|
|
17
|
+
changes.each do |change|
|
|
18
|
+
rules.each do |rule|
|
|
19
|
+
matched = rule.match(change)
|
|
20
|
+
next if matched.nil?
|
|
21
|
+
|
|
22
|
+
findings.concat(matched.is_a?(Array) ? matched : [matched])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
findings.sort_by { |f| [CLASS_RANK[f["class"]], f["ruleId"], f["target"], f["message"]] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Exit-code contract (D4): 0 clean/compat/info, 1 breaking, 2 behavior-only.
|
|
29
|
+
# strict: "behavior" promotes behavior findings to exit 1.
|
|
30
|
+
def compute_exit_code(findings, strict = "breaking")
|
|
31
|
+
has_breaking = findings.any? { |f| f["class"] == "breaking" }
|
|
32
|
+
has_behavior = findings.any? { |f| f["class"] == "behavior" }
|
|
33
|
+
return 1 if has_breaking || (strict == "behavior" && has_behavior)
|
|
34
|
+
return 2 if has_behavior
|
|
35
|
+
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|