|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# Conformance test client for the MCP Ruby SDK. |
| 4 | +# Invoked by the conformance runner: |
| 5 | +# MCP_CONFORMANCE_SCENARIO=<scenario> bundle exec ruby conformance/client.rb <server-url> |
| 6 | +# |
| 7 | +# The server URL is passed as the last positional argument. |
| 8 | +# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, |
| 9 | +# which is set automatically by the conformance test runner. |
| 10 | + |
| 11 | +require "net/http" |
| 12 | +require "json" |
| 13 | +require "securerandom" |
| 14 | +require "uri" |
| 15 | +require_relative "../lib/mcp" |
| 16 | + |
| 17 | +# A transport that handles both JSON and SSE (text/event-stream) responses. |
| 18 | +# The standard `MCP::Client::HTTP` transport only accepts application/json, |
| 19 | +# but the MCP `StreamableHTTPServerTransport` may return text/event-stream. |
| 20 | +class ConformanceTransport |
| 21 | + def initialize(url:) |
| 22 | + @uri = URI(url) |
| 23 | + end |
| 24 | + |
| 25 | + def send_request(request:) |
| 26 | + http = Net::HTTP.new(@uri.host, @uri.port) |
| 27 | + req = Net::HTTP::Post.new(@uri.path.empty? ? "/" : @uri.path) |
| 28 | + req["Content-Type"] = "application/json" |
| 29 | + req["Accept"] = "application/json, text/event-stream" |
| 30 | + req.body = JSON.generate(request) |
| 31 | + |
| 32 | + response = http.request(req) |
| 33 | + |
| 34 | + case response.content_type |
| 35 | + when "application/json" |
| 36 | + JSON.parse(response.body) |
| 37 | + when "text/event-stream" |
| 38 | + parse_sse_response(response.body) |
| 39 | + else |
| 40 | + raise "Unexpected content type: #{response.content_type}" |
| 41 | + end |
| 42 | + end |
| 43 | + |
| 44 | + private |
| 45 | + |
| 46 | + def parse_sse_response(body) |
| 47 | + body.each_line do |line| |
| 48 | + next unless line.start_with?("data: ") |
| 49 | + |
| 50 | + data = line.delete_prefix("data: ").strip |
| 51 | + next if data.empty? |
| 52 | + |
| 53 | + return JSON.parse(data) |
| 54 | + end |
| 55 | + nil |
| 56 | + end |
| 57 | +end |
| 58 | + |
| 59 | +scenario = ENV["MCP_CONFORMANCE_SCENARIO"] |
| 60 | +server_url = ARGV.last |
| 61 | + |
| 62 | +unless scenario && server_url |
| 63 | + abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>") |
| 64 | +end |
| 65 | + |
| 66 | +# |
| 67 | +# TODO: Once https://github.com/modelcontextprotocol/ruby-sdk/pull/210 is merged, |
| 68 | +# replace `ConformanceTransport` and the manual initialize handshake below with: |
| 69 | +# |
| 70 | +# ``` |
| 71 | +# transport = MCP::Client::HTTP.new(url: server_url) |
| 72 | +# client = MCP::Client.new(transport: transport) |
| 73 | +# client.connect(client_info: { ... }, protocol_version: "2025-11-25") |
| 74 | +# ``` |
| 75 | +# |
| 76 | +# After that `ConformanceTransport` will be removed. |
| 77 | +# |
| 78 | +transport = ConformanceTransport.new(url: server_url) |
| 79 | + |
| 80 | +# MCP initialize handshake (the MCP::Client API does not expose this yet). |
| 81 | +transport.send_request(request: { |
| 82 | + jsonrpc: "2.0", |
| 83 | + id: SecureRandom.uuid, |
| 84 | + method: "initialize", |
| 85 | + params: { |
| 86 | + clientInfo: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }, |
| 87 | + protocolVersion: "2025-11-25", |
| 88 | + capabilities: {}, |
| 89 | + }, |
| 90 | +}) |
| 91 | + |
| 92 | +client = MCP::Client.new(transport: transport) |
| 93 | + |
| 94 | +case scenario |
| 95 | +when "initialize" |
| 96 | + client.tools |
| 97 | +when "tools_call" |
| 98 | + tools = client.tools |
| 99 | + add_numbers = tools.find { |t| t.name == "add_numbers" } |
| 100 | + abort("Tool add_numbers not found") unless add_numbers |
| 101 | + client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 }) |
| 102 | +else |
| 103 | + abort("Unknown or unsupported scenario: #{scenario}") |
| 104 | +end |
0 commit comments