Module: McpDiff::Client

Defined in:
lib/mcp_diff/client.rb

Overview

Capture an MCP server's complete public surface over the official mcp gem. The ONLY component that talks to servers (keeps Core pure). Port of client/src/index.ts.

Note: the gem's MCP::Client#tools/#resources/... wrap results into objects that DROP annotations and title. Those are part of the contract (BEHAVIOR class), so we read the raw JSON one layer down via the client's own private request (full JSON-RPC envelope + id correlation, nothing dropped). This is covered by capture_test.rb — if a future mcp version changes it, that fails.

Constant Summary collapse

DEFAULT_TIMEOUT_MS =
30_000

Class Method Summary collapse

Class Method Details

.as_hash(value) ⇒ Object



89
90
91
# File 'lib/mcp_diff/client.rb', line 89

def as_hash(value)
  value.is_a?(Hash) ? value : {}
end

.capture(stdio: nil, url: nil, timeout_ms: DEFAULT_TIMEOUT_MS) ⇒ Object

stdio: { command:, args: [], env: {} } | url: "http://…/mcp" Returns a RawCapture Hash (string keys) matching Core's normalize input.



23
24
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
# File 'lib/mcp_diff/client.rb', line 23

def capture(stdio: nil, url: nil, timeout_ms: DEFAULT_TIMEOUT_MS)
  transport = make_transport(stdio: stdio, url: url)
  client = MCP::Client.new(transport: transport)

  Timeout.timeout(timeout_ms / 1000.0, nil, "server did not respond within #{timeout_ms}ms") do
    client.connect
    info = as_hash(client.server_info)
    caps = as_hash(info["capabilities"])

    {
      "protocolVersion" => info["protocolVersion"],
      "serverInfo" => info["serverInfo"],
      "capabilities" => caps,
      "instructions" => info["instructions"],
      "tools" => caps["tools"] ? raw_list(client, "tools/list", "tools") : [],
      "resources" => caps["resources"] ? raw_list(client, "resources/list", "resources") : [],
      "resourceTemplates" => caps["resources"] ? raw_list(client, "resources/templates/list", "resourceTemplates") : [],
      "prompts" => caps["prompts"] ? raw_list(client, "prompts/list", "prompts") : []
    }
  end
rescue Timeout::Error
  raise
rescue StandardError => e
  raise "cannot capture server (#{target_desc(stdio, url)}) — #{e.message}" \
        "check the command/URL and that the server builds & starts"
ensure
  transport.close if transport.respond_to?(:close)
end

.make_transport(stdio:, url:) ⇒ Object



71
72
73
74
75
76
77
78
79
# File 'lib/mcp_diff/client.rb', line 71

def make_transport(stdio:, url:)
  if stdio
    MCP::Client::Stdio.new(command: stdio[:command], args: stdio[:args] || [], env: stdio[:env])
  elsif url
    MCP::Client::HTTP.new(url: url)
  else
    raise "no server target: provide either stdio or url"
  end
end

.raw_list(client, method, key) ⇒ Object

Follow nextCursor to the end, reading raw hashes. Guards against a server that returns a repeating cursor (would loop forever).



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

def raw_list(client, method, key)
  items = []
  cursor = nil
  seen = {}
  loop do
    params = cursor ? { cursor: cursor } : {}
    result = as_hash(client.send(:request, method: method, params: params)["result"])
    items.concat(result[key] || [])
    cursor = result["nextCursor"]
    break if cursor.nil?
    raise "server returned a repeating pagination cursor" if seen[cursor]

    seen[cursor] = true
  end
  items
end

.target_desc(stdio, url) ⇒ Object



81
82
83
84
85
86
87
# File 'lib/mcp_diff/client.rb', line 81

def target_desc(stdio, url)
  if stdio
    %(stdio: "#{[stdio[:command], *(stdio[:args] || [])].join(' ')}")
  else
    url || "no target"
  end
end