## Motivation and Context
The ping utility says the receiver "MUST respond promptly with an empty response",
and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor
that on the client side (the TypeScript protocol layer installs an automatic pong,
and the Python session answers `PingRequest` with an empty result), but this client answered
a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio,
leaving long-lived legacy sessions vulnerable to a liveness-checking server.
Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`.
The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens
the GET listening stream on connect, and a built-in pong must not change listener behavior -
a server can only ping on a stream that is already open. A handler registered via
`on_server_request("ping")` still wins.
Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response.
Frames are told apart by shape rather than id: one carrying `method` is a request or notification,
never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered -
and a request frame can no longer be returned to the caller as if it were the response.
Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading,
which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay
unsupported as documented.
The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written
over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading,
and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright -
is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found,
exactly as before this change.
The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only.
## How Has This Been Tested?
New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with
the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while
a response is awaited is answered on stdin and the original response still returns. Both fail without the change
(Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over
a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio
and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None. A server ping that previously received Method not found (or nothing) now receives the empty result
the specification requires.
1957 lines
63 KiB
Ruby
1957 lines
63 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "json"
|
|
require "mcp/client"
|
|
require "mcp/client/stdio"
|
|
require "mcp/client/tool"
|
|
|
|
module MCP
|
|
class Client
|
|
class StdioTest < Minitest::Test
|
|
include DeprecationWarningTestHelper
|
|
|
|
def test_send_request_raises_when_connect_not_called
|
|
Open3.expects(:popen3).never
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" })
|
|
end
|
|
|
|
assert_equal("MCP::Client#connect must be called before sending requests.", error.message)
|
|
end
|
|
|
|
def test_send_request_starts_process_and_returns_response
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
# Simulate server responses: initialize response, then tools/list response
|
|
server_thread = Thread.new do
|
|
# Read and respond to initialize request
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
init_response = {
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}
|
|
stdout_write.puts(JSON.generate(init_response))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read and respond to tools/list request
|
|
tools_line = stdin_read.gets
|
|
tools_request = JSON.parse(tools_line)
|
|
tools_response = {
|
|
jsonrpc: "2.0",
|
|
id: tools_request["id"],
|
|
result: { tools: [{ name: "test_tool", description: "A test tool", inputSchema: {} }] },
|
|
}
|
|
stdout_write.puts(JSON.generate(tools_response))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_equal(1, response.dig("result", "tools").size)
|
|
assert_equal("test_tool", response.dig("result", "tools", 0, "name"))
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
stderr_read.close
|
|
end
|
|
|
|
def test_send_request_answers_a_server_ping_inline
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
pong_line = nil
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification, then the tools/list request.
|
|
stdin_read.gets
|
|
stdin_read.gets
|
|
|
|
# Ping the client while it awaits the response; the receiver "MUST respond promptly".
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" }))
|
|
stdout_write.flush
|
|
|
|
pong_line = stdin_read.gets
|
|
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } }))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
server_thread.join
|
|
|
|
assert_equal("test-id", response["id"])
|
|
pong = JSON.parse(pong_line)
|
|
assert_equal("srv-ping-1", pong["id"])
|
|
assert_equal({}, pong["result"])
|
|
refute(pong.key?("error"))
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_response
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
pong_line = nil
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification, then the tools/list request.
|
|
stdin_read.gets
|
|
stdin_read.gets
|
|
|
|
# JSON-RPC id spaces are per sender, so a server ping may reuse the id the client
|
|
# is awaiting; it must still be answered rather than treated as the response.
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" }))
|
|
stdout_write.flush
|
|
|
|
pong_line = stdin_read.gets
|
|
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } }))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
server_thread.join
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_empty(response.dig("result", "tools"))
|
|
pong = JSON.parse(pong_line)
|
|
assert_equal("test-id", pong["id"])
|
|
assert_equal({}, pong["result"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification, then the tools/list request.
|
|
stdin_read.gets
|
|
stdin_read.gets
|
|
|
|
# Break the write path: the pong cannot be delivered, and that must not fail the request.
|
|
stdin_read.close
|
|
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" }))
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } }))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_empty(response.dig("result", "tools"))
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close unless stdin_read.closed?
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_a_ping_with_a_malformed_id_is_not_answered
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
pong_line = nil
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification, then the tools/list request.
|
|
stdin_read.gets
|
|
stdin_read.gets
|
|
|
|
# A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright.
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" }))
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" }))
|
|
stdout_write.flush
|
|
|
|
# The single pong that arrives must answer the well-formed ping, not the malformed one.
|
|
pong_line = stdin_read.gets
|
|
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } }))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
server_thread.join
|
|
|
|
assert_equal("test-id", response["id"])
|
|
pong = JSON.parse(pong_line)
|
|
assert_equal("srv-ping-2", pong["id"])
|
|
assert_equal({}, pong["result"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_skips_notifications
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
# Handle initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request
|
|
stdin_read.gets
|
|
|
|
# Send a notification before the response
|
|
notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" }
|
|
stdout_write.puts(JSON.generate(notification))
|
|
stdout_write.flush
|
|
|
|
# Then send the actual response
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
result: { tools: [] },
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_empty(response.dig("result", "tools"))
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_skips_non_object_frames
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request
|
|
stdin_read.gets
|
|
|
|
# Non-object frames are skipped like any other non-matching frame.
|
|
stdout_write.puts("[[]]")
|
|
stdout_write.puts("null")
|
|
stdout_write.flush
|
|
|
|
# Then send the actual response
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
result: { tools: [] },
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
response = transport.send_request(request: request)
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_empty(response.dig("result", "tools"))
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_raises_error_when_process_exits
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
|
|
dead_thread = mock("wait_thread")
|
|
dead_thread.stubs(:alive?).returns(false)
|
|
dead_thread.stubs(:value).returns(nil)
|
|
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, dead_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
assert_equal("Server process has exited", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
ensure
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_raises_error_on_closed_stdout
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
# Handle initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request, then close stdout
|
|
stdin_read.gets
|
|
stdout_write.close
|
|
end
|
|
|
|
transport.connect
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("Server process closed stdout unexpectedly", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_close_resets_state
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, stderr_write = IO.pipe
|
|
|
|
wait_thread = mock("wait_thread")
|
|
wait_thread.stubs(:alive?).returns(true)
|
|
wait_thread.stubs(:value).returns(nil)
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
|
|
assert(transport.instance_variable_get(:@started))
|
|
|
|
transport.close
|
|
|
|
refute(transport.instance_variable_get(:@started))
|
|
refute(transport.instance_variable_get(:@initialized))
|
|
ensure
|
|
stdin_read.close
|
|
begin
|
|
stdin_write.close
|
|
rescue
|
|
nil
|
|
end
|
|
begin
|
|
stdout_read.close
|
|
rescue
|
|
nil
|
|
end
|
|
stdout_write.close
|
|
begin
|
|
stderr_read.close
|
|
rescue
|
|
nil
|
|
end
|
|
stderr_write.close
|
|
end
|
|
|
|
def test_multiple_send_requests_do_not_reinitialize
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
received_methods = []
|
|
|
|
server_thread = Thread.new do
|
|
# First call: initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
received_methods << init_request["method"]
|
|
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
notification_line = stdin_read.gets
|
|
received_methods << JSON.parse(notification_line)["method"]
|
|
|
|
# First request: tools/list
|
|
first_line = stdin_read.gets
|
|
first_request = JSON.parse(first_line)
|
|
received_methods << first_request["method"]
|
|
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: first_request["id"],
|
|
result: { tools: [] },
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Second request: tools/list (no re-initialization)
|
|
second_line = stdin_read.gets
|
|
second_request = JSON.parse(second_line)
|
|
received_methods << second_request["method"]
|
|
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: second_request["id"],
|
|
result: { tools: [] },
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" })
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" })
|
|
|
|
assert_equal(
|
|
["initialize", "notifications/initialized", "tools/list", "tools/list"],
|
|
received_methods,
|
|
)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_env_is_passed_to_process
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"], env: { "FOO" => "bar" })
|
|
|
|
Open3.expects(:popen3).with({ "FOO" => "bar" }, "ruby", "server.rb").returns(
|
|
[StringIO.new, StringIO.new, StringIO.new, mock_wait_thread],
|
|
)
|
|
|
|
transport.start
|
|
end
|
|
|
|
def test_send_request_raises_error_on_invalid_json
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
# Handle initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request, then send invalid JSON
|
|
stdin_read.gets
|
|
stdout_write.puts("not valid json")
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("Failed to parse server response", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
assert_instance_of(JSON::ParserError, error.original_error)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_close_kills_process_on_timeout
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
hanging_thread = mock("wait_thread")
|
|
hanging_thread.stubs(:alive?).returns(true)
|
|
hanging_thread.stubs(:pid).returns(99999)
|
|
hanging_thread.stubs(:value).raises(Timeout::Error)
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, hanging_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
|
|
Process.expects(:kill).with("TERM", 99999).once
|
|
Process.expects(:kill).with("KILL", 99999).once
|
|
|
|
transport.close
|
|
ensure
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_read_response_raises_error_on_timeout
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.01)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: "test-id",
|
|
method: "tools/list",
|
|
}
|
|
|
|
server_thread = Thread.new do
|
|
# Handle initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request but don't respond (simulate timeout)
|
|
stdin_read.gets
|
|
end
|
|
|
|
transport.connect
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request(request: request)
|
|
end
|
|
|
|
assert_equal("Timed out waiting for server response", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_raises_error_when_stdin_is_closed
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
# Handle initialize handshake
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification
|
|
stdin_read.gets
|
|
|
|
# Read and respond to first request
|
|
line = stdin_read.gets
|
|
request = JSON.parse(line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: request["id"],
|
|
result: {},
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
# Complete a successful request before breaking the pipe.
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "setup", method: "ping" })
|
|
server_thread.join
|
|
|
|
# Now close stdin to simulate broken pipe
|
|
stdin_write.close
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" })
|
|
end
|
|
|
|
assert_equal("Failed to write to server process", error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
ensure
|
|
stdin_read.close
|
|
begin
|
|
stdin_write.close
|
|
rescue
|
|
nil
|
|
end
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_close_is_noop_when_not_started
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
# Should not raise
|
|
transport.close
|
|
end
|
|
|
|
def test_start_raises_error_when_already_started
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
transport.start
|
|
end
|
|
|
|
assert_equal("MCP::Client::Stdio already started", error.message)
|
|
ensure
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_start_raises_error_for_invalid_command
|
|
Open3.stubs(:popen3).raises(Errno::ENOENT.new("No such file or directory - nonexistent_command"))
|
|
|
|
transport = Stdio.new(command: "nonexistent_command")
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.start
|
|
end
|
|
|
|
assert_match(/Failed to spawn server process/, error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
assert_instance_of(Errno::ENOENT, error.original_error)
|
|
end
|
|
|
|
def test_connect_performs_initialize_handshake_explicitly
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
received_methods = []
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
received_methods << init_request["method"]
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
))
|
|
stdout_write.flush
|
|
|
|
notification_line = stdin_read.gets
|
|
received_methods << JSON.parse(notification_line)["method"]
|
|
end
|
|
|
|
result = transport.connect
|
|
|
|
server_thread.join
|
|
|
|
assert_equal(["initialize", "notifications/initialized"], received_methods)
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
assert_equal({ "tools" => {} }, result["capabilities"])
|
|
assert_equal({ "name" => "test-server", "version" => "1.0.0" }, result["serverInfo"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_does_not_warn_after_explicit_connect
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
))
|
|
stdout_write.flush
|
|
|
|
stdin_read.gets
|
|
|
|
ping_line = stdin_read.gets
|
|
ping_request = JSON.parse(ping_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: ping_request["id"],
|
|
result: {},
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
assert_silent do
|
|
transport.connect
|
|
end
|
|
|
|
response = nil
|
|
assert_silent do
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "ping-id", method: "ping" })
|
|
end
|
|
|
|
assert_equal("ping-id", response["id"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_caches_server_info
|
|
transport, server_thread, pipes = stub_successful_connect
|
|
|
|
transport.connect
|
|
|
|
assert_equal("2025-11-25", transport.server_info["protocolVersion"])
|
|
assert_equal({ "tools" => {} }, transport.server_info["capabilities"])
|
|
ensure
|
|
server_thread.join
|
|
pipes.each(&:close)
|
|
end
|
|
|
|
def test_connect_is_idempotent
|
|
transport, server_thread, pipes = stub_successful_connect
|
|
|
|
first_result = transport.connect
|
|
second_result = transport.connect
|
|
|
|
assert_same(first_result, second_result)
|
|
ensure
|
|
server_thread.join
|
|
pipes.each(&:close)
|
|
end
|
|
|
|
def test_connect_accepts_custom_parameters
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
sent_init_params = nil
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
sent_init_params = init_request["params"]
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2025-03-26" },
|
|
))
|
|
stdout_write.flush
|
|
stdin_read.gets
|
|
end
|
|
|
|
transport.connect(
|
|
client_info: { name: "my-app", version: "9.9" },
|
|
protocol_version: "2025-03-26",
|
|
capabilities: { roots: { listChanged: true } },
|
|
)
|
|
|
|
assert_equal("2025-03-26", sent_init_params["protocolVersion"])
|
|
assert_equal({ "name" => "my-app", "version" => "9.9" }, sent_init_params["clientInfo"])
|
|
assert_equal({ "roots" => { "listChanged" => true } }, sent_init_params["capabilities"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_adopts_a_counter_offered_protocol_version
|
|
# A server may answer `initialize` with a version other than the one offered, and the connection
|
|
# then speaks the answer rather than the offer. This server counter-offers a handshake version
|
|
# to clients asking `initialize` for a modern one, which only resolves anything because
|
|
# clients behave this way.
|
|
counter_offered = "2025-06-18"
|
|
refute_equal(
|
|
counter_offered,
|
|
MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION,
|
|
"the counter-offer has to differ from the offer for this to test anything",
|
|
)
|
|
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
offered = nil
|
|
|
|
server_thread = Thread.new do
|
|
init_request = JSON.parse(stdin_read.gets)
|
|
offered = init_request.dig("params", "protocolVersion")
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: counter_offered },
|
|
))
|
|
stdout_write.flush
|
|
stdin_read.gets
|
|
end
|
|
|
|
transport.connect
|
|
|
|
assert_equal(MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, offered)
|
|
assert_equal(counter_offered, transport.protocol_version)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result
|
|
# A server answering `initialize` with a modern version is refused like an unknown one:
|
|
# the handshake settles on a legacy version by definition (SEP-2575 era model),
|
|
# and the TypeScript and Python clients refuse a modern counter-offer the same way.
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2026-07-28" },
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect(capabilities: { roots: { listChanged: true }, sampling: {} })
|
|
end
|
|
|
|
assert_includes(error.message, "2026-07-28")
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_raises_argument_error_for_an_explicit_modern_protocol_version_on_the_legacy_handshake
|
|
# Validated before the child process is spawned: a pure argument error must not leave
|
|
# an orphaned server process behind.
|
|
Open3.expects(:popen3).never
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
error = assert_raises(ArgumentError) do
|
|
transport.connect(protocol_version: "2026-07-28", mode: :legacy)
|
|
end
|
|
|
|
assert_includes(error.message, "cannot be negotiated through the legacy `initialize` handshake")
|
|
end
|
|
|
|
def test_connect_raises_on_jsonrpc_error_response
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
error: { code: -32602, message: "boom" },
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
assert_includes(error.message, "boom")
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_raises_on_missing_result
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
assert_includes(error.message, "missing result in response")
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_raises_on_non_hash_result
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: [],
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
assert_includes(error.message, "missing result in response")
|
|
refute_predicate(transport, :connected?)
|
|
assert_nil(transport.server_info)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_clears_state_when_initialized_notification_fails
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
# Close stdin_read first so the next write to @stdin (the notification) raises EPIPE.
|
|
stdin_read.close
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2025-11-25" },
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
refute_predicate(transport, :connected?)
|
|
assert_nil(transport.server_info)
|
|
ensure
|
|
server_thread.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_connect_can_be_retried_after_failed_handshake
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
# First handshake: server returns an unsupported protocol version.
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "1999-01-01" },
|
|
))
|
|
stdout_write.flush
|
|
|
|
# Second handshake: server returns a supported protocol version.
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2025-11-25" },
|
|
))
|
|
stdout_write.flush
|
|
stdin_read.gets
|
|
end
|
|
|
|
assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
refute_predicate(transport, :connected?)
|
|
assert_nil(transport.server_info)
|
|
|
|
result = transport.connect
|
|
|
|
server_thread.join
|
|
|
|
assert_predicate(transport, :connected?)
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connect_raises_on_unsupported_protocol_version
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
sent_methods = []
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
sent_methods << init_request["method"]
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "1999-01-01" },
|
|
))
|
|
stdout_write.flush
|
|
end
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.connect
|
|
end
|
|
|
|
assert_includes(error.message, "unsupported protocol version")
|
|
assert_includes(error.message, "1999-01-01")
|
|
assert_equal(["initialize"], sent_methods)
|
|
refute_predicate(transport, :connected?)
|
|
assert_nil(transport.server_info)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_connected_is_false_before_first_send_request
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
refute_predicate(transport, :connected?)
|
|
end
|
|
|
|
def test_connected_is_true_after_explicit_connect
|
|
transport, server_thread, pipes = stub_successful_connect
|
|
|
|
refute_predicate(transport, :connected?)
|
|
|
|
transport.connect
|
|
|
|
assert_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread.join
|
|
pipes.each(&:close)
|
|
end
|
|
|
|
def test_connected_is_false_after_close
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, stderr_write = IO.pipe
|
|
|
|
wait_thread = mock("wait_thread")
|
|
wait_thread.stubs(:alive?).returns(true)
|
|
wait_thread.stubs(:value).returns(nil)
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
transport.instance_variable_set(:@initialized, true)
|
|
|
|
assert_predicate(transport, :connected?)
|
|
|
|
transport.close
|
|
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_server_info_is_nil_before_connect
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
assert_nil(transport.server_info)
|
|
end
|
|
|
|
def test_server_info_is_cleared_after_close
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, stderr_write = IO.pipe
|
|
|
|
wait_thread = mock("wait_thread")
|
|
wait_thread.stubs(:alive?).returns(true)
|
|
wait_thread.stubs(:value).returns(nil)
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.start
|
|
transport.instance_variable_set(:@initialized, true)
|
|
transport.instance_variable_set(:@server_info, { "protocolVersion" => "2025-11-25" })
|
|
|
|
transport.close
|
|
|
|
assert_nil(transport.server_info)
|
|
ensure
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer
|
|
[nil, 0, -1, 1.5, "1024"].each do |invalid|
|
|
error = assert_raises(ArgumentError) do
|
|
Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid)
|
|
end
|
|
assert_equal("max_line_bytes must be a positive Integer", error.message)
|
|
end
|
|
end
|
|
|
|
def test_send_request_raises_when_response_frame_exceeds_max_line_bytes
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024)
|
|
|
|
request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" }
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# Read initialized notification.
|
|
stdin_read.gets
|
|
|
|
# Read tools/list request, then stream an unbounded frame with no newline.
|
|
stdin_read.gets
|
|
stdout_write.write("A" * 2000)
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request(request: request)
|
|
end
|
|
|
|
assert_match(/exceeds 1024 bytes without a newline/, error.message)
|
|
assert_equal(:internal_error, error.error_type)
|
|
# The stream is desynced (leftover bytes in the pipe), so the transport is closed
|
|
# rather than left resumable on a corrupt stream.
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_request_accepts_response_within_custom_max_line_bytes
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024)
|
|
|
|
request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" }
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
stdin_read.gets
|
|
|
|
tools_line = stdin_read.gets
|
|
tools_request = JSON.parse(tools_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: tools_request["id"],
|
|
result: { tools: [] },
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
transport.connect
|
|
|
|
response = transport.send_request(request: request)
|
|
|
|
assert_equal("test-id", response["id"])
|
|
assert_equal({ "tools" => [] }, response["result"])
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_send_notification_writes_json_line_without_waiting_for_response
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
|
params: { requestId: "abc-123", reason: "user cancel" },
|
|
}
|
|
|
|
# Server: respond to initialize, then read the notification line.
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test", version: "1.0.0" } },
|
|
}))
|
|
stdout_write.flush
|
|
|
|
stdin_read.gets # skip initialized notification
|
|
|
|
stdin_read.gets # read the cancellation notification line we are testing
|
|
end
|
|
|
|
result = transport.send_notification(notification: notification)
|
|
|
|
assert_nil(result, "send_notification must return nil (no response expected)")
|
|
ensure
|
|
server_thread.join
|
|
stdin_read.close
|
|
stdin_write.close
|
|
stdout_read.close
|
|
stdout_write.close
|
|
end
|
|
|
|
def test_concurrent_write_message_does_not_interleave_lines
|
|
# Regression: cancellation can call `send_notification` from a thread while `send_request` is mid-flight
|
|
# on another thread. The two writes must serialize so JSON-RPC lines do not interleave on stdin.
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
# Server: respond to initialize, then accept multiple lines.
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "t", version: "1.0.0" } },
|
|
}))
|
|
stdout_write.flush
|
|
stdin_read.gets # initialized
|
|
end
|
|
|
|
# Force initialization synchronously.
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
|
params: { requestId: "warmup" },
|
|
}
|
|
transport.send_notification(notification: notification)
|
|
server_thread.join
|
|
|
|
# Now hammer send_notification from many threads concurrently. Each line is one JSON object terminated by `\n`;
|
|
# if writes interleave, the received text would not be one valid JSON object per line.
|
|
threads = 8.times.map do |i|
|
|
Thread.new do
|
|
10.times do |j|
|
|
transport.send_notification(
|
|
notification: {
|
|
jsonrpc: "2.0",
|
|
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
|
|
params: { requestId: "t#{i}-#{j}" },
|
|
},
|
|
)
|
|
end
|
|
end
|
|
end
|
|
threads.each(&:join)
|
|
|
|
# Drain everything written to stdin and verify each line parses as JSON.
|
|
stdin_write.close
|
|
lines = []
|
|
until stdin_read.eof?
|
|
line = stdin_read.gets
|
|
break if line.nil?
|
|
|
|
lines << line
|
|
end
|
|
lines.each do |line|
|
|
assert(JSON.parse(line.strip), "interleaved write produced unparseable line: #{line.inspect}")
|
|
end
|
|
assert_operator(lines.size, :>=, 80, "expected at least 80 notification lines")
|
|
ensure
|
|
stdout_read.close
|
|
stdout_write.close
|
|
begin
|
|
stderr_read.close
|
|
rescue
|
|
nil
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def stub_successful_connect
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
init_line = stdin_read.gets
|
|
init_request = JSON.parse(init_line)
|
|
stdout_write.puts(JSON.generate(
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "test-server", version: "1.0.0" },
|
|
},
|
|
))
|
|
stdout_write.flush
|
|
stdin_read.gets
|
|
end
|
|
|
|
[transport, server_thread, [stdin_read, stdin_write, stdout_read, stdout_write]]
|
|
end
|
|
|
|
def test_send_request_allows_server_discover_before_connect
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
discover_request = JSON.parse(stdin_read.gets)
|
|
response = {
|
|
jsonrpc: "2.0",
|
|
id: discover_request["id"],
|
|
result: { supportedVersions: ["2026-07-28"], ttlMs: 0, cacheScope: "private" },
|
|
}
|
|
stdout_write.puts(JSON.generate(response))
|
|
stdout_write.flush
|
|
end
|
|
|
|
# No prior `connect`: `server/discover` (SEP-2575) is sessionless discovery.
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "d1", method: "server/discover" })
|
|
|
|
assert_equal(["2026-07-28"], response.dig("result", "supportedVersions"))
|
|
refute_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread&.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_connect_modern_probes_discover_and_stamps_requests
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
received = []
|
|
server_thread = Thread.new do
|
|
discover_request = JSON.parse(stdin_read.gets)
|
|
received << discover_request
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: discover_request["id"],
|
|
result: {
|
|
supportedVersions: ["2026-07-28"],
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0" },
|
|
ttlMs: 0,
|
|
cacheScope: "private",
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
tools_request = JSON.parse(stdin_read.gets)
|
|
received << tools_request
|
|
stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: tools_request["id"], result: { tools: [] } }))
|
|
stdout_write.flush
|
|
end
|
|
|
|
result = transport.connect(mode: :modern, client_info: { name: "my-app", version: "9.9" })
|
|
response = transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" })
|
|
|
|
assert_equal(["2026-07-28"], result["supportedVersions"])
|
|
assert_predicate(transport, :modern?)
|
|
assert_predicate(transport, :connected?)
|
|
assert_equal("2026-07-28", transport.protocol_version)
|
|
assert_equal("t1", response["id"])
|
|
|
|
# No `initialize` was sent; both frames carry the SEP-2575 envelope.
|
|
assert_equal(["server/discover", "tools/list"], received.map { |frame| frame["method"] })
|
|
triple = received[1].dig("params", "_meta")
|
|
assert_equal("2026-07-28", triple["io.modelcontextprotocol/protocolVersion"])
|
|
assert_equal({ "name" => "my-app", "version" => "9.9" }, triple["io.modelcontextprotocol/clientInfo"])
|
|
assert_equal({}, triple["io.modelcontextprotocol/clientCapabilities"])
|
|
ensure
|
|
server_thread&.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_connect_modern_warns_for_deprecated_capabilities
|
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks.
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
discover_request = JSON.parse(stdin_read.gets)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: discover_request["id"],
|
|
result: {
|
|
supportedVersions: ["2026-07-28"],
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0" },
|
|
ttlMs: 0,
|
|
cacheScope: "private",
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
end
|
|
|
|
assert_deprecation_warning(/MCP Roots .*2026-07-28.*MCP Sampling .*2026-07-28/m) do
|
|
transport.connect(mode: :modern, capabilities: { roots: { listChanged: true }, sampling: {} })
|
|
end
|
|
|
|
assert_predicate(transport, :modern?)
|
|
ensure
|
|
server_thread&.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_connect_auto_falls_back_to_the_legacy_handshake
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
|
|
server_thread = Thread.new do
|
|
discover_request = JSON.parse(stdin_read.gets)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: discover_request["id"],
|
|
error: { code: -32601, message: "Method not found" },
|
|
}))
|
|
stdout_write.flush
|
|
|
|
init_request = JSON.parse(stdin_read.gets)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# `notifications/initialized`
|
|
stdin_read.gets
|
|
end
|
|
|
|
result = transport.connect(mode: :auto)
|
|
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
assert_equal("2025-11-25", transport.protocol_version)
|
|
refute_predicate(transport, :modern?)
|
|
assert_predicate(transport, :connected?)
|
|
ensure
|
|
server_thread&.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def test_connect_auto_bounds_the_discover_probe_when_no_read_timeout_is_configured
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"])
|
|
transport.stubs(:start)
|
|
|
|
observed_timeout = nil
|
|
transport.define_singleton_method(:probe_discover) do
|
|
observed_timeout = @read_timeout
|
|
raise RequestHandlerError.new("probe timed out", {}, error_type: :internal_error)
|
|
end
|
|
transport.define_singleton_method(:connect_legacy) do |**|
|
|
{ "protocolVersion" => "2025-11-25" }
|
|
end
|
|
|
|
result = transport.connect(mode: :auto)
|
|
|
|
assert_equal(Stdio::DEFAULT_DISCOVER_PROBE_TIMEOUT, observed_timeout)
|
|
assert_nil(transport.instance_variable_get(:@read_timeout))
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
end
|
|
|
|
def test_connect_auto_falls_back_when_the_server_never_answers_the_probe
|
|
stdin_read, stdin_write = IO.pipe
|
|
stdout_read, stdout_write = IO.pipe
|
|
stderr_read, _ = IO.pipe
|
|
|
|
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
|
# A configured `read_timeout` also bounds the probe; the server below reads
|
|
# the `server/discover` request and never responds, which must count as
|
|
# legacy evidence instead of blocking `connect` forever.
|
|
transport = Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: 0.05)
|
|
|
|
server_thread = Thread.new do
|
|
# Silently swallow the probe.
|
|
stdin_read.gets
|
|
|
|
init_request = JSON.parse(stdin_read.gets)
|
|
stdout_write.puts(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: init_request["id"],
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
serverInfo: { name: "test-server", version: "1.0" },
|
|
},
|
|
}))
|
|
stdout_write.flush
|
|
|
|
# `notifications/initialized`
|
|
stdin_read.gets
|
|
end
|
|
|
|
result = transport.connect(mode: :auto)
|
|
|
|
assert_equal("2025-11-25", result["protocolVersion"])
|
|
refute_predicate(transport, :modern?)
|
|
ensure
|
|
server_thread&.join
|
|
[stdin_read, stdin_write, stdout_read, stdout_write, stderr_read].each do |io|
|
|
io.close unless io.closed?
|
|
end
|
|
end
|
|
|
|
def mock_wait_thread
|
|
thread = mock("wait_thread")
|
|
thread.stubs(:alive?).returns(true)
|
|
thread.stubs(:value).returns(nil)
|
|
thread
|
|
end
|
|
end
|
|
end
|
|
end
|