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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpDiff
4
+ VERSION = "0.1.0"
5
+ 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: []