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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "canonical"
|
|
6
|
+
|
|
7
|
+
module McpDiff
|
|
8
|
+
module Core
|
|
9
|
+
# Pragmatic structural diff of a JSON-Schema object schema (D9). Recognizes
|
|
10
|
+
# type / enum / required / properties / description / default; anything it
|
|
11
|
+
# cannot prove compatible surfaces as an `*-opaque` change so the incompatible
|
|
12
|
+
# catch-all rules fire loudly instead of silently passing. Port of
|
|
13
|
+
# core/src/schema-diff.ts. SchemaChange hashes use symbol keys (internal).
|
|
14
|
+
module SchemaDiff
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
PARAM_HANDLED = %w[type enum description default title].freeze
|
|
18
|
+
TOP_HANDLED = %w[properties required description title $schema].freeze
|
|
19
|
+
|
|
20
|
+
def as_obj(value)
|
|
21
|
+
value.is_a?(Hash) ? value : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def required_set(schema)
|
|
25
|
+
req = schema["required"]
|
|
26
|
+
Set.new(req.is_a?(Array) ? req.select { |r| r.is_a?(String) } : [])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `type` as a set of names; nil = unspecified (accepts anything).
|
|
30
|
+
def type_set(schema)
|
|
31
|
+
t = schema["type"]
|
|
32
|
+
return Set[t] if t.is_a?(String)
|
|
33
|
+
return Set.new(t.select { |x| x.is_a?(String) }) if t.is_a?(Array)
|
|
34
|
+
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def type_label(set)
|
|
39
|
+
set.nil? ? "(any)" : set.to_a.sort.join("|")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subset?(a, b)
|
|
43
|
+
a.all? { |x| b.include?(x) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enum_values(schema)
|
|
47
|
+
e = schema["enum"]
|
|
48
|
+
e.is_a?(Array) ? e : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def strip_keys(obj, keys)
|
|
52
|
+
obj.reject { |k, _| keys.include?(k) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def diff_param(param, before_raw, after_raw, changes)
|
|
56
|
+
return if Canonical.deep_equal(before_raw, after_raw)
|
|
57
|
+
|
|
58
|
+
before = as_obj(before_raw)
|
|
59
|
+
after = as_obj(after_raw)
|
|
60
|
+
unless before && after
|
|
61
|
+
changes << { kind: "param-changed-opaque", param: param }
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
diff_param_type(param, before, after, changes)
|
|
66
|
+
diff_param_enum(param, before, after, changes)
|
|
67
|
+
|
|
68
|
+
changes << { kind: "description-changed", param: param } if before["description"] != after["description"]
|
|
69
|
+
changes << { kind: "default-changed", param: param } unless Canonical.deep_equal(before["default"], after["default"])
|
|
70
|
+
|
|
71
|
+
unless Canonical.deep_equal(strip_keys(before, PARAM_HANDLED), strip_keys(after, PARAM_HANDLED))
|
|
72
|
+
changes << { kind: "param-changed-opaque", param: param }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diff_param_type(param, before, after, changes)
|
|
77
|
+
b_t = type_set(before)
|
|
78
|
+
a_t = type_set(after)
|
|
79
|
+
types_equal = (b_t.nil? && a_t.nil?) ||
|
|
80
|
+
(!b_t.nil? && !a_t.nil? && subset?(b_t, a_t) && subset?(a_t, b_t))
|
|
81
|
+
return if types_equal
|
|
82
|
+
|
|
83
|
+
label = { before: type_label(b_t), after: type_label(a_t) }
|
|
84
|
+
if b_t.nil? || (!a_t.nil? && subset?(a_t, b_t))
|
|
85
|
+
changes << { kind: "type-narrowed", param: param }.merge(label)
|
|
86
|
+
elsif a_t.nil? || subset?(b_t, a_t)
|
|
87
|
+
changes << { kind: "type-widened", param: param }.merge(label)
|
|
88
|
+
else
|
|
89
|
+
changes << { kind: "param-changed-opaque", param: param }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def diff_param_enum(param, before, after, changes)
|
|
94
|
+
b_e = enum_values(before)
|
|
95
|
+
a_e = enum_values(after)
|
|
96
|
+
if !b_e.nil? && !a_e.nil?
|
|
97
|
+
removed = b_e.reject { |v| a_e.any? { |w| Canonical.deep_equal(v, w) } }
|
|
98
|
+
added = a_e.reject { |v| b_e.any? { |w| Canonical.deep_equal(v, w) } }
|
|
99
|
+
changes << { kind: "enum-value-removed", param: param, values: removed } unless removed.empty?
|
|
100
|
+
changes << { kind: "enum-value-added", param: param, values: added } unless added.empty?
|
|
101
|
+
elsif !b_e.nil? && a_e.nil?
|
|
102
|
+
changes << { kind: "enum-constraint-removed", param: param }
|
|
103
|
+
elsif b_e.nil? && !a_e.nil?
|
|
104
|
+
changes << { kind: "enum-constraint-added", param: param }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def diff_object_schema(before_raw, after_raw)
|
|
109
|
+
return [] if Canonical.deep_equal(before_raw, after_raw)
|
|
110
|
+
|
|
111
|
+
before = as_obj(before_raw)
|
|
112
|
+
after = as_obj(after_raw)
|
|
113
|
+
return [{ kind: "schema-changed-opaque" }] unless before && after
|
|
114
|
+
|
|
115
|
+
changes = []
|
|
116
|
+
b_props = as_obj(before["properties"]) || {}
|
|
117
|
+
a_props = as_obj(after["properties"]) || {}
|
|
118
|
+
b_req = required_set(before)
|
|
119
|
+
a_req = required_set(after)
|
|
120
|
+
|
|
121
|
+
b_props.keys.sort.each { |p| changes << { kind: "param-removed", param: p } unless a_props.key?(p) }
|
|
122
|
+
a_props.keys.sort.each do |p|
|
|
123
|
+
changes << { kind: "param-added", param: p, required: a_req.include?(p) } unless b_props.key?(p)
|
|
124
|
+
end
|
|
125
|
+
a_req.to_a.sort.each do |p|
|
|
126
|
+
changes << { kind: "required-added", param: p, existed_before: b_props.key?(p) } unless b_req.include?(p)
|
|
127
|
+
end
|
|
128
|
+
b_req.to_a.sort.each do |p|
|
|
129
|
+
changes << { kind: "required-removed", param: p } if !a_req.include?(p) && a_props.key?(p)
|
|
130
|
+
end
|
|
131
|
+
b_props.keys.sort.each { |p| diff_param(p, b_props[p], a_props[p], changes) if a_props.key?(p) }
|
|
132
|
+
|
|
133
|
+
unless Canonical.deep_equal(strip_keys(before, TOP_HANDLED), strip_keys(after, TOP_HANDLED))
|
|
134
|
+
changes << { kind: "schema-changed-opaque" }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
changes
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_values(values)
|
|
141
|
+
values.map(&:to_json).join(", ")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module McpDiff
|
|
6
|
+
# Output renderers: pretty (default), --json, --md. Findings always ordered
|
|
7
|
+
# most-severe first, then rule, then target. Port of cli/src/render.ts.
|
|
8
|
+
module Render
|
|
9
|
+
GLYPHS = { "breaking" => "✗", "behavior" => "⚠", "compat" => "✓", "info" => "ℹ" }.freeze
|
|
10
|
+
COLORS = { "breaking" => 31, "behavior" => 33, "compat" => 32, "info" => 34 }.freeze
|
|
11
|
+
SEVERITY_RANK = { "breaking" => 0, "behavior" => 1, "compat" => 2, "info" => 3 }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def color?
|
|
16
|
+
ENV["NO_COLOR"].nil? && $stdout.tty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def paint(code, str)
|
|
20
|
+
color? ? "\e[#{code}m#{str}\e[39m" : str
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def bold(str)
|
|
24
|
+
color? ? "\e[1m#{str}\e[22m" : str
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dim(str)
|
|
28
|
+
color? ? "\e[2m#{str}\e[22m" : str
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sort_findings(findings)
|
|
32
|
+
findings.sort_by { |f| [SEVERITY_RANK[f["class"]], f["ruleId"], f["target"]] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def render(format, findings, exit_code)
|
|
36
|
+
sorted = sort_findings(findings)
|
|
37
|
+
case format
|
|
38
|
+
when "json" then render_json(sorted, exit_code)
|
|
39
|
+
when "md" then render_markdown(sorted, exit_code)
|
|
40
|
+
else render_pretty(sorted, exit_code)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_pretty(findings, exit_code)
|
|
45
|
+
return paint(COLORS["compat"], "✓ contract unchanged") if findings.empty?
|
|
46
|
+
|
|
47
|
+
rule_width = findings.map { |f| f["ruleId"].length }.max + 2
|
|
48
|
+
lines = findings.map do |f|
|
|
49
|
+
code = COLORS[f["class"]]
|
|
50
|
+
[
|
|
51
|
+
paint(code, GLYPHS[f["class"]]),
|
|
52
|
+
paint(code, f["class"].upcase.ljust(9)),
|
|
53
|
+
f["ruleId"].ljust(rule_width),
|
|
54
|
+
"#{bold(f['target'])}: #{f['message']}"
|
|
55
|
+
].join(" ")
|
|
56
|
+
end
|
|
57
|
+
lines << dim("exit code #{exit_code}")
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_json(findings, exit_code)
|
|
62
|
+
count = ->(cls) { findings.count { |f| f["class"] == cls } }
|
|
63
|
+
JSON.pretty_generate(
|
|
64
|
+
"findings" => findings,
|
|
65
|
+
"summary" => {
|
|
66
|
+
"breaking" => count.call("breaking"),
|
|
67
|
+
"behavior" => count.call("behavior"),
|
|
68
|
+
"compat" => count.call("compat"),
|
|
69
|
+
"info" => count.call("info")
|
|
70
|
+
},
|
|
71
|
+
"exitCode" => exit_code
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_markdown(findings, exit_code)
|
|
76
|
+
return "✅ **mcp-diff**: contract unchanged" if findings.empty?
|
|
77
|
+
|
|
78
|
+
rows = findings.map do |f|
|
|
79
|
+
"| #{GLYPHS[f['class']]} #{f['class'].upcase} | `#{f['ruleId']}` | `#{f['target']}` | #{f['message']} |"
|
|
80
|
+
end
|
|
81
|
+
header =
|
|
82
|
+
case exit_code
|
|
83
|
+
when 1 then "❌ **mcp-diff**: breaking contract changes"
|
|
84
|
+
when 2 then "⚠️ **mcp-diff**: behavior-level contract changes"
|
|
85
|
+
else "✅ **mcp-diff**: compatible contract changes"
|
|
86
|
+
end
|
|
87
|
+
[header, "", "| Severity | Rule | Target | Change |", "|---|---|---|---|", *rows].join("\n")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/mcp_diff.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mcp_diff/version"
|
|
4
|
+
require_relative "mcp_diff/core/canonical"
|
|
5
|
+
require_relative "mcp_diff/core/normalize"
|
|
6
|
+
require_relative "mcp_diff/core/schema_diff"
|
|
7
|
+
require_relative "mcp_diff/core/diff"
|
|
8
|
+
require_relative "mcp_diff/core/rule"
|
|
9
|
+
require_relative "mcp_diff/core/rules"
|
|
10
|
+
require_relative "mcp_diff/core/classify"
|
|
11
|
+
require_relative "mcp_diff/client"
|
|
12
|
+
require_relative "mcp_diff/config"
|
|
13
|
+
require_relative "mcp_diff/render"
|
|
14
|
+
require_relative "mcp_diff/cli"
|
|
15
|
+
|
|
16
|
+
# mcp-diff — a lockfile + CI gate for an MCP server's contract (Ruby port).
|
|
17
|
+
#
|
|
18
|
+
# Module layout mirrors the TS package boundaries so the "core is pure (zero
|
|
19
|
+
# I/O)" architecture rule survives the port:
|
|
20
|
+
# McpDiff::Core — normalize → diff → classify + the rule catalog. Pure.
|
|
21
|
+
# McpDiff::Client — capture over the official `mcp` gem (the ONLY I/O to servers).
|
|
22
|
+
# McpDiff::Cli — init/snapshot/check/update/diff/explain, exit codes 0/1/2.
|
|
23
|
+
module McpDiff
|
|
24
|
+
# Populated as the port lands, stream by stream (see context/STREAMS.md).
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mcp_diff
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Serhii Leniv
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: mcp
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
description: Snapshots an MCP server's public surface (tools, resources, prompts,
|
|
28
|
+
capabilities) into a committed mcp.lock.json and fails CI when the contract changes
|
|
29
|
+
in a breaking way.
|
|
30
|
+
email:
|
|
31
|
+
executables:
|
|
32
|
+
- mcp-diff
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- README.md
|
|
37
|
+
- exe/mcp-diff
|
|
38
|
+
- lib/mcp_diff.rb
|
|
39
|
+
- lib/mcp_diff/cli.rb
|
|
40
|
+
- lib/mcp_diff/client.rb
|
|
41
|
+
- lib/mcp_diff/config.rb
|
|
42
|
+
- lib/mcp_diff/core/canonical.rb
|
|
43
|
+
- lib/mcp_diff/core/classify.rb
|
|
44
|
+
- lib/mcp_diff/core/diff.rb
|
|
45
|
+
- lib/mcp_diff/core/normalize.rb
|
|
46
|
+
- lib/mcp_diff/core/rule.rb
|
|
47
|
+
- lib/mcp_diff/core/rules.rb
|
|
48
|
+
- lib/mcp_diff/core/schema_diff.rb
|
|
49
|
+
- lib/mcp_diff/render.rb
|
|
50
|
+
- lib/mcp_diff/version.rb
|
|
51
|
+
homepage: https://github.com/Serhii-Leniv/mcp-diff
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata: {}
|
|
55
|
+
post_install_message:
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '3.2'
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.5.22
|
|
71
|
+
signing_key:
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Lockfile + CI gate for your MCP server's contract — CI goes red when you
|
|
74
|
+
silently break it.
|
|
75
|
+
test_files: []
|