## Motivation and Context
SEP-2243's custom-header half (MCP 2026-07-28) lets a tool annotate `inputSchema` properties with
`x-mcp-header` so intermediaries can route on argument values without parsing bodies: the client MUST
mirror each annotated argument of a `tools/call` into an `Mcp-Param-{Name}` header, encode values that
cannot ride as plain ASCII field values with the `=?base64?...?=` sentinel, and omit the header for
`null` or absent arguments. `MCP::Client::HTTP` sent only the standard `Mcp-Method`/`Mcp-Name` mirror
headers, so every `http-custom-headers` check of the 2026-07-28 conformance requirements fails.
The new `MCP::Client::McpParamHeaders` module ports the TypeScript SDK's `mcpParamHeaders` codec:
- `scan` walks a tool's `inputSchema` for declarations and validates every constraint the spec places
on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed declaring properties
(`number` is accepted alongside the spec's `string`/`integer`/`boolean` because the published
referee annotates `type: "number"` parameters, the same accommodation the TypeScript SDK makes),
and static reachability through a chain of `properties` keys only: an annotation under `items`,
the combinators, or `$defs` invalidates the whole tool definition.
- `build` resolves each declaration's path in the call's `arguments`, omits `null`/absent and
non-representable values (unsafe integers, non-finite floats, non-primitives), converts primitives
per the spec's rules, and encodes with the sentinel exactly where a plain ASCII field value
cannot carry the value (empty, edge whitespace, bytes outside visible ASCII, sentinel-shaped input).
`MCP::Client::HTTP` learns declarations from the `tools/list` responses passing through it (the same
source of truth the TypeScript SDK's response cache uses) and mirrors on each `tools/call` alongside
the existing `Mcp-Method`/`Mcp-Name` construction. Learning and mirroring exist on the modern
lifecycle only, matching both reference SDKs. An invalid tool definition mirrors nothing, per
the spec's guidance to send without custom headers when no reliable declarations are available;
a complete (uncursored, `nextCursor`-less) listing rebuilds the registry so declarations of
unlisted tools stop mirroring (the Python SDK's pruning), and the registry is capped
(`MAX_MCP_PARAM_TOOLS`, 1000) so a server rotating tool names cannot grow it without bound.
On the modern lifecycle, `MCP::Client#list_tools` and `#tools` also enforce the spec's MUST:
a tool definition whose `x-mcp-header` annotations violate the constraints is excluded from
the listing, with a warning naming the tool and the reason (the spec's SHOULD), so one malformed
definition does not block the valid tools - the same filtering the Python SDK's
`_absorb_tool_listing` and the TypeScript SDK's cached-listing finalize apply. A value that cannot
be represented as UTF-8 omits its header like the other non-representable values.
The README documents the mirroring and the exclusion under the lifecycle negotiation section.
The server-side validation half of the codec is a follow-up.
## How Has This Been Tested?
New `test/mcp/client/mcp_param_headers_test.rb` covers the scan constraint matrix (root and
non-reachable placements, empty/non-token/duplicate names, non-primitive types), the primitive
conversions (including `42.0` serializing as `42` and unsafe integers refusing), the encoding matrix
(plain pass-through, empty, non-ASCII, edge whitespace, CR/LF, sentinel-shaped input), and header
construction with nested paths and omitted values (including a value that cannot be represented
as UTF-8). `test/mcp/client/http_test.rb` drives the transport end to end over a modern connection:
a `tools/list` teaching the declarations, the following `tools/call` carrying the mirrored headers,
an invalid declaration mirroring nothing, a legacy connection learning nothing, and a complete
listing pruning the declarations of unlisted tools. `test/mcp/client_test.rb` covers the MUST-level
exclusion: the invalid definition dropped from `#tools` with the warning on a modern connection,
and listed unchanged on a legacy one. `bundle exec rake` is green.
## Breaking Changes
None. Requests to servers whose tools carry no `x-mcp-header` annotations are byte-for-byte unchanged,
and the new headers only appear for tools that declare them.
2239 lines
72 KiB
Ruby
2239 lines
72 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_mirrors_x_mcp_header_params_into_mcp_param_headers
|
|
# SEP-2243: on a modern connection, `tools/list` teaches the transport the `x-mcp-header`
|
|
# declarations, and the following `tools/call` mirrors the annotated arguments into
|
|
# `Mcp-Param-*` headers.
|
|
call_headers = nil
|
|
client = mcp_param_test_client(
|
|
tools: [mcp_param_annotated_tool],
|
|
on_call: ->(headers) { call_headers = headers },
|
|
)
|
|
client.connect(mode: :modern)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
client.send_request(request: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "test_custom_headers",
|
|
arguments: { region: "us-west1", priority: 42, non_ascii_val: "Hello, 世界", null_val: nil },
|
|
},
|
|
})
|
|
|
|
assert_equal("us-west1", call_headers["Mcp-Param-Region"])
|
|
assert_equal("42", call_headers["Mcp-Param-Priority"])
|
|
assert_equal("=?base64?#{["Hello, 世界"].pack("m0")}?=", call_headers["Mcp-Param-NonAsciiVal"])
|
|
refute(call_headers.key?("Mcp-Param-NullVal"), "a null argument must omit its header")
|
|
end
|
|
|
|
def test_send_request_mirrors_nothing_for_a_tool_with_an_invalid_x_mcp_header_declaration
|
|
# An invalid tool definition (here a case-insensitive duplicate) mirrors nothing rather than
|
|
# emitting a partial or malformed header set.
|
|
invalid_tool = {
|
|
name: "invalid_duplicate",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
one: { type: "string", "x-mcp-header": "Region" },
|
|
two: { type: "string", "x-mcp-header": "REGION" },
|
|
},
|
|
},
|
|
}
|
|
call_headers = nil
|
|
client = mcp_param_test_client(tools: [invalid_tool], on_call: ->(headers) { call_headers = headers })
|
|
client.connect(mode: :modern)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
client.send_request(request: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: "invalid_duplicate", arguments: { one: "a", two: "b" } },
|
|
})
|
|
|
|
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
|
assert_equal("tools/call", call_headers["Mcp-Method"])
|
|
end
|
|
|
|
def test_send_request_mirrors_nothing_on_a_legacy_connection
|
|
# The custom headers exist on the modern lifecycle only (SEP-2243), matching
|
|
# the TypeScript and Python SDKs: without modern adoption nothing is learned or mirrored.
|
|
call_headers = nil
|
|
client = mcp_param_test_client(
|
|
tools: [mcp_param_annotated_tool],
|
|
on_call: ->(headers) { call_headers = headers },
|
|
)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
client.send_request(request: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "tools/call",
|
|
params: { name: "test_custom_headers", arguments: { region: "us-west1" } },
|
|
})
|
|
|
|
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
|
assert_equal("tools/call", call_headers["Mcp-Method"])
|
|
end
|
|
|
|
def test_send_request_prunes_declarations_dropped_by_a_complete_listing
|
|
# A complete (uncursored, `nextCursor`-less) listing is the full tool universe,
|
|
# so declarations of unlisted tools are stale and stop mirroring.
|
|
call_headers = nil
|
|
listings = [[mcp_param_annotated_tool], []]
|
|
client = mcp_param_test_client(
|
|
tools: -> { listings.shift || [] },
|
|
on_call: ->(headers) { call_headers = headers },
|
|
)
|
|
client.connect(mode: :modern)
|
|
|
|
client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" })
|
|
client.send_request(request: { jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
client.send_request(request: {
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "tools/call",
|
|
params: { name: "test_custom_headers", arguments: { region: "us-west1" } },
|
|
})
|
|
|
|
refute(call_headers.keys.any? { |key| key.start_with?("Mcp-Param-") })
|
|
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
|
|
|
|
# Builds an `HTTP` client over the Faraday test adapter for the SEP-2243 mirroring tests:
|
|
# the stub serves `server/discover` (so `connect(mode: :modern)` works), answers `tools/list`
|
|
# with `tools` (an Array, or a Proc returning the listing per request), and captures
|
|
# the request headers of any other POST via `on_call`.
|
|
def mcp_param_test_client(tools:, on_call:)
|
|
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
|
|
stub.post("/") do |env|
|
|
case JSON.parse(env.request_body)["method"]
|
|
when "server/discover"
|
|
discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" }
|
|
[200, { "Content-Type" => "application/json" }, { result: discover }.to_json]
|
|
when "tools/list"
|
|
listing = tools.respond_to?(:call) ? tools.call : tools
|
|
[200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json]
|
|
else
|
|
on_call.call(env.request_headers)
|
|
[200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json]
|
|
end
|
|
end
|
|
end
|
|
HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) }
|
|
end
|
|
|
|
def mcp_param_annotated_tool
|
|
{
|
|
name: "test_custom_headers",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
region: { type: "string", "x-mcp-header": "Region" },
|
|
priority: { type: "integer", "x-mcp-header": "Priority" },
|
|
non_ascii_val: { type: "string", "x-mcp-header": "NonAsciiVal" },
|
|
null_val: { type: "string", "x-mcp-header": "NullVal" },
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
def url
|
|
"http://example.com"
|
|
end
|
|
|
|
def client
|
|
@client ||= HTTP.new(url: url)
|
|
end
|
|
end
|
|
end
|
|
end
|