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

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

Returns:

  • (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