Merge pull request #293 from koic/conformance_client

Support running MCP client conformance tests against the Ruby SDK
This commit is contained in:
Koichi ITO 2026-05-10 23:34:17 +09:00 • committed by GitHub
commit e47607defa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 131 additions and 17 deletions

View file

@ -20,7 +20,8 @@ desc "Run MCP conformance tests (PORT, SCENARIO, SPEC_VERSION, VERBOSE)"
task :conformance do |t| task :conformance do |t|
next unless npx_available?(t.name) next unless npx_available?(t.name)
require_relative "conformance/runner" require_relative "conformance/server_runner"
require_relative "conformance/client_runner"
options = {} options = {}
options[:port] = Integer(ENV["PORT"]) if ENV["PORT"] options[:port] = Integer(ENV["PORT"]) if ENV["PORT"]
@ -28,7 +29,8 @@ task :conformance do |t|
options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"] options[:spec_version] = ENV["SPEC_VERSION"] if ENV["SPEC_VERSION"]
options[:verbose] = true if ENV["VERBOSE"] options[:verbose] = true if ENV["VERBOSE"]
Conformance::Runner.new(**options).run Conformance::ServerRunner.new(**options).run
Conformance::ClientRunner.new(**options.except(:port)).run
end end
desc "List available conformance scenarios" desc "List available conformance scenarios"
@ -36,6 +38,7 @@ task :conformance_list do |t|
next unless npx_available?(t.name) next unless npx_available?(t.name)
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server") system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--server")
system("npx", "--yes", "@modelcontextprotocol/conformance", "list", "--client")
end end
desc "Start the conformance server (PORT)" desc "Start the conformance server (PORT)"

View file

@ -15,22 +15,22 @@ Validates the Ruby SDK's conformance to the MCP specification using [`@modelcont
bundle exec rake conformance bundle exec rake conformance
``` ```
Starts the conformance server, runs all active scenarios against it, prints a pass/fail Runs both server and client conformance tests in sequence. Server conformance starts the
summary for each scenario, and exits with a non-zero status code if any unexpected failures conformance server and tests it. Client conformance spawns test servers for each scenario
are detected. Scenarios listed in `expected_failures.yml` are allowed to fail without and invokes `conformance/client.rb` against them. Scenarios listed in `expected_failures.yml`
affecting the exit code. are allowed to fail without affecting the exit code.
### Environment variables ### Environment variables
| Variable | Description | Default | | Variable | Description | Default |
|----------------|--------------------------------------|---------| |----------------|---------------------------------------|---------|
| `PORT` | Server port | `9292` | | `PORT` | Server port (server conformance only) | `9292` |
| `SCENARIO` | Run a single scenario by name | (all) | | `SCENARIO` | Run a single scenario by name | (all) |
| `SPEC_VERSION` | Filter scenarios by spec version | (all) | | `SPEC_VERSION` | Filter scenarios by spec version | (all) |
| `VERBOSE` | Show raw JSON output when set | (off) | | `VERBOSE` | Show raw JSON output when set | (off) |
```bash ```bash
# Run a single scenario # Run a single server scenario
bundle exec rake conformance SCENARIO=ping bundle exec rake conformance SCENARIO=ping
# Use a different port with verbose output # Use a different port with verbose output
@ -91,8 +91,10 @@ submissions.
``` ```
conformance/ conformance/
server.rb # Conformance server (Rack + Puma, default port 9292) server.rb # Conformance server (Rack + Puma, default port 9292)
runner.rb # Starts the server, runs npx conformance, exits with result code server_runner.rb # Starts the server, runs npx conformance server, exits with result code
expected_failures.yml # Baseline of known-failing scenarios client.rb # Conformance client (invoked by npx conformance client)
client_runner.rb # Runs npx conformance client, exits with result code
expected_failures.yml # Baseline of known-failing scenarios (server and client)
README.md # This file README.md # This file
``` ```

34
conformance/client.rb Normal file
View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
# Conformance test client for the MCP Ruby SDK.
# Invoked by the conformance runner:
# MCP_CONFORMANCE_SCENARIO=<scenario> bundle exec ruby conformance/client.rb <server-url>
#
# The server URL is passed as the last positional argument.
# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
# which is set automatically by the conformance test runner.
require_relative "../lib/mcp"
scenario = ENV["MCP_CONFORMANCE_SCENARIO"]
server_url = ARGV.last
unless scenario && server_url
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
end
transport = MCP::Client::HTTP.new(url: server_url)
client = MCP::Client.new(transport: transport)
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
case scenario
when "initialize"
client.tools
when "tools_call"
tools = client.tools
add_numbers = tools.find { |t| t.name == "add_numbers" }
abort("Tool add_numbers not found") unless add_numbers
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
else
abort("Unknown or unsupported scenario: #{scenario}")
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
# Runs `npx @modelcontextprotocol/conformance client` against the conformance client script.
require "English"
module Conformance
class ClientRunner
def initialize(scenario: nil, spec_version: nil, verbose: false)
@scenario = scenario
@spec_version = spec_version
@verbose = verbose
end
def run
command = build_command
puts "Command: #{command.join(" ")}\n\n"
system(*command)
conformance_exit_code = $CHILD_STATUS.exitstatus
exit(conformance_exit_code || 1) unless conformance_exit_code == 0
end
private
def build_command
expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
client_script = File.expand_path("client.rb", __dir__)
npx_command = [
"npx",
"--yes",
"@modelcontextprotocol/conformance",
"client",
"--command",
"bundle exec ruby #{client_script}",
]
npx_command += if @scenario
["--scenario", @scenario]
else
["--suite", "all"]
end
npx_command += ["--spec-version", @spec_version] if @spec_version
npx_command += ["--verbose"] if @verbose
npx_command += ["--expected-failures", expected_failures_yml]
npx_command
end
end
end

View file

@ -1 +1,28 @@
server: [] server: []
client:
# TODO: SSE reconnection not implemented in Ruby client.
- sse-retry
# TODO: Elicitation not implemented in Ruby client.
- elicitation-sep1034-client-defaults
# TODO: OAuth/auth not implemented in Ruby client.
- auth/metadata-default
- auth/metadata-var1
- auth/metadata-var2
- auth/metadata-var3
- auth/basic-cimd
- auth/scope-from-www-authenticate
- auth/scope-from-scopes-supported
- auth/scope-omitted-when-undefined
- auth/scope-step-up
- auth/scope-retry-limit
- auth/token-endpoint-auth-basic
- auth/token-endpoint-auth-post
- auth/token-endpoint-auth-none
- auth/pre-registration
- auth/2025-03-26-oauth-metadata-backcompat
- auth/2025-03-26-oauth-endpoint-fallback
- auth/client-credentials-jwt
- auth/client-credentials-basic
- auth/cross-app-access-complete-flow
- auth/offline-access-scope
- auth/offline-access-not-supported

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
# Starts the conformance server and runs `npx @modelcontextprotocol/conformance` against it.
require "English"
require "net/http"
require_relative "server"
module Conformance
class ServerRunner
# Timeout for waiting for the Puma server to start.
SERVER_START_TIMEOUT = 20
SERVER_POLL_INTERVAL = 0.5
SERVER_HEALTH_CHECK_RETRIES = (SERVER_START_TIMEOUT / SERVER_POLL_INTERVAL).to_i
def initialize(port: Server::DEFAULT_PORT, scenario: nil, spec_version: nil, verbose: false)
@port = port
@scenario = scenario
@spec_version = spec_version
@verbose = verbose
end
def run
command = build_command
server_pid = start_server
run_conformance(command, server_pid: server_pid)
end
private
def build_command
expected_failures_yml = File.expand_path("expected_failures.yml", __dir__)
npx_command = ["npx", "--yes", "@modelcontextprotocol/conformance", "server", "--url", "http://localhost:#{@port}/mcp"]
npx_command += ["--scenario", @scenario] if @scenario
npx_command += ["--spec-version", @spec_version] if @spec_version
npx_command += ["--verbose"] if @verbose
npx_command += ["--expected-failures", expected_failures_yml]
npx_command
end
def start_server
puts "Starting conformance server on port #{@port}..."
server_pid = fork do
Conformance::Server.new(port: @port).start
end
health_url = URI("http://localhost:#{@port}/health")
ready = false
SERVER_HEALTH_CHECK_RETRIES.times do
begin
response = Net::HTTP.get_response(health_url)
if response.code == "200"
ready = true
break
end
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::ReadTimeout
# not ready yet
end
sleep(SERVER_POLL_INTERVAL)
end
unless ready
warn("ERROR: Conformance server did not start within #{SERVER_START_TIMEOUT} seconds")
terminate_server(server_pid)
exit(1)
end
puts "Server ready. Running conformance tests..."
server_pid
end
def run_conformance(command, server_pid:)
puts "Command: #{command.join(" ")}\n\n"
conformance_exit_code = nil
begin
system(*command)
conformance_exit_code = $CHILD_STATUS.exitstatus
ensure
terminate_server(server_pid)
end
exit(conformance_exit_code || 1) unless conformance_exit_code == 0
end
def terminate_server(pid)
Process.kill("TERM", pid)
rescue Errno::ESRCH
# process already exited
ensure
begin
Process.wait(pid)
rescue Errno::ECHILD
# already reaped
end
end
end
end