## Motivation and Context Resolves the `sse-retry` client conformance scenario (SEP-1699, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699). Per SEP-1699, a server may close a request's SSE stream right after a priming event (an event carrying an `id:`) without delivering the response, expecting the client to treat the graceful close like a network failure: wait the server-specified `retry:` interval, then reconnect with an HTTP GET carrying `Last-Event-ID` so the server can replay the pending response on the resumed stream. The Ruby client previously read the entire SSE body to EOF and parsed it afterward, so it had no notion of per-event state (`id:`, `retry:`) or reconnection, and the scenario failed all three checks (graceful reconnect, retry timing, `Last-Event-ID`). `MCP::Client::HTTP#send_request` now consumes responses incrementally via Faraday's `on_data` streaming callback, feeding SSE chunks to `event_stream_parser` as they arrive while buffering plain JSON bodies as before. A new internal `SSEStream` tracks the last received event id, the `retry:` reconnection delay, and the awaited JSON-RPC response; once the response arrives, a held-open stream is abandoned via an internal control-flow exception since servers may never close it. When a stream closes gracefully after a priming event but before the response, the client sleeps for the `retry:` interval (default 1000ms when the server sent none), then issues a GET with `Accept: text/event-stream`, the session headers, and `Last-Event-ID`, for up to 2 attempts. The defaults match the Python SDK's `DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` (`streamable_http.py`); the TypeScript SDK's `StreamableHTTPClientTransport` (`streamableHttp.ts`) likewise lets the `retry:` field override its backoff and reconnects only when a priming event was received and no response has arrived. The public behavior of `send_request` is unchanged: same return values, same error mapping, and a stream that closes without a priming event still raises the "No valid JSON-RPC response found in SSE stream" error without reconnecting. Because the previous implementation read `response.body` and therefore worked with any Faraday adapter, a fallback keeps that compatibility: when nothing was parsed during streaming, the body is read from `response.body` (adapters without `on_data` support, e.g. the Faraday test adapter) or from the buffered chunks (Faraday < 2.1, which invokes `on_data` without `env`). The conformance client gains an `sse-retry` branch that calls the harness's `test_reconnection` tool, and the scenario is removed from the expected failures baseline. ## How Has This Been Tested? New unit tests covering: reconnection with `Last-Event-ID` after a primed graceful close including the `retry:` wait, the default 1000ms delay when `retry:` is absent, raising after the reconnection attempt cap with `Last-Event-ID` advancing across attempts, no reconnection for unprimed streams, and the non-streaming fallbacks (JSON and SSE responses through the Faraday test adapter, which ignores `on_data`, plus buffered SSE chunks when `on_data` receives no `env`) ## Breaking Changes None. The `send_request` contract is unchanged; SSE bodies are now parsed incrementally instead of after EOF, and reconnection only activates when the server opts in by sending a priming event.
1416 lines
44 KiB
Ruby
1416 lines
44 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
|
|
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_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_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_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_request(method, url)
|
|
WebMock.stub_request(method, url)
|
|
end
|
|
|
|
def url
|
|
"http://example.com"
|
|
end
|
|
|
|
def client
|
|
@client ||= HTTP.new(url: url)
|
|
end
|
|
end
|
|
end
|
|
end
|