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,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "canonical"
|
|
4
|
+
|
|
5
|
+
module McpDiff
|
|
6
|
+
module Core
|
|
7
|
+
# Structural diff between two snapshots. Emits typed Change hashes (symbol
|
|
8
|
+
# keys); no severities. Port of core/src/diff.ts. Entity payloads (before/
|
|
9
|
+
# after) keep their string-keyed entry shape.
|
|
10
|
+
module Diff
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def diff(before, after)
|
|
14
|
+
changes = []
|
|
15
|
+
changes.concat(entity_changes(before["tools"], after["tools"], "name",
|
|
16
|
+
{ added: "tool-added", removed: "tool-removed", modified: "tool-modified" }, :name))
|
|
17
|
+
changes.concat(entity_changes(before["resources"], after["resources"], "uri",
|
|
18
|
+
{ added: "resource-added", removed: "resource-removed", modified: "resource-modified" }, :uri))
|
|
19
|
+
changes.concat(entity_changes(before["resourceTemplates"], after["resourceTemplates"], "uriTemplate",
|
|
20
|
+
{ added: "resource-template-added", removed: "resource-template-removed", modified: "resource-template-modified" }, :uriTemplate))
|
|
21
|
+
changes.concat(entity_changes(before["prompts"], after["prompts"], "name",
|
|
22
|
+
{ added: "prompt-added", removed: "prompt-removed", modified: "prompt-modified" }, :name))
|
|
23
|
+
changes.concat(capability_changes(before["capabilities"] || {}, after["capabilities"] || {}))
|
|
24
|
+
changes.concat(scalar_changes(before, after))
|
|
25
|
+
changes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def entity_changes(before, after, key, kinds, key_field)
|
|
29
|
+
before ||= []
|
|
30
|
+
after ||= []
|
|
31
|
+
before_map = before.to_h { |item| [item[key], item] }
|
|
32
|
+
after_map = after.to_h { |item| [item[key], item] }
|
|
33
|
+
changes = []
|
|
34
|
+
|
|
35
|
+
before_map.each do |k, item|
|
|
36
|
+
nxt = after_map[k]
|
|
37
|
+
if nxt.nil? && !after_map.key?(k)
|
|
38
|
+
changes << { kind: kinds[:removed], key_field => k, before: item }
|
|
39
|
+
elsif !Canonical.deep_equal(item, nxt)
|
|
40
|
+
changes << { kind: kinds[:modified], key_field => k, before: item, after: nxt }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
after_map.each do |k, item|
|
|
44
|
+
changes << { kind: kinds[:added], key_field => k, after: item } unless before_map.key?(k)
|
|
45
|
+
end
|
|
46
|
+
changes
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def capability_changes(before_caps, after_caps)
|
|
50
|
+
changes = []
|
|
51
|
+
(before_caps.keys | after_caps.keys).sort.each do |k|
|
|
52
|
+
b_present = before_caps.key?(k)
|
|
53
|
+
a_present = after_caps.key?(k)
|
|
54
|
+
if !b_present && a_present
|
|
55
|
+
changes << { kind: "capability-added", capability: k, after: after_caps[k] }
|
|
56
|
+
elsif b_present && !a_present
|
|
57
|
+
changes << { kind: "capability-removed", capability: k, before: before_caps[k] }
|
|
58
|
+
elsif b_present && a_present && !Canonical.deep_equal(before_caps[k], after_caps[k])
|
|
59
|
+
changes << { kind: "capability-modified", capability: k, before: before_caps[k], after: after_caps[k] }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
changes
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scalar_changes(before, after)
|
|
66
|
+
changes = []
|
|
67
|
+
unless before["instructions"] == after["instructions"]
|
|
68
|
+
changes << { kind: "instructions-changed", before: before["instructions"], after: after["instructions"] }
|
|
69
|
+
end
|
|
70
|
+
unless Canonical.deep_equal(before["server"], after["server"])
|
|
71
|
+
changes << { kind: "server-info-changed", before: before["server"], after: after["server"] }
|
|
72
|
+
end
|
|
73
|
+
unless before["protocolVersion"] == after["protocolVersion"]
|
|
74
|
+
changes << { kind: "protocol-version-changed", before: before["protocolVersion"], after: after["protocolVersion"] }
|
|
75
|
+
end
|
|
76
|
+
changes
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "canonical"
|
|
4
|
+
|
|
5
|
+
module McpDiff
|
|
6
|
+
module Core
|
|
7
|
+
# Canonicalize a raw capture into a deterministic snapshot Hash: known fields
|
|
8
|
+
# only, stable sort order, sorted keys, contractHash. Pure — same input, same
|
|
9
|
+
# output. Port of core/src/normalize.ts. Keys are strings throughout to match
|
|
10
|
+
# the lockfile shape.
|
|
11
|
+
module Normalize
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# `*`-only wildcard, anchored both ends. `split("*", -1)` keeps trailing
|
|
15
|
+
# empties so "foo*" matches like the TS RegExp does.
|
|
16
|
+
def wildcard_match(pattern, value)
|
|
17
|
+
re = Regexp.new("^#{pattern.split('*', -1).map { |p| Regexp.escape(p) }.join('.*')}$")
|
|
18
|
+
re.match?(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ignored?(name, patterns)
|
|
22
|
+
patterns.any? { |p| wildcard_match(p, name) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def as_record(value)
|
|
26
|
+
value.is_a?(Hash) ? value : {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def str_or_null(value)
|
|
30
|
+
value.is_a?(String) ? value : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def json_or_null(value)
|
|
34
|
+
value.nil? ? nil : Canonical.sort_keys_deep(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tool_entry(raw)
|
|
38
|
+
r = as_record(raw)
|
|
39
|
+
return nil unless r["name"].is_a?(String)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
"name" => r["name"],
|
|
43
|
+
"title" => str_or_null(r["title"]),
|
|
44
|
+
"description" => str_or_null(r["description"]),
|
|
45
|
+
"inputSchema" => json_or_null(r["inputSchema"]),
|
|
46
|
+
"outputSchema" => json_or_null(r["outputSchema"]),
|
|
47
|
+
"annotations" => json_or_null(r["annotations"])
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resource_entry(raw)
|
|
52
|
+
r = as_record(raw)
|
|
53
|
+
return nil unless r["uri"].is_a?(String)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
"uri" => r["uri"],
|
|
57
|
+
"name" => str_or_null(r["name"]),
|
|
58
|
+
"title" => str_or_null(r["title"]),
|
|
59
|
+
"description" => str_or_null(r["description"]),
|
|
60
|
+
"mimeType" => str_or_null(r["mimeType"])
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resource_template_entry(raw)
|
|
65
|
+
r = as_record(raw)
|
|
66
|
+
return nil unless r["uriTemplate"].is_a?(String)
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"uriTemplate" => r["uriTemplate"],
|
|
70
|
+
"name" => str_or_null(r["name"]),
|
|
71
|
+
"title" => str_or_null(r["title"]),
|
|
72
|
+
"description" => str_or_null(r["description"]),
|
|
73
|
+
"mimeType" => str_or_null(r["mimeType"])
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def prompt_entry(raw)
|
|
78
|
+
r = as_record(raw)
|
|
79
|
+
return nil unless r["name"].is_a?(String)
|
|
80
|
+
|
|
81
|
+
args = (r["arguments"].is_a?(Array) ? r["arguments"] : [])
|
|
82
|
+
.map { |a| prompt_argument(a) }
|
|
83
|
+
.compact
|
|
84
|
+
.sort_by { |a| a["name"] }
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
"name" => r["name"],
|
|
88
|
+
"title" => str_or_null(r["title"]),
|
|
89
|
+
"description" => str_or_null(r["description"]),
|
|
90
|
+
"arguments" => args
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def prompt_argument(raw)
|
|
95
|
+
ar = as_record(raw)
|
|
96
|
+
return nil unless ar["name"].is_a?(String)
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
"name" => ar["name"],
|
|
100
|
+
"description" => str_or_null(ar["description"]),
|
|
101
|
+
"required" => ar["required"] == true
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize(raw, ignore: [])
|
|
106
|
+
raw = as_record(raw)
|
|
107
|
+
server_info = raw["serverInfo"] ? as_record(raw["serverInfo"]) : nil
|
|
108
|
+
|
|
109
|
+
body = {
|
|
110
|
+
"lockfileVersion" => 1,
|
|
111
|
+
"protocolVersion" => raw["protocolVersion"] || nil,
|
|
112
|
+
"server" => server_block(server_info),
|
|
113
|
+
"capabilities" => Canonical.sort_keys_deep(as_record(raw["capabilities"])),
|
|
114
|
+
"instructions" => raw["instructions"] || nil,
|
|
115
|
+
"tools" => (raw["tools"] || []).map { |t| tool_entry(t) }
|
|
116
|
+
.compact.reject { |t| ignored?(t["name"], ignore) }
|
|
117
|
+
.sort_by { |t| t["name"] },
|
|
118
|
+
"resources" => (raw["resources"] || []).map { |r| resource_entry(r) }
|
|
119
|
+
.compact.reject { |r| ignored?(r["uri"], ignore) }
|
|
120
|
+
.sort_by { |r| r["uri"] },
|
|
121
|
+
"resourceTemplates" => (raw["resourceTemplates"] || []).map { |r| resource_template_entry(r) }
|
|
122
|
+
.compact.sort_by { |r| r["uriTemplate"] },
|
|
123
|
+
"prompts" => (raw["prompts"] || []).map { |p| prompt_entry(p) }
|
|
124
|
+
.compact.reject { |p| ignored?(p["name"], ignore) }
|
|
125
|
+
.sort_by { |p| p["name"] }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
body.merge("contractHash" => Canonical.contract_hash_of(body))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parse and lightly validate a lockfile read from disk.
|
|
132
|
+
def parse_snapshot(data)
|
|
133
|
+
r = as_record(data)
|
|
134
|
+
raise "unsupported lockfileVersion #{r['lockfileVersion'].inspect} — expected 1" unless r["lockfileVersion"] == 1
|
|
135
|
+
|
|
136
|
+
%w[tools resources resourceTemplates prompts].each do |key|
|
|
137
|
+
raise "invalid lockfile: \"#{key}\" must be an array" unless r[key].is_a?(Array)
|
|
138
|
+
end
|
|
139
|
+
data
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Build a complete snapshot from a partial one (fixtures, tests). Entries
|
|
143
|
+
# are taken as-is; only top-level defaults + contractHash are filled.
|
|
144
|
+
def make_snapshot(partial = {})
|
|
145
|
+
body = {
|
|
146
|
+
"lockfileVersion" => 1,
|
|
147
|
+
"protocolVersion" => partial["protocolVersion"],
|
|
148
|
+
"server" => partial["server"],
|
|
149
|
+
"capabilities" => partial["capabilities"] || {},
|
|
150
|
+
"instructions" => partial["instructions"],
|
|
151
|
+
"tools" => partial["tools"] || [],
|
|
152
|
+
"resources" => partial["resources"] || [],
|
|
153
|
+
"resourceTemplates" => partial["resourceTemplates"] || [],
|
|
154
|
+
"prompts" => partial["prompts"] || []
|
|
155
|
+
}
|
|
156
|
+
body.merge("contractHash" => Canonical.contract_hash_of(body))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def server_block(server_info)
|
|
160
|
+
return nil unless server_info && server_info["name"].is_a?(String)
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
"name" => server_info["name"],
|
|
164
|
+
"version" => server_info["version"].is_a?(String) ? server_info["version"] : "",
|
|
165
|
+
"title" => str_or_null(server_info["title"])
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpDiff
|
|
4
|
+
module Core
|
|
5
|
+
# A single classification rule. The match block is instance_exec'd on the
|
|
6
|
+
# rule so it can call `finding(...)` and read `id`/`severity` — mirroring the
|
|
7
|
+
# TS `this.id` / `this.class` shape. Returns a Finding hash, an array of
|
|
8
|
+
# them, or nil. Findings use string keys to match the --json output shape.
|
|
9
|
+
class Rule
|
|
10
|
+
attr_reader :id, :severity, :description
|
|
11
|
+
|
|
12
|
+
def initialize(id:, severity:, description:, &match)
|
|
13
|
+
@id = id
|
|
14
|
+
@severity = severity
|
|
15
|
+
@description = description
|
|
16
|
+
@match = match
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def match(change)
|
|
20
|
+
instance_exec(change, &@match)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def finding(target, message)
|
|
24
|
+
{ "ruleId" => @id, "class" => @severity, "target" => target, "message" => message }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
require_relative "schema_diff"
|
|
5
|
+
require_relative "canonical"
|
|
6
|
+
|
|
7
|
+
module McpDiff
|
|
8
|
+
module Core
|
|
9
|
+
# The v1 rule catalog (24 rules), faithful port of packages/core/src/rules/*.
|
|
10
|
+
# NOTE: the TS project uses one-folder-per-rule as the contributor funnel;
|
|
11
|
+
# the Ruby port keeps them in one file for the parity milestone. Splitting
|
|
12
|
+
# into per-rule files is a follow-up (see ruby/README.md).
|
|
13
|
+
module Rules
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def keys_of(value)
|
|
17
|
+
value.is_a?(Hash) ? value : {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def schema_findings(rule, change, schema_key)
|
|
21
|
+
SchemaDiff.diff_object_schema(change[:before][schema_key], change[:after][schema_key])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
25
|
+
def build
|
|
26
|
+
[
|
|
27
|
+
# ───────────────── breaking ─────────────────
|
|
28
|
+
Rule.new(id: "tool-removed", severity: "breaking",
|
|
29
|
+
description: "A tool that existed in the lockfile is no longer exposed by the server.") do |c|
|
|
30
|
+
next nil unless c[:kind] == "tool-removed"
|
|
31
|
+
|
|
32
|
+
finding(c[:name], "tool was removed")
|
|
33
|
+
end,
|
|
34
|
+
|
|
35
|
+
Rule.new(id: "tool-param-required-added", severity: "breaking",
|
|
36
|
+
description: "A tool input parameter is now required (newly required existing param, or new required param). Calls that previously validated are now rejected.") do |c|
|
|
37
|
+
next nil unless c[:kind] == "tool-modified"
|
|
38
|
+
|
|
39
|
+
f = []
|
|
40
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
41
|
+
next unless sc[:kind] == "required-added"
|
|
42
|
+
|
|
43
|
+
f << finding(c[:name], sc[:existed_before] ? %(param "#{sc[:param]}" is now required) : %(new required param "#{sc[:param]}"))
|
|
44
|
+
end
|
|
45
|
+
f.empty? ? nil : f
|
|
46
|
+
end,
|
|
47
|
+
|
|
48
|
+
Rule.new(id: "tool-param-removed", severity: "breaking",
|
|
49
|
+
description: "A tool input parameter was removed. Callers still sending it may be rejected (additionalProperties) or silently ignored.") do |c|
|
|
50
|
+
next nil unless c[:kind] == "tool-modified"
|
|
51
|
+
|
|
52
|
+
f = []
|
|
53
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
54
|
+
f << finding(c[:name], %(param "#{sc[:param]}" was removed)) if sc[:kind] == "param-removed"
|
|
55
|
+
end
|
|
56
|
+
f.empty? ? nil : f
|
|
57
|
+
end,
|
|
58
|
+
|
|
59
|
+
Rule.new(id: "tool-param-type-narrowed", severity: "breaking",
|
|
60
|
+
description: "A tool input parameter accepts fewer types or values than before (type set shrank, or an enum constraint appeared). Inputs that used to validate are now rejected.") do |c|
|
|
61
|
+
next nil unless c[:kind] == "tool-modified"
|
|
62
|
+
|
|
63
|
+
f = []
|
|
64
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
65
|
+
case sc[:kind]
|
|
66
|
+
when "type-narrowed"
|
|
67
|
+
f << finding(c[:name], %(param "#{sc[:param]}" type narrowed (#{sc[:before]} → #{sc[:after]})))
|
|
68
|
+
when "enum-constraint-added"
|
|
69
|
+
f << finding(c[:name], %(param "#{sc[:param]}" is now restricted to an enum))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
f.empty? ? nil : f
|
|
73
|
+
end,
|
|
74
|
+
|
|
75
|
+
Rule.new(id: "tool-enum-value-removed", severity: "breaking",
|
|
76
|
+
description: "An enum value was removed from a tool input parameter. Calls sending the removed value are now rejected.") do |c|
|
|
77
|
+
next nil unless c[:kind] == "tool-modified"
|
|
78
|
+
|
|
79
|
+
f = []
|
|
80
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
81
|
+
next unless sc[:kind] == "enum-value-removed"
|
|
82
|
+
|
|
83
|
+
f << finding(c[:name], %(param "#{sc[:param]}": enum value(s) removed: #{SchemaDiff.format_values(sc[:values])}))
|
|
84
|
+
end
|
|
85
|
+
f.empty? ? nil : f
|
|
86
|
+
end,
|
|
87
|
+
|
|
88
|
+
Rule.new(id: "tool-input-schema-incompatible", severity: "breaking",
|
|
89
|
+
description: "Catch-all: the tool's input schema changed in a way mcp-diff cannot prove compatible (nested or unrecognized JSON-Schema constructs). Fails loud rather than silently passing (D9).") do |c|
|
|
90
|
+
next nil unless c[:kind] == "tool-modified"
|
|
91
|
+
|
|
92
|
+
f = []
|
|
93
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
94
|
+
case sc[:kind]
|
|
95
|
+
when "param-changed-opaque"
|
|
96
|
+
f << finding(c[:name], %(param "#{sc[:param]}" schema changed — cannot prove compatibility))
|
|
97
|
+
when "schema-changed-opaque"
|
|
98
|
+
f << finding(c[:name], "input schema changed — cannot prove compatibility")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
f.empty? ? nil : f
|
|
102
|
+
end,
|
|
103
|
+
|
|
104
|
+
Rule.new(id: "tool-output-schema-incompatible", severity: "breaking",
|
|
105
|
+
description: "The tool's output schema no longer keeps its promise: a field was removed, made optional, widened, or changed unrecognizably. Outputs may no longer validate against what consumers expect (covariant, D9).") do |c|
|
|
106
|
+
next nil unless c[:kind] == "tool-modified"
|
|
107
|
+
|
|
108
|
+
f = []
|
|
109
|
+
SchemaDiff.diff_object_schema(c[:before]["outputSchema"], c[:after]["outputSchema"]).each do |sc|
|
|
110
|
+
msg =
|
|
111
|
+
case sc[:kind]
|
|
112
|
+
when "param-removed" then %(output field "#{sc[:param]}" was removed)
|
|
113
|
+
when "required-removed" then %(output field "#{sc[:param]}" is no longer guaranteed)
|
|
114
|
+
when "type-widened" then %(output field "#{sc[:param]}" type widened (#{sc[:before]} → #{sc[:after]}))
|
|
115
|
+
when "enum-value-added" then %(output field "#{sc[:param]}": new enum value(s) old consumers don't expect: #{SchemaDiff.format_values(sc[:values])})
|
|
116
|
+
when "enum-constraint-removed" then %(output field "#{sc[:param]}" is no longer enum-constrained)
|
|
117
|
+
when "param-changed-opaque" then %(output field "#{sc[:param]}" schema changed — cannot prove compatibility)
|
|
118
|
+
when "schema-changed-opaque" then "output schema changed — cannot prove compatibility"
|
|
119
|
+
end
|
|
120
|
+
f << finding(c[:name], msg) if msg
|
|
121
|
+
end
|
|
122
|
+
f.empty? ? nil : f
|
|
123
|
+
end,
|
|
124
|
+
|
|
125
|
+
Rule.new(id: "capability-removed", severity: "breaking",
|
|
126
|
+
description: "A server capability (tools, resources, prompts, logging, …) is no longer advertised. Clients relying on it will fail.") do |c|
|
|
127
|
+
next nil unless c[:kind] == "capability-removed"
|
|
128
|
+
|
|
129
|
+
finding(c[:capability], "capability was removed")
|
|
130
|
+
end,
|
|
131
|
+
|
|
132
|
+
Rule.new(id: "resource-removed", severity: "breaking",
|
|
133
|
+
description: "A resource that existed in the lockfile is no longer exposed (matched by uri).") do |c|
|
|
134
|
+
next nil unless c[:kind] == "resource-removed"
|
|
135
|
+
|
|
136
|
+
finding(c[:uri], "resource was removed")
|
|
137
|
+
end,
|
|
138
|
+
|
|
139
|
+
Rule.new(id: "prompt-removed", severity: "breaking",
|
|
140
|
+
description: "A prompt that existed in the lockfile is no longer exposed (matched by name).") do |c|
|
|
141
|
+
next nil unless c[:kind] == "prompt-removed"
|
|
142
|
+
|
|
143
|
+
finding(c[:name], "prompt was removed")
|
|
144
|
+
end,
|
|
145
|
+
|
|
146
|
+
# ───────────────── behavior ─────────────────
|
|
147
|
+
Rule.new(id: "tool-description-changed", severity: "behavior",
|
|
148
|
+
description: "A tool's description text changed. For MCP the description is the prompt surface steering the LLM — a change can alter when and how agents call the tool.") do |c|
|
|
149
|
+
next nil unless c[:kind] == "tool-modified"
|
|
150
|
+
next nil if c[:before]["description"] == c[:after]["description"]
|
|
151
|
+
|
|
152
|
+
finding(c[:name], "description text changed (LLM prompt surface)")
|
|
153
|
+
end,
|
|
154
|
+
|
|
155
|
+
Rule.new(id: "tool-param-description-changed", severity: "behavior",
|
|
156
|
+
description: "A tool input parameter's description changed. Param descriptions are read by the LLM when filling arguments — a change can alter what agents pass.") do |c|
|
|
157
|
+
next nil unless c[:kind] == "tool-modified"
|
|
158
|
+
|
|
159
|
+
f = []
|
|
160
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
161
|
+
next unless sc[:kind] == "description-changed"
|
|
162
|
+
|
|
163
|
+
f << finding(c[:name], %(param "#{sc[:param]}" description changed (LLM prompt surface)))
|
|
164
|
+
end
|
|
165
|
+
f.empty? ? nil : f
|
|
166
|
+
end,
|
|
167
|
+
|
|
168
|
+
Rule.new(id: "tool-annotation-changed", severity: "behavior",
|
|
169
|
+
description: "Tool annotations changed (readOnlyHint, destructiveHint, idempotentHint, …). Clients use these to decide confirmation prompts and parallelism — a change alters agent behavior.") do |c|
|
|
170
|
+
next nil unless c[:kind] == "tool-modified"
|
|
171
|
+
next nil if Canonical.deep_equal(c[:before]["annotations"], c[:after]["annotations"])
|
|
172
|
+
|
|
173
|
+
before = Rules.keys_of(c[:before]["annotations"])
|
|
174
|
+
after = Rules.keys_of(c[:after]["annotations"])
|
|
175
|
+
changed = (before.keys | after.keys).reject { |k| Canonical.deep_equal(before[k], after[k]) }.sort
|
|
176
|
+
finding(c[:name], "annotations changed: #{changed.join(', ')}")
|
|
177
|
+
end,
|
|
178
|
+
|
|
179
|
+
Rule.new(id: "server-instructions-changed", severity: "behavior",
|
|
180
|
+
description: "The server's instructions text changed. Instructions are injected into the client's system context — the LLM prompt surface for the whole server.") do |c|
|
|
181
|
+
next nil unless c[:kind] == "instructions-changed"
|
|
182
|
+
|
|
183
|
+
detail = c[:before].nil? ? "added" : (c[:after].nil? ? "removed" : "changed")
|
|
184
|
+
finding("server", "instructions #{detail} (LLM prompt surface)")
|
|
185
|
+
end,
|
|
186
|
+
|
|
187
|
+
Rule.new(id: "default-value-changed", severity: "behavior",
|
|
188
|
+
description: "A tool input parameter's default value changed. Calls that omit the param now behave differently.") do |c|
|
|
189
|
+
next nil unless c[:kind] == "tool-modified"
|
|
190
|
+
|
|
191
|
+
f = []
|
|
192
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
193
|
+
next unless sc[:kind] == "default-changed"
|
|
194
|
+
|
|
195
|
+
f << finding(c[:name], %(param "#{sc[:param]}" default value changed))
|
|
196
|
+
end
|
|
197
|
+
f.empty? ? nil : f
|
|
198
|
+
end,
|
|
199
|
+
|
|
200
|
+
# ───────────────── compat ─────────────────
|
|
201
|
+
Rule.new(id: "tool-added", severity: "compat",
|
|
202
|
+
description: "A new tool is exposed by the server.") do |c|
|
|
203
|
+
next nil unless c[:kind] == "tool-added"
|
|
204
|
+
|
|
205
|
+
finding(c[:name], "new tool")
|
|
206
|
+
end,
|
|
207
|
+
|
|
208
|
+
Rule.new(id: "tool-param-optional-added", severity: "compat",
|
|
209
|
+
description: "A new optional input parameter was added. Existing calls keep working.") do |c|
|
|
210
|
+
next nil unless c[:kind] == "tool-modified"
|
|
211
|
+
|
|
212
|
+
f = []
|
|
213
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
214
|
+
next unless sc[:kind] == "param-added" && !sc[:required]
|
|
215
|
+
|
|
216
|
+
f << finding(c[:name], %(new optional param "#{sc[:param]}"))
|
|
217
|
+
end
|
|
218
|
+
f.empty? ? nil : f
|
|
219
|
+
end,
|
|
220
|
+
|
|
221
|
+
Rule.new(id: "tool-enum-value-added", severity: "compat",
|
|
222
|
+
description: "An enum value was added to a tool input parameter. Existing calls keep working; new inputs are accepted.") do |c|
|
|
223
|
+
next nil unless c[:kind] == "tool-modified"
|
|
224
|
+
|
|
225
|
+
f = []
|
|
226
|
+
SchemaDiff.diff_object_schema(c[:before]["inputSchema"], c[:after]["inputSchema"]).each do |sc|
|
|
227
|
+
next unless sc[:kind] == "enum-value-added"
|
|
228
|
+
|
|
229
|
+
f << finding(c[:name], %(param "#{sc[:param]}": enum value(s) added: #{SchemaDiff.format_values(sc[:values])}))
|
|
230
|
+
end
|
|
231
|
+
f.empty? ? nil : f
|
|
232
|
+
end,
|
|
233
|
+
|
|
234
|
+
Rule.new(id: "tool-output-field-added", severity: "compat",
|
|
235
|
+
description: "The tool's output schema gained a field. Old consumers ignore fields they don't know (covariant, D9).") do |c|
|
|
236
|
+
next nil unless c[:kind] == "tool-modified"
|
|
237
|
+
|
|
238
|
+
f = []
|
|
239
|
+
SchemaDiff.diff_object_schema(c[:before]["outputSchema"], c[:after]["outputSchema"]).each do |sc|
|
|
240
|
+
next unless sc[:kind] == "param-added"
|
|
241
|
+
|
|
242
|
+
f << finding(c[:name], %(output schema gained field "#{sc[:param]}"))
|
|
243
|
+
end
|
|
244
|
+
f.empty? ? nil : f
|
|
245
|
+
end,
|
|
246
|
+
|
|
247
|
+
Rule.new(id: "resource-added", severity: "compat",
|
|
248
|
+
description: "A new resource is exposed by the server.") do |c|
|
|
249
|
+
next nil unless c[:kind] == "resource-added"
|
|
250
|
+
|
|
251
|
+
finding(c[:uri], "new resource")
|
|
252
|
+
end,
|
|
253
|
+
|
|
254
|
+
Rule.new(id: "prompt-added", severity: "compat",
|
|
255
|
+
description: "A new prompt is exposed by the server.") do |c|
|
|
256
|
+
next nil unless c[:kind] == "prompt-added"
|
|
257
|
+
|
|
258
|
+
finding(c[:name], "new prompt")
|
|
259
|
+
end,
|
|
260
|
+
|
|
261
|
+
Rule.new(id: "capability-added", severity: "compat",
|
|
262
|
+
description: "The server advertises a new capability.") do |c|
|
|
263
|
+
next nil unless c[:kind] == "capability-added"
|
|
264
|
+
|
|
265
|
+
finding(c[:capability], "new capability")
|
|
266
|
+
end,
|
|
267
|
+
|
|
268
|
+
# ───────────────── info ─────────────────
|
|
269
|
+
Rule.new(id: "server-version-changed", severity: "info",
|
|
270
|
+
description: "The server's reported version changed. Informational — not a contract change by itself.") do |c|
|
|
271
|
+
next nil unless c[:kind] == "server-info-changed"
|
|
272
|
+
|
|
273
|
+
before = (c[:before] || {})["version"] || "(none)"
|
|
274
|
+
after = (c[:after] || {})["version"] || "(none)"
|
|
275
|
+
next nil if before == after
|
|
276
|
+
|
|
277
|
+
finding("server", "version #{before} → #{after}")
|
|
278
|
+
end,
|
|
279
|
+
|
|
280
|
+
Rule.new(id: "title-changed", severity: "info",
|
|
281
|
+
description: "A display title changed (server, tool, resource, or prompt). Titles are for humans, not the model — informational.") do |c|
|
|
282
|
+
case c[:kind]
|
|
283
|
+
when "tool-modified", "prompt-modified"
|
|
284
|
+
c[:before]["title"] != c[:after]["title"] ? finding(c[:name], "title changed") : nil
|
|
285
|
+
when "resource-modified"
|
|
286
|
+
c[:before]["title"] != c[:after]["title"] ? finding(c[:uri], "title changed") : nil
|
|
287
|
+
when "server-info-changed"
|
|
288
|
+
(c[:before] || {})["title"] != (c[:after] || {})["title"] ? finding("server", "title changed") : nil
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
]
|
|
292
|
+
end
|
|
293
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
294
|
+
|
|
295
|
+
ALL = build
|
|
296
|
+
CATALOG = ALL.map { |r| { "id" => r.id, "class" => r.severity, "description" => r.description } }.freeze
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|