Module: McpDiff::Config

Defined in:
lib/mcp_diff/config.rb

Overview

Config loading + validation (port of cli/src/config.ts). Actionable error messages: every failure says how to fix it. Config uses string keys (JSON).

Constant Summary collapse

DEFAULT_CONFIG_PATH =
"mcp-diff.config.json"
DEFAULT_LOCKFILE =
"mcp.lock.json"

Class Method Summary collapse

Class Method Details

.apply_profile(config, profile) ⇒ Object



33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/mcp_diff/config.rb', line 33

def apply_profile(config, profile)
  overlay = (config["profiles"] || {})[profile]
  if overlay.nil?
    known = (config["profiles"] || {}).keys.join(", ")
    known = "none defined" if known.empty?
    raise "unknown profile \"#{profile}\" — known profiles: #{known}"
  end
  merged = config.merge(overlay)
  merged["env"] = (config["env"] || {}).merge(overlay["env"] || {})
  merged["server"] = overlay["server"] || config["server"]
  merged
end

.detect_mcp_jsonObject



92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/mcp_diff/config.rb', line 92

def detect_mcp_json
  return nil unless File.exist?(".mcp.json")

  parsed = JSON.parse(File.read(".mcp.json"))
  servers = parsed["mcpServers"] || {}
  _name, server = servers.first
  return nil unless server.is_a?(Hash) && server["command"].is_a?(String)

  args = (server["args"] || []).map { |a| a.match?(/\s/) ? "\"#{a}\"" : a }.join(" ")
  { "stdio" => args.empty? ? server["command"] : "#{server['command']} #{args}" }
rescue StandardError
  nil
end

.load_config(path, profile = nil) ⇒ Object



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

def load_config(path, profile = nil)
  text = read_config_text(path)
  begin
    config = JSON.parse(text)
  rescue JSON::ParserError => e
    raise "config \"#{path}\" is not valid JSON: #{e.message}"
  end
  config = apply_profile(config, profile) unless profile.nil?
  validate_config(path, config)
  config
end

.read_config_text(path) ⇒ Object



27
28
29
30
31
# File 'lib/mcp_diff/config.rb', line 27

def read_config_text(path)
  File.read(path)
rescue SystemCallError
  raise "cannot read config \"#{path}\" — run: npx @mcp-diff/cli init"
end

.shell_split(command) ⇒ Object

Split a command line into argv, honoring single/double quotes.



88
89
90
# File 'lib/mcp_diff/config.rb', line 88

def shell_split(command)
  command.scan(/"([^"]*)"|'([^']*)'|(\S+)/).map { |dq, sq, bare| dq || sq || bare }
end

.validate_config(path, config) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
# File 'lib/mcp_diff/config.rb', line 46

def validate_config(path, config)
  invalid = ->(msg) { raise "config \"#{path}\": #{msg}" }
  validate_server(config["server"], invalid) if config.key?("server")
  invalid.call('"lockfile" must be a string path') if config.key?("lockfile") && !config["lockfile"].is_a?(String)
  validate_timeout(config["timeoutMs"], invalid) if config.key?("timeoutMs")
  validate_ignore(config["ignore"], invalid) if config.key?("ignore")
  if config.key?("strict") && !%w[breaking behavior].include?(config["strict"])
    invalid.call('"strict" must be "breaking" or "behavior"')
  end
  validate_env(config["env"], invalid) if config.key?("env")
end

.validate_env(value, invalid) ⇒ Object



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

def validate_env(value, invalid)
  ok = value.is_a?(Hash) && value.values.all?(String)
  invalid.call('"env" must be an object of string values') unless ok
end

.validate_ignore(value, invalid) ⇒ Object



78
79
80
# File 'lib/mcp_diff/config.rb', line 78

def validate_ignore(value, invalid)
  invalid.call('"ignore" must be an array of string patterns') unless value.is_a?(Array) && value.all?(String)
end

.validate_server(server, invalid) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/mcp_diff/config.rb', line 58

def validate_server(server, invalid)
  invalid.call('"server" must be an object with "stdio" or "url"') unless server.is_a?(Hash)
  if server.key?("stdio") && !server["stdio"].is_a?(String)
    invalid.call('"server.stdio" must be a string command line')
  end
  return unless server.key?("url")

  invalid.call('"server.url" must be a string') unless server["url"].is_a?(String)
  begin
    u = URI.parse(server["url"])
    invalid.call("\"server.url\" is not a valid URL: #{server['url']}") unless u.scheme && u.host
  rescue URI::InvalidURIError
    invalid.call("\"server.url\" is not a valid URL: #{server['url']}")
  end
end

.validate_timeout(value, invalid) ⇒ Object



74
75
76
# File 'lib/mcp_diff/config.rb', line 74

def validate_timeout(value, invalid)
  invalid.call('"timeoutMs" must be a positive number') unless value.is_a?(Numeric) && value.finite? && value.positive?
end