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
- .as_hash(value) ⇒ Object
-
.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.
- .make_transport(stdio:, url:) ⇒ Object
-
.raw_list(client, method, key) ⇒ Object
Follow nextCursor to the end, reading raw hashes.
- .target_desc(stdio, url) ⇒ Object
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.} — " \ "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 |