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,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