Module: McpDiff::Cli

Defined in:
lib/mcp_diff/cli.rb

Overview

Command dispatcher: init/snapshot/check/update/diff/explain/rules, with pretty/JSON/MD output and the 0/1/2 exit-code contract. Port of cli/src/index.ts. run() returns the process exit code.

Class Method Summary collapse

Class Method Details

.capture_options_from(cfg) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/mcp_diff/cli.rb', line 87

def capture_options_from(cfg)
  server = cfg["server"] || {}
  opts = {}
  opts[:timeout_ms] = cfg["timeoutMs"] if cfg["timeoutMs"]
  if server["stdio"]
    parts = Config.shell_split(server["stdio"])
    command = parts.shift
    raise "config server.stdio is empty" if command.nil?

    opts[:stdio] = { command: command, args: parts }
    opts[:stdio][:env] = cfg["env"] if cfg["env"]
  elsif server["url"]
    opts[:url] = server["url"]
  else
    raise "config has no server target — set server.stdio or server.url"
  end
  opts
end

.cmd_check(globals) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/mcp_diff/cli.rb', line 194

def cmd_check(globals)
  cfg = globals_config(globals)
  locked = read_lockfile(lockfile_path(cfg))
  live = live_snapshot(cfg)
  if locked["contractHash"] == live["contractHash"]
    puts Render.render(output_format(globals), [], 0)
    return 0
  end
  findings = Core::Classify.classify(Core::Diff.diff(locked, live))
  exit_code = Core::Classify.compute_exit_code(findings, cfg["strict"] || "breaking")
  puts Render.render(output_format(globals), findings, exit_code)
  if findings.empty?
    puts "ℹ contract hash changed but no rule classified the change (compatible) — run: mcp-diff update"
  end
  exit_code
end

.cmd_diff(globals, before_path, after_path) ⇒ Object



224
225
226
227
228
229
230
231
232
233
# File 'lib/mcp_diff/cli.rb', line 224

def cmd_diff(globals, before_path, after_path)
  raise "diff needs two snapshot files: mcp-diff diff <before> <after>" if before_path.nil? || after_path.nil?

  before = read_snapshot_file(before_path)
  after = read_snapshot_file(after_path)
  findings = Core::Classify.classify(Core::Diff.diff(before, after))
  exit_code = Core::Classify.compute_exit_code(findings)
  puts Render.render(output_format(globals), findings, exit_code)
  exit_code
end

.cmd_explain(rule_id) ⇒ Object



235
236
237
238
239
240
241
242
# File 'lib/mcp_diff/cli.rb', line 235

def cmd_explain(rule_id)
  entry = Core::Rules::CATALOG.find { |r| r["id"] == rule_id }
  unless entry
    known = Core::Rules::CATALOG.map { |r| r["id"] }.join("\n  ")
    raise "unknown rule \"#{rule_id}\" — known rules:\n  #{known}"
  end
  puts "#{entry['id']} [#{entry['class'].upcase}]\n\n#{entry['description']}"
end

.cmd_init(globals, opts) ⇒ Object

── commands ────────────────────────────────────────────────────



152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/mcp_diff/cli.rb', line 152

def cmd_init(globals, opts)
  config_path = globals[:config]
  raise "#{config_path} already exists — delete it first to re-init" if File.exist?(config_path)

  server = init_server_target(opts)
  cfg = { "server" => server, "strict" => "breaking" }
  write_lockfile(config_path, cfg)
  puts "wrote #{config_path}"
  snapshot = live_snapshot(cfg)
  write_lockfile(lockfile_path(cfg), snapshot)
  puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools) — commit both files"
end

.cmd_rules(globals) ⇒ Object



244
245
246
247
248
249
250
# File 'lib/mcp_diff/cli.rb', line 244

def cmd_rules(globals)
  if globals[:json]
    puts JSON.pretty_generate(Core::Rules::CATALOG)
    return
  end
  Core::Rules::CATALOG.each { |r| puts "#{r['class'].upcase.ljust(9)} #{r['id']}" }
end

.cmd_snapshot(globals) ⇒ Object



187
188
189
190
191
192
# File 'lib/mcp_diff/cli.rb', line 187

def cmd_snapshot(globals)
  cfg = globals_config(globals)
  snapshot = live_snapshot(cfg)
  write_lockfile(lockfile_path(cfg), snapshot)
  puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools)"
end

.cmd_update(globals) ⇒ Object



211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/mcp_diff/cli.rb', line 211

def cmd_update(globals)
  cfg = globals_config(globals)
  path = lockfile_path(cfg)
  live = live_snapshot(cfg)
  if File.exist?(path)
    locked = read_lockfile(path, verify: false)
    findings = Core::Classify.classify(Core::Diff.diff(locked, live))
    puts Render.render(output_format(globals), findings, 0) unless findings.empty?
  end
  write_lockfile(path, live)
  puts "updated #{path}"
end

.dispatch(command, args, globals, opts) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/mcp_diff/cli.rb', line 54

def dispatch(command, args, globals, opts)
  case command
  when "init" then cmd_init(globals, opts)
  when "snapshot" then cmd_snapshot(globals)
  when "check" then return cmd_check(globals)
  when "update" then cmd_update(globals)
  when "diff" then return cmd_diff(globals, args[0], args[1])
  when "explain" then cmd_explain(args[0])
  when "rules" then cmd_rules(globals)
  when nil then (puts help_text)
  else raise "unknown command \"#{command}\" — run: mcp-diff --help"
  end
  0
end

.globals_config(globals) ⇒ Object

── shared helpers ──────────────────────────────────────────────



71
72
73
# File 'lib/mcp_diff/cli.rb', line 71

def globals_config(globals)
  Config.load_config(globals[:config], globals[:profile])
end

.help_textObject



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/mcp_diff/cli.rb', line 252

def help_text
  <<~HELP
    mcp-diff — lockfile + CI gate for your MCP server's contract

    Usage: mcp-diff [--config <path>] [--profile <name>] [--json|--md] <command>

    Commands:
      init [--stdio "<cmd>" | --url <url>]   write config + first lockfile (auto-detects .mcp.json)
      snapshot                               (re)write mcp.lock.json
      check                                  live server vs lockfile; exit 0/1/2
      update                                 accept changes, rewrite lockfile
      diff <a.json> <b.json>                 compare two snapshots
      explain <rule-id>                      explain one rule
      rules                                  list the rule catalog
  HELP
end

.init_server_target(opts) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/mcp_diff/cli.rb', line 165

def init_server_target(opts)
  if opts[:url]
    valid = begin
      u = URI.parse(opts[:url])
      u.scheme && u.host
    rescue URI::InvalidURIError
      false
    end
    raise "\"#{opts[:url]}\" is not a valid URL — expected e.g. http://localhost:3000/mcp" unless valid

    { "url" => opts[:url] }
  elsif opts[:stdio]
    { "stdio" => opts[:stdio] }
  else
    detected = Config.detect_mcp_json
    raise "no server target — pass --stdio \"<command>\" or --url <url> (no .mcp.json found to auto-detect)" if detected.nil?

    puts "detected server from .mcp.json: #{detected['stdio']}"
    detected
  end
end

.live_snapshot(cfg) ⇒ Object



106
107
108
109
# File 'lib/mcp_diff/cli.rb', line 106

def live_snapshot(cfg)
  raw = Client.capture(**capture_options_from(cfg))
  Core::Normalize.normalize(raw, ignore: cfg["ignore"] || [])
end

.lockfile_path(cfg) ⇒ Object



83
84
85
# File 'lib/mcp_diff/cli.rb', line 83

def lockfile_path(cfg)
  cfg["lockfile"] || Config::DEFAULT_LOCKFILE
end

.output_format(globals) ⇒ Object



75
76
77
78
79
80
81
# File 'lib/mcp_diff/cli.rb', line 75

def output_format(globals)
  raise "choose one output format — pass --json or --md, not both" if globals[:json] && globals[:md]
  return "json" if globals[:json]
  return "md" if globals[:md]

  "pretty"
end

.parse(argv) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/mcp_diff/cli.rb', line 25

def parse(argv)
  globals = { config: Config::DEFAULT_CONFIG_PATH, profile: nil, json: false, md: false, handled: false }
  opts = {}
  command = nil
  args = []
  i = 0
  while i < argv.length
    a = argv[i]
    case a
    when "-c", "--config" then globals[:config] = argv[i += 1]
    when "-p", "--profile" then globals[:profile] = argv[i += 1]
    when "--json" then globals[:json] = true
    when "--md" then globals[:md] = true
    when "--stdio" then opts[:stdio] = argv[i += 1]
    when "--url" then opts[:url] = argv[i += 1]
    when "-V", "--version" then (puts VERSION; globals[:handled] = true)
    when "-h", "--help" then (puts help_text; globals[:handled] = true)
    else
      if command.nil? && !a.start_with?("-")
        command = a
      else
        args << a
      end
    end
    i += 1
  end
  [globals, command, args, opts]
end

.read_lockfile(path, verify: true) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/mcp_diff/cli.rb', line 111

def read_lockfile(path, verify: true)
  text = begin
    File.read(path)
  rescue SystemCallError
    raise "cannot read lockfile \"#{path}\" — run: npx @mcp-diff/cli snapshot"
  end
  data = begin
    JSON.parse(text)
  rescue JSON::ParserError => e
    raise "lockfile \"#{path}\" is not valid JSON (#{e.message}) — run: npx @mcp-diff/cli snapshot"
  end
  snapshot = Core::Normalize.parse_snapshot(data)
  if verify
    body = snapshot.reject { |k, _| k == "contractHash" }
    if Core::Canonical.contract_hash_of(body) != snapshot["contractHash"]
      raise "lockfile \"#{path}\" does not match its contractHash (hand-edited or bad merge?) — " \
            "run: npx @mcp-diff/cli update"
    end
  end
  snapshot
end

.read_snapshot_file(path) ⇒ Object



133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/mcp_diff/cli.rb', line 133

def read_snapshot_file(path)
  text = begin
    File.read(path)
  rescue SystemCallError
    raise "cannot read snapshot file \"#{path}\""
  end
  begin
    Core::Normalize.parse_snapshot(JSON.parse(text))
  rescue JSON::ParserError => e
    raise "snapshot file \"#{path}\": #{e.message}"
  end
end

.run(argv) ⇒ Object



15
16
17
18
19
20
21
22
23
# File 'lib/mcp_diff/cli.rb', line 15

def run(argv)
  globals, command, args, opts = parse(argv)
  return 0 if globals[:handled]

  dispatch(command, args, globals, opts)
rescue StandardError => e
  warn "#{e.message}"
  1
end

.write_lockfile(path, snapshot) ⇒ Object



146
147
148
# File 'lib/mcp_diff/cli.rb', line 146

def write_lockfile(path, snapshot)
  File.write(path, Core::Canonical.canonical_stringify(snapshot))
end