## Motivation and Context Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream; `subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications. The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035); the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321. Wire behavior, per the draft schema: - The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types the client did not request. - The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery (`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping; the mere presence of a primitive's capability is not enough. - Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId` (the listen request id) in `_meta`. - A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream, stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path); an abrupt disconnect sends nothing. Implementation: - `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation) and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream. Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers. The matching snapshot is taken under the transport mutex, but stream writes happen outside it, matching the legacy delivery paths: a slow or stalled subscriber must not block the transport. Duplicate subscription ids close the new stream instead of double-registering. - Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out); a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods. Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain unbounded connections. - Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds (default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream). A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed, so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism. - Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too. - stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered, so the method answers `-32601`. - `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`). - The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls (`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either. Out of scope, noted for follow-ups: an external event bus for multi-worker deployments, and the client-side listen driver (the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047). Refs #389. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover: the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses), opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, the 503 past the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot, no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no` on both the listen stream and the legacy GET stream. `test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio, and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that `server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway.
728 lines
25 KiB
Ruby
728 lines
25 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class Server
|
|
module Transports
|
|
class StdioTransportTest < ActiveSupport::TestCase
|
|
include InstrumentationTestHelper
|
|
include InitializeParamsTestHelper
|
|
|
|
setup do
|
|
configuration = MCP::Configuration.new
|
|
configuration.instrumentation_callback = instrumentation_helper.callback
|
|
@server = Server.new(name: "test_server", configuration: configuration)
|
|
@transport = StdioTransport.new(@server)
|
|
end
|
|
|
|
test "initializes with server and closed state" do
|
|
server = @transport.instance_variable_get(:@server)
|
|
assert_equal @server.object_id, server.object_id
|
|
refute @transport.instance_variable_get(:@open)
|
|
end
|
|
|
|
test "processes JSON-RPC requests from stdin and sends responses to stdout" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
method: "ping",
|
|
id: "123",
|
|
}
|
|
input = StringIO.new(JSON.generate(request) + "\n")
|
|
output = StringIO.new
|
|
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
|
|
thread = Thread.new { @transport.open }
|
|
sleep(0.1)
|
|
@transport.close
|
|
thread.join
|
|
|
|
response = JSON.parse(output.string, symbolize_names: true)
|
|
assert_equal("2.0", response[:jsonrpc])
|
|
assert_equal("123", response[:id])
|
|
assert_equal({}, response[:result])
|
|
refute(@transport.instance_variable_get(:@open))
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "sends string responses to stdout" do
|
|
output = StringIO.new
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdout = output
|
|
@transport.send_response("test response")
|
|
assert_equal("test response\n", output.string)
|
|
ensure
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "sends JSON responses to stdout" do
|
|
output = StringIO.new
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdout = output
|
|
response = { key: "value" }
|
|
@transport.send_response(response)
|
|
assert_equal(JSON.generate(response) + "\n", output.string)
|
|
ensure
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "handles valid JSON-RPC requests" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
method: "ping",
|
|
id: "123",
|
|
}
|
|
output = StringIO.new
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdout = output
|
|
@transport.send(:handle_request, JSON.generate(request))
|
|
response = JSON.parse(output.string, symbolize_names: true)
|
|
assert_equal("2.0", response[:jsonrpc])
|
|
assert_nil(response[:id])
|
|
assert_nil(response[:result])
|
|
ensure
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "open creates a ServerSession and processes requests through it" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
method: "initialize",
|
|
id: "1",
|
|
params: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "stdio-client", version: "1.0" },
|
|
},
|
|
}
|
|
input = StringIO.new(JSON.generate(request) + "\n")
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
@transport.open
|
|
|
|
# Verify a session was created.
|
|
session = @transport.instance_variable_get(:@session)
|
|
assert_instance_of(ServerSession, session)
|
|
|
|
# Verify client info was stored on the session, not on the server.
|
|
assert_equal({ name: "stdio-client", version: "1.0" }, session.client)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "open handles a malformed JSON-RPC batch element and emits an error response" do
|
|
input = StringIO.new("[[]]\n")
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
@transport.open
|
|
|
|
response = JSON.parse(output.string, symbolize_names: true)
|
|
assert_nil(response[:id])
|
|
assert_equal(-32600, response[:error][:code])
|
|
assert_equal("Invalid Request", response[:error][:message])
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "rejects duplicate initialize on the same stdio session with -32600" do
|
|
first = {
|
|
jsonrpc: "2.0",
|
|
method: "initialize",
|
|
id: "first",
|
|
params: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "original", version: "1.0" },
|
|
},
|
|
}
|
|
second = {
|
|
jsonrpc: "2.0",
|
|
method: "initialize",
|
|
id: "second",
|
|
params: {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: {},
|
|
clientInfo: { name: "intruder", version: "9.9" },
|
|
},
|
|
}
|
|
input = StringIO.new("#{JSON.generate(first)}\n#{JSON.generate(second)}\n")
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
@transport.open
|
|
|
|
lines = output.string.lines
|
|
assert_equal(2, lines.length)
|
|
first_response = JSON.parse(lines[0], symbolize_names: true)
|
|
second_response = JSON.parse(lines[1], symbolize_names: true)
|
|
|
|
assert_equal("first", first_response[:id])
|
|
refute_nil(first_response[:result])
|
|
|
|
assert_equal("second", second_response[:id])
|
|
assert_equal(-32600, second_response[:error][:code])
|
|
assert_equal("Invalid Request", second_response[:error][:message])
|
|
|
|
session = @transport.instance_variable_get(:@session)
|
|
assert_equal({ name: "original", version: "1.0" }, session.client)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "handles invalid JSON requests" do
|
|
invalid_json = "invalid json"
|
|
output = StringIO.new
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdout = output
|
|
@transport.send(:handle_request, invalid_json)
|
|
response = JSON.parse(output.string, symbolize_names: true)
|
|
assert_equal("2.0", response[:jsonrpc])
|
|
assert_nil(response[:id])
|
|
assert_equal(-32600, response[:error][:code])
|
|
assert_equal("Invalid Request", response[:error][:message])
|
|
assert_equal("Request must be an array or a hash", response[:error][:data])
|
|
ensure
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "send_request sends request to stdout and waits for response" do
|
|
reader, writer = IO.pipe
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = reader
|
|
$stdout = output
|
|
@transport.instance_variable_set(:@open, true)
|
|
|
|
# Send response from client in a thread.
|
|
Thread.new do
|
|
sleep(0.05) # Wait for request to be written to `StringIO`.
|
|
request = JSON.parse(output.string.lines.first, symbolize_names: true)
|
|
response = {
|
|
jsonrpc: "2.0",
|
|
id: request[:id],
|
|
result: { content: "test response" },
|
|
}
|
|
writer.puts(response.to_json)
|
|
writer.flush
|
|
end
|
|
|
|
result = @transport.send_request("test/method", { param: "value" })
|
|
|
|
assert_equal({ content: "test response" }, result)
|
|
|
|
# Verify request was sent.
|
|
request = JSON.parse(output.string.lines.first, symbolize_names: true)
|
|
assert_equal("2.0", request[:jsonrpc])
|
|
assert_equal("test/method", request[:method])
|
|
assert_equal({ param: "value" }, request[:params])
|
|
assert(request[:id])
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
begin
|
|
writer.close
|
|
rescue
|
|
nil
|
|
end
|
|
begin
|
|
reader.close
|
|
rescue
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "send_request raises on error response from client" do
|
|
reader, writer = IO.pipe
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = reader
|
|
$stdout = output
|
|
@transport.instance_variable_set(:@open, true)
|
|
|
|
Thread.new do
|
|
sleep(0.05) # Wait for request to be written to `StringIO`.
|
|
request = JSON.parse(output.string.lines.first, symbolize_names: true)
|
|
error_response = {
|
|
jsonrpc: "2.0",
|
|
id: request[:id],
|
|
error: { code: -1, message: "User rejected sampling request" },
|
|
}
|
|
writer.puts(error_response.to_json)
|
|
writer.flush
|
|
end
|
|
|
|
error = assert_raises(StandardError) do
|
|
@transport.send_request("sampling/createMessage", { messages: [] })
|
|
end
|
|
|
|
assert_equal("Client returned an error for sampling/createMessage request (code: -1): User rejected sampling request", error.message)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
begin
|
|
writer.close
|
|
rescue
|
|
nil
|
|
end
|
|
begin
|
|
reader.close
|
|
rescue
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "send_request does not double-report intentional raises via exception_reporter" do
|
|
reader, writer = IO.pipe
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
reported_errors = []
|
|
original_reporter = MCP.configuration.exception_reporter
|
|
|
|
begin
|
|
MCP.configuration.exception_reporter = ->(e, ctx) { reported_errors << [e, ctx] }
|
|
$stdin = reader
|
|
$stdout = output
|
|
@transport.instance_variable_set(:@open, true)
|
|
|
|
Thread.new do
|
|
sleep(0.05) # Wait for request to be written to `StringIO`.
|
|
request = JSON.parse(output.string.lines.first, symbolize_names: true)
|
|
error_response = {
|
|
jsonrpc: "2.0",
|
|
id: request[:id],
|
|
error: { code: -1, message: "rejected" },
|
|
}
|
|
writer.puts(error_response.to_json)
|
|
writer.flush
|
|
end
|
|
|
|
assert_raises(StandardError) do
|
|
@transport.send_request("sampling/createMessage", { messages: [] })
|
|
end
|
|
|
|
assert_empty(reported_errors)
|
|
ensure
|
|
MCP.configuration.exception_reporter = original_reporter
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
begin
|
|
writer.close
|
|
rescue
|
|
nil
|
|
end
|
|
begin
|
|
reader.close
|
|
rescue
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "send_request processes interleaved requests via session" do
|
|
reader, writer = IO.pipe
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = reader
|
|
$stdout = output
|
|
@transport.instance_variable_set(:@open, true)
|
|
|
|
# Initialize a session so @session is set.
|
|
session = MCP::ServerSession.new(server: @server, transport: @transport)
|
|
@transport.instance_variable_set(:@session, session)
|
|
|
|
Thread.new do
|
|
sleep(0.05) # Wait for request to be written to `StringIO`.
|
|
request = JSON.parse(output.string.lines.first, symbolize_names: true)
|
|
|
|
# Send an interleaved ping request before the response.
|
|
ping = { jsonrpc: "2.0", method: "ping", id: "ping-1" }
|
|
writer.puts(ping.to_json)
|
|
writer.flush
|
|
|
|
sleep(0.05) # Wait for the ping to be processed.
|
|
|
|
# Then send the actual response.
|
|
response = {
|
|
jsonrpc: "2.0",
|
|
id: request[:id],
|
|
result: { content: "done" },
|
|
}
|
|
writer.puts(response.to_json)
|
|
writer.flush
|
|
end
|
|
|
|
result = @transport.send_request("test/method", { param: "value" })
|
|
|
|
assert_equal({ content: "done" }, result)
|
|
|
|
# Verify the interleaved ping was handled (response sent to output).
|
|
lines = output.string.lines
|
|
ping_response = lines.find { |l| l.include?("ping-1") }
|
|
assert(ping_response, "Interleaved ping request should have been handled")
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
begin
|
|
writer.close
|
|
rescue
|
|
nil
|
|
end
|
|
begin
|
|
reader.close
|
|
rescue
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "send_request raises when transport is closed while waiting" do
|
|
reader, writer = IO.pipe
|
|
output = StringIO.new
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = reader
|
|
$stdout = output
|
|
@transport.instance_variable_set(:@open, true)
|
|
|
|
# Close transport while waiting for response.
|
|
Thread.new do
|
|
sleep(0.05) # Wait for request to be written to `StringIO`.
|
|
@transport.instance_variable_set(:@open, false)
|
|
writer.close
|
|
end
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
@transport.send_request("sampling/createMessage", { messages: [] })
|
|
end
|
|
|
|
assert_equal("Transport closed while waiting for response to sampling/createMessage request.", error.message)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
begin
|
|
writer.close
|
|
rescue IOError
|
|
nil
|
|
end
|
|
begin
|
|
reader.close
|
|
rescue IOError
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
test "raises ArgumentError when max_line_bytes is not a positive Integer" do
|
|
[nil, 0, -1, 1.5, "1024"].each do |invalid|
|
|
error = assert_raises(ArgumentError) do
|
|
StdioTransport.new(@server, max_line_bytes: invalid)
|
|
end
|
|
assert_equal("max_line_bytes must be a positive Integer", error.message)
|
|
end
|
|
end
|
|
|
|
test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do
|
|
transport = StdioTransport.new(@server, max_line_bytes: 64)
|
|
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
|
|
output = StringIO.new
|
|
|
|
reported = []
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
original_reporter = MCP.configuration.exception_reporter
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] }
|
|
|
|
transport.open
|
|
|
|
refute(transport.instance_variable_get(:@open))
|
|
assert_equal(1, reported.size)
|
|
error, _ctx = reported.first
|
|
assert_instance_of(RequestHandlerError, error)
|
|
assert_match(/exceeds 64 bytes without a newline/, error.message)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
MCP.configuration.exception_reporter = original_reporter
|
|
end
|
|
end
|
|
|
|
test "open processes a valid final frame that ends at EOF without a trailing newline" do
|
|
request = { jsonrpc: "2.0", method: "ping", id: "eof" }
|
|
input = StringIO.new(JSON.generate(request)) # No trailing newline.
|
|
output = StringIO.new
|
|
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
|
|
@transport.open
|
|
|
|
response = JSON.parse(output.string, symbolize_names: true)
|
|
assert_equal("eof", response[:id])
|
|
assert_equal({}, response[:result])
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "send_request raises when a response frame exceeds max_line_bytes without a newline" do
|
|
transport = StdioTransport.new(@server, max_line_bytes: 64)
|
|
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
|
|
output = StringIO.new
|
|
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
transport.instance_variable_set(:@open, true)
|
|
|
|
error = assert_raises(RequestHandlerError) do
|
|
transport.send_request("sampling/createMessage", { messages: [] })
|
|
end
|
|
|
|
assert_match(/exceeds 64 bytes without a newline/, error.message)
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
end
|
|
|
|
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
|
|
responses = run_transport_session([
|
|
initialize_request(id: 1),
|
|
modern_tools_list_request(id: 2),
|
|
])
|
|
|
|
refute responses[0].key?(:error)
|
|
assert_equal :legacy, session_era
|
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
|
end
|
|
|
|
test "initialize negotiating 2026-07-28 still locks the legacy era" do
|
|
# 2026-07-28 serves both lifecycles of the dual-era model: negotiating it through
|
|
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
|
|
responses = run_transport_session([
|
|
initialize_request(id: 1, protocol_version: "2026-07-28"),
|
|
modern_tools_list_request(id: 2),
|
|
])
|
|
|
|
assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion)
|
|
assert_equal :legacy, session_era
|
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
|
end
|
|
|
|
test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do
|
|
responses = run_transport_session([
|
|
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
|
|
initialize_request(id: 2),
|
|
modern_tools_list_request(id: 3),
|
|
])
|
|
|
|
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
|
|
assert_equal :modern, session_era
|
|
# SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection
|
|
# answers it with Method not found.
|
|
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code)
|
|
refute responses[2].key?(:error)
|
|
end
|
|
|
|
test "locks the modern era on a successful request carrying the modern envelope" do
|
|
responses = run_transport_session([modern_tools_list_request(id: 1)])
|
|
|
|
refute responses[0].key?(:error)
|
|
assert_equal :modern, session_era
|
|
end
|
|
|
|
test "does not lock an era when the era-distinctive request fails" do
|
|
# An unsupported envelope version fails with -32022, so the connection stays unlocked
|
|
# and a legacy initialize can still succeed afterwards.
|
|
responses = run_transport_session([
|
|
modern_tools_list_request(id: 1, version: "2027-01-01"),
|
|
initialize_request(id: 2),
|
|
])
|
|
|
|
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[0].dig(:error, :code)
|
|
refute responses[1].key?(:error)
|
|
assert_equal :legacy, session_era
|
|
end
|
|
|
|
test "requires the modern envelope after a modern era lock" do
|
|
responses = run_transport_session([
|
|
modern_tools_list_request(id: 1),
|
|
{ jsonrpc: "2.0", method: "tools/list", id: 2 },
|
|
])
|
|
|
|
refute responses[0].key?(:error)
|
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
|
|
end
|
|
|
|
test "answers removed lifecycle methods with -32601 after a modern era lock" do
|
|
removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index|
|
|
{ jsonrpc: "2.0", method: method, id: index + 2 }
|
|
end
|
|
responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests)
|
|
|
|
refute responses[0].key?(:error)
|
|
responses[1..].each_with_index do |response, index|
|
|
method = Methods::MODERN_REMOVED_METHODS[index]
|
|
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
|
|
end
|
|
end
|
|
|
|
test "subscriptions/listen is not served over stdio and answers -32601" do
|
|
# Like the Python SDK, the stream-pair transport does not serve the SEP-2575 notification subscription stream.
|
|
listen = {
|
|
jsonrpc: "2.0",
|
|
method: "subscriptions/listen",
|
|
id: "listen-1",
|
|
params: {
|
|
notifications: { toolsListChanged: true },
|
|
_meta: {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
},
|
|
},
|
|
}
|
|
|
|
responses = run_transport_session([listen])
|
|
|
|
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
|
|
end
|
|
|
|
test "#send_request raises on a modern-locked session" do
|
|
run_transport_session([modern_tools_list_request(id: 1)])
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
@transport.send_request("roots/list")
|
|
end
|
|
assert_match(/modern lifecycle/, error.message)
|
|
end
|
|
|
|
private
|
|
|
|
def initialize_request(id:, protocol_version: "2025-11-25")
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: "initialize",
|
|
id: id,
|
|
params: initialize_params(
|
|
protocolVersion: protocol_version,
|
|
clientInfo: { name: "legacy_client", version: "1.0" },
|
|
),
|
|
}
|
|
end
|
|
|
|
# `tools/list` stands in as the era-distinctive modern request: `ping` cannot,
|
|
# because SEP-2575 removes it from the modern lifecycle (-32601 there).
|
|
def modern_tools_list_request(id:, version: "2026-07-28")
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: "tools/list",
|
|
id: id,
|
|
params: {
|
|
_meta: {
|
|
"io.modelcontextprotocol/protocolVersion": version,
|
|
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
# Feeds the frames to a fresh transport session over swapped stdio and returns the parsed responses in order.
|
|
def run_transport_session(frames)
|
|
input = StringIO.new(frames.map { |frame| JSON.generate(frame) }.join("\n") + "\n")
|
|
output = StringIO.new
|
|
|
|
original_stdin = $stdin
|
|
original_stdout = $stdout
|
|
|
|
begin
|
|
$stdin = input
|
|
$stdout = output
|
|
|
|
thread = Thread.new { @transport.open }
|
|
sleep(0.1)
|
|
@transport.close
|
|
thread.join
|
|
ensure
|
|
$stdin = original_stdin
|
|
$stdout = original_stdout
|
|
end
|
|
|
|
output.string.each_line.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
end
|
|
|
|
def session_era
|
|
@transport.instance_variable_get(:@session).era
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|