Add client-level connect handshake to stdio transport
## Motivation and Context This commit implements Phase 1 of 3 for #334. The MCP specification requires an `initialize` request followed by a `notifications/initialized` notification before any other interaction. PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect` to expose this handshake explicitly for the HTTP transport, but stdio remained on its private `initialize_session` lazy-init path, inconsistent with the Python SDK (`ClientSession.initialize()`) and TypeScript SDK (`Client.connect(transport)`), both of which require an explicit handshake call regardless of transport. The implicit init path on the first `send_request` is preserved as a non-breaking compatibility shim. Public API added on `Stdio`: - `connect(client_info:, protocol_version:, capabilities:)` performs the handshake, validates the negotiated `protocolVersion` against `MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`, caches the `InitializeResult`, and is idempotent. - `connected?` reports handshake completion. - `server_info` exposes the cached `InitializeResult`. Documentation and examples are updated so new users learn the explicit pattern from the start. ## How Has This Been Tested? Fifteen regression tests cover: explicit handshake, `server_info` caching, idempotence, custom keyword arguments, JSON-RPC error response, missing or non-Hash `result`, unsupported protocol version with state rollback, state rollback when the `notifications/initialized` write fails, retry after a failed handshake, and the `connected?` / `server_info` lifecycle. The pre-existing stdio tests pass without modification, verifying that the implicit-init compatibility path still works. ## Breaking Changes None for the typical "implicit init via `client.tools` / `client.call_tool`" usage. Existing stdio clients keep working unchanged. Three observable behavior changes for code that inspects transport state directly: - `Client#connected?` for a never-initialized stdio client returns `false` (previously fell through to a `true` default via the `respond_to?` fallback). - `Client#server_info` for stdio returns the cached `InitializeResult` after the handshake (previously always `nil` via the fallback). - The implicit handshake path now validates the server's negotiated protocol version, raising `RequestHandlerError` for unsupported versions. Production servers that return one of the supported versions are unaffected; this closes a pre-existing spec-conformance gap on stdio.
This commit is contained in:
parent
3d9437736b
commit
46b738e945
7 changed files with 581 additions and 75 deletions
|
|
@ -1782,6 +1782,9 @@ stdio_transport = MCP::Client::Stdio.new(
|
|||
)
|
||||
client = MCP::Client.new(transport: stdio_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools.
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
|
|
@ -1822,6 +1825,9 @@ Example usage:
|
|||
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
|
||||
client = MCP::Client.new(transport: http_transport)
|
||||
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@ The `MCP::Client` class provides an interface for interacting with MCP servers.
|
|||
- Prompt listing (`MCP::Client#prompts`) and retrieval (`MCP::Client#get_prompt`)
|
||||
- Completion requests (`MCP::Client#complete`)
|
||||
|
||||
## Handshake
|
||||
|
||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) before sending any other requests. The client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
||||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
```
|
||||
|
||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent: a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
||||
|
||||
This applies to both the Stdio and HTTP transports below.
|
||||
|
||||
## Stdio Transport
|
||||
|
||||
Use `MCP::Client::Stdio` to interact with MCP servers running as subprocesses:
|
||||
|
|
@ -28,6 +44,7 @@ stdio_transport = MCP::Client::Stdio.new(
|
|||
read_timeout: 30
|
||||
)
|
||||
client = MCP::Client.new(transport: stdio_transport)
|
||||
client.connect
|
||||
|
||||
tools = client.tools
|
||||
tools.each do |tool|
|
||||
|
|
@ -75,20 +92,6 @@ response = client.call_tool(
|
|||
)
|
||||
```
|
||||
|
||||
### Handshake
|
||||
|
||||
Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):
|
||||
|
||||
```ruby
|
||||
client.connect
|
||||
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }
|
||||
|
||||
client.connected? # => true
|
||||
client.server_info # => cached InitializeResult
|
||||
```
|
||||
|
||||
`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.
|
||||
|
||||
### Sessions
|
||||
|
||||
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ $ ruby examples/stdio_client.rb
|
|||
|
||||
The client will automatically launch `stdio_server.rb` as a subprocess and demonstrate:
|
||||
|
||||
- Performing the MCP initialization handshake via `client.connect`
|
||||
- Listing and calling tools
|
||||
- Listing prompts
|
||||
- Listing and reading resources
|
||||
- Automatic MCP protocol initialization
|
||||
- Transport cleanup on exit
|
||||
|
||||
### 3. HTTP Server (`http_server.rb`)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ transport = MCP::Client::Stdio.new(command: "ruby", args: [server_script])
|
|||
client = MCP::Client.new(transport: transport)
|
||||
|
||||
begin
|
||||
# Perform the MCP initialization handshake before sending any requests.
|
||||
client.connect
|
||||
|
||||
# List available tools
|
||||
puts "=== Listing tools ==="
|
||||
tools = client.tools
|
||||
|
|
|
|||
|
|
@ -61,18 +61,17 @@ module MCP
|
|||
|
||||
# The server's `InitializeResult` (protocol version, capabilities, server info,
|
||||
# instructions), as reported by the transport after a successful `connect`.
|
||||
# Returns `nil` before `connect`, after `close`, or when the transport manages
|
||||
# the handshake implicitly and does not expose it (e.g. stdio).
|
||||
# Returns `nil` before `connect`, after `close`, or when the transport does
|
||||
# not expose a cached handshake result.
|
||||
def server_info
|
||||
transport.server_info if transport.respond_to?(:server_info)
|
||||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake by delegating to the transport when
|
||||
# it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the
|
||||
# server's `InitializeResult`.
|
||||
# Performs the MCP `initialize` handshake by delegating to the transport
|
||||
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
|
||||
# `InitializeResult`.
|
||||
#
|
||||
# When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio`
|
||||
# manages the handshake implicitly on the first request), this is a no-op and
|
||||
# When the transport does not respond to `:connect`, this is a no-op and
|
||||
# returns `nil`.
|
||||
#
|
||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||
|
|
@ -91,10 +90,9 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
# Returns true once `connect` has completed the handshake on transports that
|
||||
# expose connection state. Transports that manage the handshake implicitly
|
||||
# (e.g. stdio) always report `true`, since the first request will initialize
|
||||
# on demand.
|
||||
# Returns true once `connect` has completed the handshake on the underlying
|
||||
# transport. Transports that do not expose connection state are assumed
|
||||
# connected and return `true`.
|
||||
def connected?
|
||||
return transport.connected? if transport.respond_to?(:connected?)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module MCP
|
|||
CLOSE_TIMEOUT = 2
|
||||
STDERR_READ_SIZE = 4096
|
||||
|
||||
attr_reader :command, :args, :env
|
||||
attr_reader :command, :args, :env, :server_info
|
||||
|
||||
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
||||
@command = command
|
||||
|
|
@ -33,11 +33,108 @@ module MCP
|
|||
@stderr_thread = nil
|
||||
@started = false
|
||||
@initialized = false
|
||||
@server_info = nil
|
||||
end
|
||||
|
||||
# Performs the MCP `initialize` handshake: sends an `initialize` request
|
||||
# followed by the required `notifications/initialized` notification. The
|
||||
# server's `InitializeResult` (protocol version, capabilities, server
|
||||
# info, instructions) is cached on the transport and returned.
|
||||
#
|
||||
# Idempotent: a second call returns the cached `InitializeResult` without
|
||||
# contacting the server. After `close`, state is cleared and `connect`
|
||||
# will handshake again. Spawns the subprocess via `start` if it has not
|
||||
# been started yet.
|
||||
#
|
||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
||||
# @param protocol_version [String, nil] Protocol version to offer. Defaults
|
||||
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
|
||||
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
||||
# @return [Hash] The server's `InitializeResult`.
|
||||
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error,
|
||||
# a malformed result, or an unsupported protocol version.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
||||
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
||||
return @server_info if @initialized
|
||||
|
||||
start unless @started
|
||||
|
||||
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
||||
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
||||
|
||||
init_request = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
id: SecureRandom.uuid,
|
||||
method: MCP::Methods::INITIALIZE,
|
||||
params: {
|
||||
protocolVersion: protocol_version,
|
||||
capabilities: capabilities,
|
||||
clientInfo: client_info,
|
||||
},
|
||||
}
|
||||
|
||||
write_message(init_request)
|
||||
response = read_response(init_request)
|
||||
|
||||
if response.key?("error")
|
||||
error = response["error"]
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: #{error["message"]}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
unless response["result"].is_a?(Hash)
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: missing result in response",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
@server_info = response["result"]
|
||||
|
||||
negotiated_protocol_version = @server_info["protocolVersion"]
|
||||
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
||||
# Per spec, if the client does not support the server's returned protocol version,
|
||||
# the client SHOULD disconnect. Roll back the cached `InitializeResult` before
|
||||
# raising so a retry starts without a stale `server_info`.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
||||
@server_info = nil
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
begin
|
||||
notification = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
||||
}
|
||||
write_message(notification)
|
||||
rescue StandardError
|
||||
@server_info = nil
|
||||
raise
|
||||
end
|
||||
|
||||
@initialized = true
|
||||
@server_info
|
||||
end
|
||||
|
||||
# Returns true once `connect` (or the implicit handshake on the first
|
||||
# `send_request`) has completed. Returns false before the handshake
|
||||
# and after `close`.
|
||||
def connected?
|
||||
@initialized
|
||||
end
|
||||
|
||||
def send_request(request:)
|
||||
start unless @started
|
||||
initialize_session unless @initialized
|
||||
connect unless @initialized
|
||||
|
||||
write_message(request)
|
||||
read_response(request)
|
||||
|
|
@ -98,57 +195,11 @@ module MCP
|
|||
@stderr_thread.join(CLOSE_TIMEOUT)
|
||||
@started = false
|
||||
@initialized = false
|
||||
@server_info = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# The client MUST send a protocol version it supports. This SHOULD be the latest version.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
|
||||
#
|
||||
# Always sends `LATEST_STABLE_PROTOCOL_VERSION`, matching the Python and TypeScript SDKs:
|
||||
# https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/session.py#L175
|
||||
# https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/index.ts#L495
|
||||
def initialize_session
|
||||
init_request = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
id: SecureRandom.uuid,
|
||||
method: MCP::Methods::INITIALIZE,
|
||||
params: {
|
||||
protocolVersion: MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "mcp-ruby-client", version: MCP::VERSION },
|
||||
},
|
||||
}
|
||||
|
||||
write_message(init_request)
|
||||
response = read_response(init_request)
|
||||
|
||||
if response.key?("error")
|
||||
error = response["error"]
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: #{error["message"]}",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
unless response.key?("result")
|
||||
raise RequestHandlerError.new(
|
||||
"Server initialization failed: missing result in response",
|
||||
{ method: MCP::Methods::INITIALIZE },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
notification = {
|
||||
jsonrpc: JsonRpcHandler::Version::V2_0,
|
||||
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
||||
}
|
||||
write_message(notification)
|
||||
|
||||
@initialized = true
|
||||
end
|
||||
|
||||
def write_message(message)
|
||||
ensure_running!
|
||||
json = JSON.generate(message)
|
||||
|
|
|
|||
|
|
@ -722,8 +722,453 @@ module MCP
|
|||
stdout_write.close
|
||||
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_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_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
|
||||
|
||||
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 mock_wait_thread
|
||||
thread = mock("wait_thread")
|
||||
thread.stubs(:alive?).returns(true)
|
||||
|
|
|
|||
Loading…
Reference in a new issue