## Motivation and Context Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic. Server side, `Server#discover` reaches its spec-final shape: - `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: legacy versions are negotiated via `initialize`, not selected from discovery. This also closes an interop hazard of the interim response: auto-negotiating TS and Python clients treat any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`, so advertising only legacy versions made them fail outright. - The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise. - The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002 (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`. Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default: - `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`). It works before `connect`; the stdio transport's connect guard exempts `server/discover` and spawns the subprocess on demand. - `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:` (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover` and falls back to the legacy handshake on any discovery failure, including a successful discovery without a mutually supported modern version (rollout tolerance for servers that answer discovery while only serving legacy versions). Custom transports whose `connect` does not declare `mode:` keep receiving the historical legacy call shape, detected by keyword introspection; a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError` instead of silently downgrading. - The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`, instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version, the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here as `mode: :modern` with `protocol_version: "2026-07-28"`. - An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe, so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle. `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools and when using server-initiated requests, which exist only on the legacy lifecycle. - Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference: `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one), `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta` `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover. - The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK) when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback instead of blocking `connect` forever. Regular requests keep the unbounded default. - In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize` is never sent. - The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake and its scenario servers do not expect a preceding probe. Resolves #389. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values); the existing discover assertions across `server_test.rb` and both transport test files were updated to the modern-only `supportedVersions`. New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError` on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`, the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`, `ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection, and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence. New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks, and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`, modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout default applying only while probing, and the fallback when a server never answers the probe. ## Breaking Changes `server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`); this method has not shipped in a gem release yet, so no released behavior changes. On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation `protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
2100 lines
66 KiB
Ruby
2100 lines
66 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "event_stream_parser"
|
|
require "faraday"
|
|
require "webmock/minitest"
|
|
require "mcp/client/http"
|
|
require "mcp/client/tool"
|
|
require "mcp/client"
|
|
|
|
module MCP
|
|
class Client
|
|
class HTTPTest < Minitest::Test
|
|
include DeprecationWarningTestHelper
|
|
|
|
def test_raises_load_error_when_faraday_not_available
|
|
client = HTTP.new(url: url)
|
|
|
|
# simulate Faraday not being available
|
|
HTTP.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday")
|
|
|
|
error = assert_raises(LoadError) do
|
|
# This should immediately try to instantiate the client and fail
|
|
client.send_request(request: {})
|
|
end
|
|
|
|
assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport")
|
|
assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'")
|
|
end
|
|
|
|
def test_raises_load_error_when_event_stream_parser_not_available
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "data: {}\n\n",
|
|
)
|
|
|
|
HTTP.any_instance.stubs(:require).with("faraday").returns(true)
|
|
HTTP.const_get(:SSEStream).any_instance.stubs(:require).with("event_stream_parser")
|
|
.raises(LoadError, "cannot load such file -- event_stream_parser")
|
|
|
|
error = assert_raises(LoadError) do
|
|
client.send_request(request: { method: "tools/list" })
|
|
end
|
|
|
|
assert_includes(error.message, "The 'event_stream_parser' gem is required to parse SSE responses")
|
|
assert_includes(error.message, "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'")
|
|
end
|
|
|
|
def test_headers_are_added_to_the_request
|
|
headers = { "Authorization" => "Bearer token" }
|
|
client = HTTP.new(url: url, headers: headers)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(
|
|
headers: {
|
|
"Authorization" => "Bearer token",
|
|
"Content-Type" => "application/json",
|
|
"Accept" => "application/json, text/event-stream",
|
|
},
|
|
body: request.to_json,
|
|
)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
# The test passes if the request is made with the correct headers
|
|
# If headers are wrong, the stub_request won't match and will raise
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_accept_header_is_included_in_requests
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(
|
|
headers: {
|
|
"Accept" => "application/json, text/event-stream",
|
|
},
|
|
)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_custom_accept_header_overrides_default
|
|
custom_accept = "application/json"
|
|
custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept })
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(
|
|
headers: {
|
|
"Accept" => custom_accept,
|
|
},
|
|
)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
custom_client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_and_name_headers_for_tools_call
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "get_weather", arguments: { city: "Tokyo" } },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_falls_back_to_uri_for_resources_read
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "resources/read",
|
|
params: { uri: "file:///README.md" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "resources/read", "Mcp-Name" => "file:///README.md" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_and_name_headers_for_prompts_get
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "prompts/get",
|
|
params: { name: "greeting" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "prompts/get", "Mcp-Name" => "greeting" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_header_without_name_when_params_lack_name_and_uri
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/list" },
|
|
) do |req|
|
|
!req.headers.key?("Mcp-Name")
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_is_base64_encoded_when_unsafe
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "café" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?Y2Fmw6k=?=" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_header_for_notification_without_id
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
method: "notifications/initialized",
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "notifications/initialized" },
|
|
) do |req|
|
|
!req.headers.key?("Mcp-Name")
|
|
end.to_return(
|
|
status: 202,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: "",
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_header_for_initialize_without_params
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "initialize",
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "initialize" },
|
|
) do |req|
|
|
!req.headers.key?("Mcp-Name")
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_with_string_keyed_params
|
|
request = {
|
|
"jsonrpc" => "2.0",
|
|
"id" => "test_id",
|
|
"method" => "tools/call",
|
|
"params" => { "name" => "get_weather" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_is_base64_encoded_when_value_has_surrounding_whitespace
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: " padded " },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?IHBhZGRlZCA=?=" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_is_base64_encoded_when_value_has_crlf
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "evil\r\nX-Injected: 1" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?ZXZpbA0KWC1JbmplY3RlZDogMQ==?=" },
|
|
) do |req|
|
|
!req.headers.key?("X-Injected")
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_name_header_re_encodes_value_matching_base64_sentinel
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "=?base64?literal?=" },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_mcp_method_header_without_name_for_non_hash_params
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "custom/method",
|
|
params: ["positional"],
|
|
}
|
|
|
|
stub_request(:post, url).with(headers: { "Mcp-Method" => "custom/method" }) do |req|
|
|
!req.headers.key?("Mcp-Name")
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: {} }.to_json,
|
|
)
|
|
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
def test_send_request_returns_faraday_response
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
response = client.send_request(request: request)
|
|
assert_instance_of(Hash, response)
|
|
assert_equal({ "result" => { "tools" => [] } }, response)
|
|
end
|
|
|
|
def test_send_request_raises_bad_request_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 400)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("The tools/list request is invalid", error.message)
|
|
assert_equal(:bad_request, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_raises_unauthorized_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 401)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("You are unauthorized to make tools/list requests", error.message)
|
|
assert_equal(:unauthorized, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_raises_forbidden_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 403)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("You are forbidden to make tools/list requests", error.message)
|
|
assert_equal(:forbidden, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_raises_not_found_error_on_404_without_session
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 404)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
refute_kind_of(SessionExpiredError, error)
|
|
assert_equal("The tools/list request is not found", error.message)
|
|
assert_equal(:not_found, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_raises_session_expired_error_on_404_with_session
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
stub_request(:post, url).to_return(status: 404)
|
|
|
|
error = assert_raises(SessionExpiredError) do
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
end
|
|
|
|
assert_equal(:not_found, error.error_type)
|
|
end
|
|
|
|
def test_session_expired_error_is_a_request_handler_error
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
stub_request(:post, url).to_return(status: 404)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
end
|
|
|
|
assert_kind_of(SessionExpiredError, error)
|
|
end
|
|
|
|
def test_send_request_raises_unprocessable_entity_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 422)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("The tools/list request is unprocessable", error.message)
|
|
assert_equal(:unprocessable_entity, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_raises_internal_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 500)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("Internal error handling tools/list request", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_block_customizes_faraday_connection
|
|
custom_client = HTTP.new(url: url) do |faraday|
|
|
faraday.headers["X-Custom"] = "test-value"
|
|
end
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
headers: {
|
|
"X-Custom" => "test-value",
|
|
"Accept" => "application/json, text/event-stream",
|
|
},
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
custom_client.send_request(request: request)
|
|
end
|
|
|
|
def test_send_request_raises_error_for_unsupported_content_type
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/html" },
|
|
body: "<html></html>",
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_equal(
|
|
'Unsupported Content-Type: "text/html". Expected application/json or text/event-stream.',
|
|
error.message,
|
|
)
|
|
assert_equal(:unsupported_media_type, error.error_type)
|
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
|
end
|
|
|
|
def test_send_request_parses_sse_response
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
sse_body = <<~SSE
|
|
: comment
|
|
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}}
|
|
|
|
data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[{"name":"echo"}]}}
|
|
|
|
SSE
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "tools" => [{ "name" => "echo" }] }, response["result"])
|
|
end
|
|
|
|
def test_send_request_parses_sse_error_response
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
sse_body = <<~SSE
|
|
data: {"jsonrpc":"2.0","id":"test_id","error":{"code":-32600,"message":"Invalid request"}}
|
|
|
|
SSE
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal(-32600, response.dig("error", "code"))
|
|
assert_equal("Invalid request", response.dig("error", "message"))
|
|
end
|
|
|
|
def test_send_request_returns_nil_for_202_accepted_response
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
method: "notifications/initialized",
|
|
}
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(status: 202, body: "")
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_nil(response)
|
|
end
|
|
|
|
def test_send_request_raises_error_for_sse_without_response
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
sse_body = <<~SSE
|
|
: just a comment
|
|
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}}
|
|
|
|
SSE
|
|
|
|
stub_request(:post, url)
|
|
.with(body: request.to_json)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_includes(error.message, "No valid JSON-RPC response found in SSE stream")
|
|
assert_equal(:parse_error, error.error_type)
|
|
assert_not_requested(:get, url)
|
|
end
|
|
|
|
def test_send_request_reconnects_with_last_event_id_after_primed_graceful_close
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "test_reconnection", arguments: {} },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "id: event-1\nretry: 100\ndata:\n\n",
|
|
)
|
|
|
|
get_body = "id: event-2\nretry: 100\ndata:\n\n" \
|
|
"event: message\nid: event-3\n" \
|
|
'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \
|
|
"\n\n"
|
|
get_stub = stub_request(:get, url).with(
|
|
headers: { "Accept" => "text/event-stream", "Last-Event-ID" => "event-1" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: get_body,
|
|
)
|
|
|
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
response = client.send_request(request: request)
|
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(get_stub)
|
|
|
|
# The server-specified `retry:` interval must elapse before the reconnection GET.
|
|
assert_operator(elapsed, :>=, 0.1)
|
|
end
|
|
|
|
def test_send_request_uses_default_reconnection_delay_when_retry_field_absent
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "test_reconnection", arguments: {} },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "id: event-1\ndata:\n\n",
|
|
)
|
|
|
|
get_body = 'data: {"jsonrpc":"2.0","id":"test_id","result":{"content":[]}}' \
|
|
"\n\n"
|
|
stub_request(:get, url).with(
|
|
headers: { "Last-Event-ID" => "event-1" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: get_body,
|
|
)
|
|
|
|
client.expects(:sleep).with(HTTP::DEFAULT_RECONNECTION_DELAY_MS / 1000.0)
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
end
|
|
|
|
def test_send_request_raises_after_reconnection_attempts_are_exhausted
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "test_reconnection", arguments: {} },
|
|
}
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "id: event-1\nretry: 10\ndata:\n\n",
|
|
)
|
|
|
|
first_get = stub_request(:get, url).with(
|
|
headers: { "Last-Event-ID" => "event-1" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "id: event-2\nretry: 10\ndata:\n\n",
|
|
)
|
|
second_get = stub_request(:get, url).with(
|
|
headers: { "Last-Event-ID" => "event-2" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "id: event-3\nretry: 10\ndata:\n\n",
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_includes(error.message, "after 2 reconnection attempts")
|
|
assert_equal(:internal_error, error.error_type)
|
|
assert_requested(first_get)
|
|
assert_requested(second_get)
|
|
end
|
|
|
|
def test_send_request_parses_json_response_when_adapter_does_not_stream
|
|
# The Faraday test adapter ignores `on_data`, like adapters without
|
|
# streaming support; the body must be read from `response.body`.
|
|
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
|
stub.post("/") do
|
|
[200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json]
|
|
end
|
|
end
|
|
client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
|
|
|
response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" })
|
|
|
|
assert_equal({ "result" => { "tools" => [] } }, response)
|
|
end
|
|
|
|
def test_send_request_parses_sse_response_when_adapter_does_not_stream
|
|
sse_body = "event: message\n" \
|
|
'data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}}' \
|
|
"\n\n"
|
|
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
|
stub.post("/") do
|
|
[200, { "Content-Type" => "text/event-stream" }, sse_body]
|
|
end
|
|
end
|
|
client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
|
|
|
response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" })
|
|
|
|
assert_equal({ "tools" => [] }, response["result"])
|
|
end
|
|
|
|
def test_sse_stream_parses_buffered_chunks_when_env_is_unavailable
|
|
# Faraday < 2.1 invokes `on_data` without `env`; the content type
|
|
# cannot be detected, so SSE chunks accumulate in the buffer and are
|
|
# parsed by the `ingest_pending!` fallback.
|
|
stream = HTTP.const_get(:SSEStream).new(abortable: true)
|
|
chunk = "data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{}}\n\n"
|
|
|
|
stream.on_data.call(chunk, chunk.bytesize)
|
|
|
|
assert_nil(stream.response)
|
|
assert_equal(chunk, stream.buffer)
|
|
|
|
stream.ingest_pending!(nil)
|
|
|
|
assert_equal({ "jsonrpc" => "2.0", "id" => "test_id", "result" => {} }, stream.response)
|
|
assert_empty(stream.buffer)
|
|
end
|
|
|
|
def test_send_request_rejects_sse_event_exceeding_max_message_bytes
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
client = HTTP.new(url: url, max_message_bytes: 64)
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "data: #{"a" * 128}\n",
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_includes(error.message, "Server SSE event exceeds 64 bytes")
|
|
assert_equal(:internal_error, error.error_type)
|
|
end
|
|
|
|
def test_send_request_rejects_json_body_exceeding_max_message_bytes
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
client = HTTP.new(url: url, max_message_bytes: 64)
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: ["a" * 128] } }.to_json,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_request(request: request)
|
|
end
|
|
|
|
assert_includes(error.message, "Server response body exceeds 64 bytes")
|
|
assert_equal(:internal_error, error.error_type)
|
|
end
|
|
|
|
def test_send_request_accepts_sse_events_that_each_stay_within_max_message_bytes
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/list",
|
|
}
|
|
client = HTTP.new(url: url, max_message_bytes: 256)
|
|
|
|
# The notifications and the response together exceed the limit; each event stays
|
|
# under it, so dispatched events must not count against the ones that follow.
|
|
notification = { jsonrpc: "2.0", method: "notifications/progress", params: { token: "a" * 100 } }.to_json
|
|
sse_body = "data: #{notification}\n\n" * 3 +
|
|
"data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{\"tools\":[]}}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "tools" => [] }, response["result"])
|
|
end
|
|
|
|
def test_sse_stream_rejects_unterminated_event_exceeding_max_message_bytes
|
|
stream = HTTP.const_get(:SSEStream).new(abortable: false, max_message_bytes: 64)
|
|
env = stub(response_headers: { "content-type" => "text/event-stream" })
|
|
chunk = "data: #{"a" * 128}"
|
|
|
|
error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do
|
|
stream.on_data.call(chunk, chunk.bytesize, env)
|
|
end
|
|
|
|
assert_includes(error.message, "Server SSE event exceeds 64 bytes")
|
|
end
|
|
|
|
def test_sse_stream_bounds_buffered_chunks_when_env_is_unavailable
|
|
stream = HTTP.const_get(:SSEStream).new(abortable: true, max_message_bytes: 64)
|
|
|
|
error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do
|
|
stream.on_data.call("a" * 65, 65)
|
|
end
|
|
|
|
assert_includes(error.message, "Server response body exceeds 64 bytes")
|
|
end
|
|
|
|
def test_initialize_rejects_max_message_bytes_that_is_not_a_positive_integer
|
|
[0, -1, nil, "4"].each do |value|
|
|
error = assert_raises(ArgumentError) do
|
|
HTTP.new(url: url, max_message_bytes: value)
|
|
end
|
|
|
|
assert_equal("max_message_bytes must be a positive Integer", error.message)
|
|
end
|
|
end
|
|
|
|
def test_send_request_dispatches_server_request_to_registered_handler
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "test_client_elicitation_defaults", arguments: {} },
|
|
}
|
|
|
|
elicitation_request = {
|
|
jsonrpc: "2.0",
|
|
id: 0,
|
|
method: "elicitation/create",
|
|
params: {
|
|
message: "Please accept with defaults",
|
|
requestedSchema: {
|
|
type: "object",
|
|
properties: { name: { type: "string", default: "John Doe" } },
|
|
required: [],
|
|
},
|
|
},
|
|
}
|
|
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
|
sse_body = "event: message\ndata: #{elicitation_request.to_json}\n\n" \
|
|
"event: message\ndata: #{tool_result.to_json}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
expected_response = {
|
|
jsonrpc: "2.0",
|
|
id: 0,
|
|
result: { action: "accept", content: { name: "John Doe" } },
|
|
}
|
|
response_stub = stub_request(:post, url).with(
|
|
body: expected_response.to_json,
|
|
).to_return(
|
|
status: 202, body: "",
|
|
)
|
|
|
|
received_params = nil
|
|
client.on_server_request("elicitation/create") do |params|
|
|
received_params = params
|
|
{
|
|
action: "accept",
|
|
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
|
|
}
|
|
end
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(response_stub)
|
|
assert_equal("Please accept with defaults", received_params["message"])
|
|
end
|
|
|
|
def test_send_request_dispatches_sampling_request_to_registered_handler
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "ask_llm", arguments: {} },
|
|
}
|
|
|
|
sampling_request = {
|
|
jsonrpc: "2.0",
|
|
id: 0,
|
|
method: "sampling/createMessage",
|
|
params: {
|
|
messages: [{ role: "user", content: { type: "text", text: "Hi" } }],
|
|
maxTokens: 100,
|
|
},
|
|
}
|
|
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
|
sse_body = "event: message\ndata: #{sampling_request.to_json}\n\n" \
|
|
"event: message\ndata: #{tool_result.to_json}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
expected_response = {
|
|
jsonrpc: "2.0",
|
|
id: 0,
|
|
result: {
|
|
role: "assistant",
|
|
content: { type: "text", text: "Hello there" },
|
|
model: "test-model",
|
|
stopReason: "endTurn",
|
|
},
|
|
}
|
|
response_stub = stub_request(:post, url).with(
|
|
body: expected_response.to_json,
|
|
).to_return(status: 202, body: "")
|
|
|
|
received_params = nil
|
|
client.on_server_request("sampling/createMessage") do |params|
|
|
received_params = params
|
|
{
|
|
role: "assistant",
|
|
content: { type: "text", text: "Hello there" },
|
|
model: "test-model",
|
|
stopReason: "endTurn",
|
|
}
|
|
end
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(response_stub)
|
|
assert_equal(100, received_params["maxTokens"])
|
|
end
|
|
|
|
def test_send_request_answers_unregistered_server_request_with_method_not_found
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "some_tool", arguments: {} },
|
|
}
|
|
|
|
server_request = {
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "sampling/createMessage",
|
|
params: {},
|
|
}
|
|
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
|
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
|
"event: message\ndata: #{tool_result.to_json}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
expected_error_response = {
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
error: { code: -32601, message: "Method not found: sampling/createMessage" },
|
|
}
|
|
error_stub = stub_request(:post, url).with(
|
|
body: expected_error_response.to_json,
|
|
).to_return(
|
|
status: 202, body: "",
|
|
)
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(error_stub)
|
|
end
|
|
|
|
def test_send_request_answers_raising_handler_with_internal_error
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "some_tool", arguments: {} },
|
|
}
|
|
|
|
server_request = {
|
|
jsonrpc: "2.0",
|
|
id: 9,
|
|
method: "elicitation/create",
|
|
params: {},
|
|
}
|
|
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
|
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
|
"event: message\ndata: #{tool_result.to_json}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
expected_error_response = {
|
|
jsonrpc: "2.0",
|
|
id: 9,
|
|
error: { code: -32603, message: "Internal error handling elicitation/create request: boom" },
|
|
}
|
|
error_stub = stub_request(:post, url).with(
|
|
body: expected_error_response.to_json,
|
|
).to_return(
|
|
status: 202, body: "",
|
|
)
|
|
|
|
client.on_server_request("elicitation/create") { raise "boom" }
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(error_stub)
|
|
end
|
|
|
|
def test_send_request_answers_server_request_error_with_its_code
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test_id",
|
|
method: "tools/call",
|
|
params: { name: "ask_llm", arguments: {} },
|
|
}
|
|
|
|
server_request = {
|
|
jsonrpc: "2.0",
|
|
id: 11,
|
|
method: "sampling/createMessage",
|
|
params: {},
|
|
}
|
|
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
|
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
|
"event: message\ndata: #{tool_result.to_json}\n\n"
|
|
|
|
stub_request(:post, url).with(
|
|
body: request.to_json,
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: sse_body,
|
|
)
|
|
|
|
expected_error_response = {
|
|
jsonrpc: "2.0",
|
|
id: 11,
|
|
error: { code: -1, message: "User rejected sampling request" },
|
|
}
|
|
error_stub = stub_request(:post, url).with(
|
|
body: expected_error_response.to_json,
|
|
).to_return(
|
|
status: 202, body: "",
|
|
)
|
|
|
|
client.on_server_request("sampling/createMessage") do
|
|
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1)
|
|
end
|
|
|
|
response = client.send_request(request: request)
|
|
|
|
assert_equal({ "content" => [] }, response["result"])
|
|
assert_requested(error_stub)
|
|
end
|
|
|
|
def test_on_server_request_requires_a_block
|
|
assert_raises(ArgumentError) do
|
|
client.on_server_request("elicitation/create")
|
|
end
|
|
end
|
|
|
|
def test_registering_a_handler_after_connect_listens_on_standalone_get_stream
|
|
stub_initialize
|
|
stub_notification
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
|
|
elicitation_request = {
|
|
jsonrpc: "2.0",
|
|
id: 7,
|
|
method: "elicitation/create",
|
|
params: {
|
|
message: "Please accept with defaults",
|
|
requestedSchema: {
|
|
type: "object",
|
|
properties: { name: { type: "string", default: "John Doe" } },
|
|
required: [],
|
|
},
|
|
},
|
|
}
|
|
stub_request(:get, url)
|
|
.with(
|
|
headers: { "Accept" => "text/event-stream", "Mcp-Session-Id" => "session-abc" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "event: message\ndata: #{elicitation_request.to_json}\n\n",
|
|
)
|
|
|
|
expected_response = {
|
|
jsonrpc: "2.0",
|
|
id: 7,
|
|
result: { action: "accept", content: { name: "John Doe" } },
|
|
}
|
|
response_stub = stub_request(:post, url).with(
|
|
body: expected_response.to_json,
|
|
).to_return(
|
|
status: 202, body: "",
|
|
)
|
|
|
|
client.connect
|
|
client.on_server_request("elicitation/create") do |params|
|
|
{
|
|
action: "accept",
|
|
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
|
|
}
|
|
end
|
|
|
|
wait_until { requested?(response_stub) }
|
|
|
|
assert_requested(response_stub)
|
|
ensure
|
|
client.close
|
|
end
|
|
|
|
def test_connect_listens_on_standalone_get_stream_when_a_handler_is_registered
|
|
stub_initialize
|
|
stub_notification
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
|
|
get_stub = stub_request(:get, url).with(
|
|
headers: { "Accept" => "text/event-stream" },
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "text/event-stream" },
|
|
body: "",
|
|
)
|
|
|
|
client.on_server_request("elicitation/create") { { action: "decline" } }
|
|
|
|
assert_not_requested(:get, url)
|
|
|
|
client.connect
|
|
|
|
wait_until { requested?(get_stub) }
|
|
|
|
assert_requested(get_stub)
|
|
ensure
|
|
client.close
|
|
end
|
|
|
|
def test_listener_stops_after_consecutive_connection_failures
|
|
stub_initialize
|
|
stub_notification
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
stub_request(:get, url).to_return(status: 500)
|
|
|
|
client.stubs(:sleep)
|
|
client.connect
|
|
client.on_server_request("elicitation/create") { { action: "decline" } }
|
|
listener = client.instance_variable_get(:@listener_thread)
|
|
|
|
wait_until { !listener.alive? }
|
|
|
|
assert_requested(:get, url, times: HTTP::MAX_RECONNECTION_ATTEMPTS)
|
|
ensure
|
|
client.close
|
|
end
|
|
|
|
def test_listener_failure_count_resets_after_a_successful_stream
|
|
stub_initialize
|
|
stub_notification
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
stub_request(:get, url).to_return(
|
|
{ status: 500 },
|
|
{ status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "" },
|
|
{ status: 500 },
|
|
{ status: 500 },
|
|
)
|
|
|
|
client.stubs(:sleep)
|
|
client.connect
|
|
client.on_server_request("elicitation/create") { { action: "decline" } }
|
|
listener = client.instance_variable_get(:@listener_thread)
|
|
|
|
wait_until { !listener.alive? }
|
|
|
|
# Without the reset, the second failure (the third request) would
|
|
# already reach the cap and the fourth request would never be made.
|
|
assert_requested(:get, url, times: 4)
|
|
ensure
|
|
client.close
|
|
end
|
|
|
|
def test_listener_stops_immediately_when_server_does_not_offer_a_get_stream
|
|
stub_initialize
|
|
stub_notification
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
stub_request(:get, url).to_return(status: 405)
|
|
|
|
client.stubs(:sleep)
|
|
client.connect
|
|
client.on_server_request("elicitation/create") { { action: "decline" } }
|
|
listener = client.instance_variable_get(:@listener_thread)
|
|
|
|
wait_until { !listener.alive? }
|
|
|
|
assert_requested(:get, url, times: 1)
|
|
ensure
|
|
client.close
|
|
end
|
|
|
|
def test_captures_session_id_and_protocol_version_on_initialize
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
assert_equal("session-abc", client.session_id)
|
|
assert_equal("2025-11-25", client.protocol_version)
|
|
end
|
|
|
|
def test_includes_session_and_protocol_version_headers_after_initialize
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
stub_request(:post, url)
|
|
.with(
|
|
headers: {
|
|
"Mcp-Session-Id" => "session-abc",
|
|
"MCP-Protocol-Version" => "2025-11-25",
|
|
},
|
|
)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
end
|
|
|
|
def test_does_not_send_protocol_version_header_before_initialize
|
|
stub_request(:post, url)
|
|
.with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
end
|
|
|
|
def test_ignores_empty_session_id_header
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_session_id_not_overwritten_by_subsequent_responses
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "original-session",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
assert_equal("original-session", client.session_id)
|
|
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "different-session",
|
|
},
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
|
|
assert_equal("original-session", client.session_id)
|
|
end
|
|
|
|
def test_stateless_server_without_session_id_header
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
assert_nil(client.session_id)
|
|
assert_equal("2025-11-25", client.protocol_version)
|
|
end
|
|
|
|
def test_clears_session_state_on_404
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
|
|
assert_equal("session-abc", client.session_id)
|
|
|
|
stub_request(:post, url).to_return(status: 404)
|
|
|
|
assert_raises(SessionExpiredError) do
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
end
|
|
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
end
|
|
|
|
def test_close_sends_delete_with_session_headers
|
|
initialize_session
|
|
|
|
stub_request(:delete, url)
|
|
.with(
|
|
headers: {
|
|
"Mcp-Session-Id" => "session-abc",
|
|
"MCP-Protocol-Version" => "2025-11-25",
|
|
},
|
|
)
|
|
.to_return(status: 200)
|
|
|
|
client.close
|
|
end
|
|
|
|
def test_close_clears_session_state
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
|
|
client.close
|
|
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
end
|
|
|
|
def test_close_without_session_is_noop
|
|
client.close
|
|
|
|
assert_not_requested(:delete, url)
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_close_clears_stateless_connection_state
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
stub_notification
|
|
|
|
client.connect
|
|
client.close
|
|
|
|
assert_not_requested(:delete, url)
|
|
refute_predicate(client, :connected?)
|
|
assert_nil(client.protocol_version)
|
|
assert_nil(client.server_info)
|
|
end
|
|
|
|
def test_close_tolerates_405_response
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 405)
|
|
|
|
client.close
|
|
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_close_tolerates_404_response
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 404)
|
|
|
|
client.close
|
|
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_close_propagates_server_error_and_still_clears_state
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 500)
|
|
|
|
assert_raises(Faraday::ServerError) do
|
|
client.close
|
|
end
|
|
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
end
|
|
|
|
def test_close_propagates_unauthorized_and_still_clears_state
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 401)
|
|
|
|
assert_raises(Faraday::UnauthorizedError) do
|
|
client.close
|
|
end
|
|
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_close_propagates_connection_failure_and_still_clears_state
|
|
initialize_session
|
|
stub_request(:delete, url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
|
|
|
assert_raises(Faraday::ConnectionFailed) do
|
|
client.close
|
|
end
|
|
|
|
assert_nil(client.session_id)
|
|
end
|
|
|
|
def test_close_is_idempotent
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
|
|
client.close
|
|
client.close
|
|
|
|
assert_requested(:delete, url, times: 1)
|
|
end
|
|
|
|
def test_connect_performs_initialize_handshake
|
|
init_stub = stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" },
|
|
body: {
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "test-server", version: "1.0" },
|
|
},
|
|
}.to_json,
|
|
)
|
|
|
|
notification_stub = stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
|
|
.to_return(status: 202, body: "")
|
|
|
|
result = client.connect
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
assert_equal({ "tools" => {} }, result["capabilities"])
|
|
assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"])
|
|
end
|
|
|
|
def test_connect_caches_server_info
|
|
stub_initialize
|
|
stub_notification
|
|
|
|
client.connect
|
|
|
|
assert_equal("2025-11-25", client.server_info["protocolVersion"])
|
|
end
|
|
|
|
def test_connect_uses_default_client_info_and_protocol_version
|
|
notification_stub = stub_notification
|
|
|
|
init_stub = stub_request(:post, url)
|
|
.with do |req|
|
|
body = JSON.parse(req.body)
|
|
body["method"] == "initialize" &&
|
|
body["params"]["protocolVersion"] == MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION &&
|
|
body["params"]["clientInfo"] == { "name" => "mcp-ruby-client", "version" => MCP::VERSION } &&
|
|
body["params"]["capabilities"] == {}
|
|
end
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION } }.to_json,
|
|
)
|
|
|
|
client.connect
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
end
|
|
|
|
def test_connect_accepts_custom_parameters
|
|
notification_stub = stub_notification
|
|
|
|
init_stub = stub_request(:post, url)
|
|
.with do |req|
|
|
body = JSON.parse(req.body)
|
|
body["method"] == "initialize" &&
|
|
body["params"]["protocolVersion"] == "2025-03-26" &&
|
|
body["params"]["clientInfo"] == { "name" => "my-app", "version" => "9.9" } &&
|
|
body["params"]["capabilities"] == { "roots" => { "listChanged" => true } }
|
|
end
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2025-03-26" } }.to_json,
|
|
)
|
|
|
|
client.connect(
|
|
client_info: { name: "my-app", version: "9.9" },
|
|
protocol_version: "2025-03-26",
|
|
capabilities: { roots: { listChanged: true } },
|
|
)
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
end
|
|
|
|
def test_connect_warns_for_deprecated_capabilities_when_negotiated_protocol_version_is_2026_07_28
|
|
notification_stub = stub_notification
|
|
|
|
init_stub = stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2026-07-28" } }.to_json,
|
|
)
|
|
|
|
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
|
client.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
|
end
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
end
|
|
|
|
def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_protocol_version_is_older
|
|
notification_stub = stub_notification
|
|
|
|
init_stub = stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
assert_no_deprecation_warning do
|
|
client.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
|
end
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
end
|
|
|
|
def test_connect_is_idempotent
|
|
init_stub = stub_initialize
|
|
notification_stub = stub_notification
|
|
|
|
first_result = client.connect
|
|
second_result = client.connect
|
|
|
|
assert_same(first_result, second_result)
|
|
assert_requested(init_stub, times: 1)
|
|
assert_requested(notification_stub, times: 1)
|
|
end
|
|
|
|
def test_connect_raises_on_jsonrpc_error_response
|
|
stub_request(:post, url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
|
|
body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.connect
|
|
end
|
|
|
|
assert_includes(error.message, "Unsupported protocol version")
|
|
refute_predicate(client, :connected?)
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
assert_nil(client.server_info)
|
|
end
|
|
|
|
def test_connect_raises_on_missing_result
|
|
stub_request(:post, url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
|
|
body: { jsonrpc: "2.0", id: "x" }.to_json,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.connect
|
|
end
|
|
|
|
assert_includes(error.message, "missing result in response")
|
|
refute_predicate(client, :connected?)
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
assert_nil(client.server_info)
|
|
end
|
|
|
|
def test_connect_raises_on_unsupported_negotiated_protocol_version
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
|
|
body: { result: { protocolVersion: "2099-01-01" } }.to_json,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.connect
|
|
end
|
|
|
|
assert_includes(error.message, 'unsupported protocol version "2099-01-01"')
|
|
refute_predicate(client, :connected?)
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
assert_nil(client.server_info)
|
|
end
|
|
|
|
def test_connect_clears_session_when_initialized_notification_fails
|
|
stub_initialize
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
|
|
.to_return(status: 500)
|
|
|
|
assert_raises(RequestHandlerError) do
|
|
client.connect
|
|
end
|
|
|
|
refute_predicate(client, :connected?)
|
|
assert_nil(client.session_id)
|
|
assert_nil(client.protocol_version)
|
|
assert_nil(client.server_info)
|
|
end
|
|
|
|
def test_connected_lifecycle
|
|
refute_predicate(client, :connected?)
|
|
|
|
stub_initialize
|
|
stub_notification
|
|
client.connect
|
|
|
|
assert_predicate(client, :connected?)
|
|
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
client.close
|
|
|
|
refute_predicate(client, :connected?)
|
|
end
|
|
|
|
def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle
|
|
discover_stub = stub_discover
|
|
|
|
result = client.connect(mode: :modern)
|
|
|
|
assert_requested(discover_stub)
|
|
assert_predicate(client, :connected?)
|
|
assert_predicate(client, :modern?)
|
|
assert_equal(["2026-07-28"], result["supportedVersions"])
|
|
assert_equal(result, client.server_info)
|
|
assert_equal("2026-07-28", client.protocol_version)
|
|
end
|
|
|
|
def test_modern_requests_carry_the_envelope_and_matching_header
|
|
stub_discover
|
|
client.connect(
|
|
mode: :modern,
|
|
client_info: { name: "my-app", version: "9.9" },
|
|
capabilities: { elicitation: {} },
|
|
)
|
|
|
|
request_stub = stub_request(:post, url).with do |req|
|
|
body = JSON.parse(req.body)
|
|
meta = body.dig("params", "_meta")
|
|
body["method"] == "tools/list" &&
|
|
req.headers["Mcp-Protocol-Version"] == "2026-07-28" &&
|
|
meta == {
|
|
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" },
|
|
"io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} },
|
|
}
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { tools: [] } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
|
|
|
|
assert_requested(request_stub)
|
|
end
|
|
|
|
def test_connect_modern_fails_without_a_mutual_modern_version
|
|
stub_request(:post, url).with do |req|
|
|
JSON.parse(req.body)["method"] == "server/discover"
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
|
|
)
|
|
|
|
error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) }
|
|
|
|
assert_includes(error.message, "no mutually supported modern protocol version")
|
|
refute_predicate(client, :connected?)
|
|
refute_predicate(client, :modern?)
|
|
end
|
|
|
|
def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors
|
|
stub_request(:post, url).with do |req|
|
|
JSON.parse(req.body)["method"] == "server/discover"
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { error: { code: -32601, message: "Method not found" } }.to_json,
|
|
)
|
|
init_stub = stub_initialize
|
|
notification_stub = stub_notification
|
|
|
|
result = client.connect(mode: :auto)
|
|
|
|
assert_requested(init_stub)
|
|
assert_requested(notification_stub)
|
|
refute_predicate(client, :modern?)
|
|
assert_predicate(client, :connected?)
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
end
|
|
|
|
def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version
|
|
# Rollout tolerance: a server may answer discovery while only serving legacy versions;
|
|
# auto mode falls back to the legacy handshake.
|
|
stub_request(:post, url).with do |req|
|
|
JSON.parse(req.body)["method"] == "server/discover"
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
|
|
)
|
|
init_stub = stub_initialize
|
|
stub_notification
|
|
|
|
client.connect(mode: :auto)
|
|
|
|
assert_requested(init_stub)
|
|
refute_predicate(client, :modern?)
|
|
assert_predicate(client, :connected?)
|
|
end
|
|
|
|
def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode
|
|
assert_raises(ArgumentError) { client.connect(mode: :bogus) }
|
|
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
|
|
end
|
|
|
|
def test_reconnect_after_close
|
|
stub_initialize
|
|
stub_notification
|
|
client.connect
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
client.close
|
|
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.connect
|
|
|
|
assert_predicate(client, :connected?)
|
|
assert_equal("s2", client.session_id)
|
|
end
|
|
|
|
def test_close_allows_reinitializing_a_fresh_session
|
|
initialize_session
|
|
stub_request(:delete, url).to_return(status: 200)
|
|
client.close
|
|
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-xyz",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" })
|
|
|
|
assert_equal("session-xyz", client.session_id)
|
|
assert_equal("2025-11-25", client.protocol_version)
|
|
end
|
|
|
|
def test_send_notification_posts_body_and_accepts_202
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
|
params: { requestId: "req-1", reason: "user cancel" },
|
|
}
|
|
|
|
stub_request(:post, url).with(body: notification.to_json).to_return(status: 202, body: "")
|
|
|
|
result = client.send_notification(notification: notification)
|
|
|
|
assert_nil(result, "send_notification must return nil")
|
|
end
|
|
|
|
def test_send_notification_surfaces_faraday_errors
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
|
params: { requestId: "req-1" },
|
|
}
|
|
|
|
stub_request(:post, url).to_return(status: 500)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
client.send_notification(notification: notification)
|
|
end
|
|
|
|
assert_equal(:internal_error, error.error_type)
|
|
assert_match(%r{notifications/cancelled}, error.message)
|
|
end
|
|
|
|
private
|
|
|
|
def initialize_session
|
|
stub_request(:post, url)
|
|
.to_return(
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type" => "application/json",
|
|
"Mcp-Session-Id" => "session-abc",
|
|
},
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
|
end
|
|
|
|
def stub_initialize
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "initialize" }
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" },
|
|
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
|
)
|
|
end
|
|
|
|
def stub_notification
|
|
stub_request(:post, url)
|
|
.with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" }
|
|
.to_return(status: 202, body: "")
|
|
end
|
|
|
|
def stub_discover
|
|
stub_request(:post, url).with do |req|
|
|
JSON.parse(req.body)["method"] == "server/discover"
|
|
end.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: {
|
|
result: {
|
|
supportedVersions: ["2026-07-28"],
|
|
capabilities: { tools: {} },
|
|
_meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } },
|
|
ttlMs: 0,
|
|
cacheScope: "private",
|
|
},
|
|
}.to_json,
|
|
)
|
|
end
|
|
|
|
def stub_request(method, url)
|
|
WebMock.stub_request(method, url)
|
|
end
|
|
|
|
# Polls until the block is truthy; the listener runs on a background
|
|
# thread, so its requests are observed asynchronously.
|
|
def wait_until(timeout: 5)
|
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
until yield
|
|
flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
sleep(0.01)
|
|
end
|
|
end
|
|
|
|
def requested?(stub)
|
|
WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive?
|
|
end
|
|
|
|
def url
|
|
"http://example.com"
|
|
end
|
|
|
|
def client
|
|
@client ||= HTTP.new(url: url)
|
|
end
|
|
end
|
|
end
|
|
end
|