Module: McpDiff::Core::Normalize
- Defined in:
- lib/mcp_diff/core/normalize.rb
Overview
Canonicalize a raw capture into a deterministic snapshot Hash: known fields only, stable sort order, sorted keys, contractHash. Pure — same input, same output. Port of core/src/normalize.ts. Keys are strings throughout to match the lockfile shape.
Class Method Summary collapse
- .as_record(value) ⇒ Object
- .ignored?(name, patterns) ⇒ Boolean
- .json_or_null(value) ⇒ Object
-
.make_snapshot(partial = {}) ⇒ Object
Build a complete snapshot from a partial one (fixtures, tests).
- .normalize(raw, ignore: []) ⇒ Object
-
.parse_snapshot(data) ⇒ Object
Parse and lightly validate a lockfile read from disk.
- .prompt_argument(raw) ⇒ Object
- .prompt_entry(raw) ⇒ Object
- .resource_entry(raw) ⇒ Object
- .resource_template_entry(raw) ⇒ Object
- .server_block(server_info) ⇒ Object
- .str_or_null(value) ⇒ Object
- .tool_entry(raw) ⇒ Object
-
.wildcard_match(pattern, value) ⇒ Object
*-only wildcard, anchored both ends.
Class Method Details
.as_record(value) ⇒ Object
25 26 27 |
# File 'lib/mcp_diff/core/normalize.rb', line 25 def as_record(value) value.is_a?(Hash) ? value : {} end |
.ignored?(name, patterns) ⇒ Boolean
21 22 23 |
# File 'lib/mcp_diff/core/normalize.rb', line 21 def ignored?(name, patterns) patterns.any? { |p| wildcard_match(p, name) } end |
.json_or_null(value) ⇒ Object
33 34 35 |
# File 'lib/mcp_diff/core/normalize.rb', line 33 def json_or_null(value) value.nil? ? nil : Canonical.sort_keys_deep(value) end |
.make_snapshot(partial = {}) ⇒ Object
Build a complete snapshot from a partial one (fixtures, tests). Entries are taken as-is; only top-level defaults + contractHash are filled.
144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/mcp_diff/core/normalize.rb', line 144 def make_snapshot(partial = {}) body = { "lockfileVersion" => 1, "protocolVersion" => partial["protocolVersion"], "server" => partial["server"], "capabilities" => partial["capabilities"] || {}, "instructions" => partial["instructions"], "tools" => partial["tools"] || [], "resources" => partial["resources"] || [], "resourceTemplates" => partial["resourceTemplates"] || [], "prompts" => partial["prompts"] || [] } body.merge("contractHash" => Canonical.contract_hash_of(body)) end |
.normalize(raw, ignore: []) ⇒ Object
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/mcp_diff/core/normalize.rb', line 105 def normalize(raw, ignore: []) raw = as_record(raw) server_info = raw["serverInfo"] ? as_record(raw["serverInfo"]) : nil body = { "lockfileVersion" => 1, "protocolVersion" => raw["protocolVersion"] || nil, "server" => server_block(server_info), "capabilities" => Canonical.sort_keys_deep(as_record(raw["capabilities"])), "instructions" => raw["instructions"] || nil, "tools" => (raw["tools"] || []).map { |t| tool_entry(t) } .compact.reject { |t| ignored?(t["name"], ignore) } .sort_by { |t| t["name"] }, "resources" => (raw["resources"] || []).map { |r| resource_entry(r) } .compact.reject { |r| ignored?(r["uri"], ignore) } .sort_by { |r| r["uri"] }, "resourceTemplates" => (raw["resourceTemplates"] || []).map { |r| resource_template_entry(r) } .compact.sort_by { |r| r["uriTemplate"] }, "prompts" => (raw["prompts"] || []).map { |p| prompt_entry(p) } .compact.reject { |p| ignored?(p["name"], ignore) } .sort_by { |p| p["name"] } } body.merge("contractHash" => Canonical.contract_hash_of(body)) end |
.parse_snapshot(data) ⇒ Object
Parse and lightly validate a lockfile read from disk.
132 133 134 135 136 137 138 139 140 |
# File 'lib/mcp_diff/core/normalize.rb', line 132 def parse_snapshot(data) r = as_record(data) raise "unsupported lockfileVersion #{r['lockfileVersion'].inspect} — expected 1" unless r["lockfileVersion"] == 1 %w[tools resources resourceTemplates prompts].each do |key| raise "invalid lockfile: \"#{key}\" must be an array" unless r[key].is_a?(Array) end data end |
.prompt_argument(raw) ⇒ Object
94 95 96 97 98 99 100 101 102 103 |
# File 'lib/mcp_diff/core/normalize.rb', line 94 def prompt_argument(raw) ar = as_record(raw) return nil unless ar["name"].is_a?(String) { "name" => ar["name"], "description" => str_or_null(ar["description"]), "required" => ar["required"] == true } end |
.prompt_entry(raw) ⇒ Object
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/mcp_diff/core/normalize.rb', line 77 def prompt_entry(raw) r = as_record(raw) return nil unless r["name"].is_a?(String) args = (r["arguments"].is_a?(Array) ? r["arguments"] : []) .map { |a| prompt_argument(a) } .compact .sort_by { |a| a["name"] } { "name" => r["name"], "title" => str_or_null(r["title"]), "description" => str_or_null(r["description"]), "arguments" => args } end |
.resource_entry(raw) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/mcp_diff/core/normalize.rb', line 51 def resource_entry(raw) r = as_record(raw) return nil unless r["uri"].is_a?(String) { "uri" => r["uri"], "name" => str_or_null(r["name"]), "title" => str_or_null(r["title"]), "description" => str_or_null(r["description"]), "mimeType" => str_or_null(r["mimeType"]) } end |
.resource_template_entry(raw) ⇒ Object
64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/mcp_diff/core/normalize.rb', line 64 def resource_template_entry(raw) r = as_record(raw) return nil unless r["uriTemplate"].is_a?(String) { "uriTemplate" => r["uriTemplate"], "name" => str_or_null(r["name"]), "title" => str_or_null(r["title"]), "description" => str_or_null(r["description"]), "mimeType" => str_or_null(r["mimeType"]) } end |
.server_block(server_info) ⇒ Object
159 160 161 162 163 164 165 166 167 |
# File 'lib/mcp_diff/core/normalize.rb', line 159 def server_block(server_info) return nil unless server_info && server_info["name"].is_a?(String) { "name" => server_info["name"], "version" => server_info["version"].is_a?(String) ? server_info["version"] : "", "title" => str_or_null(server_info["title"]) } end |
.str_or_null(value) ⇒ Object
29 30 31 |
# File 'lib/mcp_diff/core/normalize.rb', line 29 def str_or_null(value) value.is_a?(String) ? value : nil end |
.tool_entry(raw) ⇒ Object
37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/mcp_diff/core/normalize.rb', line 37 def tool_entry(raw) r = as_record(raw) return nil unless r["name"].is_a?(String) { "name" => r["name"], "title" => str_or_null(r["title"]), "description" => str_or_null(r["description"]), "inputSchema" => json_or_null(r["inputSchema"]), "outputSchema" => json_or_null(r["outputSchema"]), "annotations" => json_or_null(r["annotations"]) } end |
.wildcard_match(pattern, value) ⇒ Object
*-only wildcard, anchored both ends. split("*", -1) keeps trailing
empties so "foo*" matches like the TS RegExp does.
16 17 18 19 |
# File 'lib/mcp_diff/core/normalize.rb', line 16 def wildcard_match(pattern, value) re = Regexp.new("^#{pattern.split('*', -1).map { |p| Regexp.escape(p) }.join('.*')}$") re.match?(value) end |