Merge pull request #480 from koic/discover_finalize_and_client

Finalize `server/discover` and Add Client Modern Lifecycle Support per SEP-2575
This commit is contained in:
Koichi ITO 2026-08-08 15:04:54 +09:00 • committed by GitHub
commit 4ed461c72f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1203 additions and 151 deletions

View file

@ -1844,6 +1844,109 @@ module MCP
refute_predicate(client, :connected?)
end
def test_connect_modern_probes_discover_and_adopts_the_modern_lifecycle
discover_stub = stub_discover
result = client.connect(mode: :modern)
assert_requested(discover_stub)
assert_predicate(client, :connected?)
assert_predicate(client, :modern?)
assert_equal(["2026-07-28"], result["supportedVersions"])
assert_equal(result, client.server_info)
assert_equal("2026-07-28", client.protocol_version)
end
def test_modern_requests_carry_the_envelope_and_matching_header
stub_discover
client.connect(
mode: :modern,
client_info: { name: "my-app", version: "9.9" },
capabilities: { elicitation: {} },
)
request_stub = stub_request(:post, url).with do |req|
body = JSON.parse(req.body)
meta = body.dig("params", "_meta")
body["method"] == "tools/list" &&
req.headers["Mcp-Protocol-Version"] == "2026-07-28" &&
meta == {
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
"io.modelcontextprotocol/clientInfo" => { "name" => "my-app", "version" => "9.9" },
"io.modelcontextprotocol/clientCapabilities" => { "elicitation" => {} },
}
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { tools: [] } }.to_json,
)
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_requested(request_stub)
end
def test_connect_modern_fails_without_a_mutual_modern_version
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
)
error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) }
assert_includes(error.message, "no mutually supported modern protocol version")
refute_predicate(client, :connected?)
refute_predicate(client, :modern?)
end
def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { error: { code: -32601, message: "Method not found" } }.to_json,
)
init_stub = stub_initialize
notification_stub = stub_notification
result = client.connect(mode: :auto)
assert_requested(init_stub)
assert_requested(notification_stub)
refute_predicate(client, :modern?)
assert_predicate(client, :connected?)
assert_equal("2025-11-25", result["protocolVersion"])
end
def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version
# Rollout tolerance: a server may answer discovery while only serving legacy versions;
# auto mode falls back to the legacy handshake.
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { supportedVersions: ["2025-11-25"] } }.to_json,
)
init_stub = stub_initialize
stub_notification
client.connect(mode: :auto)
assert_requested(init_stub)
refute_predicate(client, :modern?)
assert_predicate(client, :connected?)
end
def test_connect_rejects_unknown_modes_and_legacy_versions_for_modern_mode
assert_raises(ArgumentError) { client.connect(mode: :bogus) }
assert_raises(ArgumentError) { client.connect(mode: :modern, protocol_version: "2025-11-25") }
end
def test_reconnect_after_close
stub_initialize
stub_notification
@ -1949,6 +2052,24 @@ module MCP
.to_return(status: 202, body: "")
end
def stub_discover
stub_request(:post, url).with do |req|
JSON.parse(req.body)["method"] == "server/discover"
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: {
result: {
supportedVersions: ["2026-07-28"],
capabilities: { tools: {} },
_meta: { "io.modelcontextprotocol/serverInfo": { name: "test-server", version: "1.0" } },
ttlMs: 0,
cacheScope: "private",
},
}.to_json,
)
end
def stub_request(method, url)
WebMock.stub_request(method, url)
end

View file

@ -1414,6 +1414,198 @@ module MCP
[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_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)

View file

@ -85,6 +85,218 @@ module MCP
assert_includes(error.message, "does not support server-to-client requests")
end
# A transport that declares `mode:` on `connect`, recording what it received.
# The `:not_forwarded` sentinel distinguishes "Client did not forward mode" from
# "Client forwarded mode: :legacy".
class ModeAwareTransport
attr_reader :received
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :not_forwarded)
@received = {
client_info: client_info,
protocol_version: protocol_version,
capabilities: capabilities,
mode: mode,
}
{ "supportedVersions" => ["2026-07-28"] }
end
end
def test_connect_defaults_to_auto_on_transports_declaring_mode
transport = ModeAwareTransport.new
Client.new(transport: transport).connect
assert_equal(:auto, transport.received[:mode])
end
def test_connect_keeps_the_legacy_call_shape_for_transports_without_mode
# Custom transports whose `connect` does not declare `mode:` keep receiving
# the historical call shape, so the `:auto` default cannot break them.
transport = mock
transport.expects(:connect).with(
client_info: nil, protocol_version: nil, capabilities: {},
).returns({ "protocolVersion" => "2025-11-25" }).once
Client.new(transport: transport).connect
end
def test_connect_routes_explicit_legacy_through_the_legacy_call_shape
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(mode: :legacy)
assert_equal(:not_forwarded, transport.received[:mode])
end
def test_connect_pins_legacy_for_an_explicit_stable_protocol_version
# An explicitly requested legacy-generation version must never be overridden
# by the default negotiation adopting the modern lifecycle.
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(protocol_version: "2025-11-25")
assert_equal(:not_forwarded, transport.received[:mode])
assert_equal("2025-11-25", transport.received[:protocol_version])
end
def test_connect_negotiates_automatically_for_an_explicit_modern_protocol_version
transport = ModeAwareTransport.new
Client.new(transport: transport).connect(protocol_version: "2026-07-28")
assert_equal(:auto, transport.received[:mode])
end
def test_connect_raises_for_explicit_non_legacy_mode_on_a_transport_without_mode
transport = mock
transport.expects(:connect).never
error = assert_raises(ArgumentError) do
Client.new(transport: transport).connect(mode: :modern)
end
assert_includes(error.message, "does not support mode:")
end
def test_connect_rejects_unknown_modes
transport = ModeAwareTransport.new
assert_raises(ArgumentError) do
Client.new(transport: transport).connect(mode: :bogus)
end
end
def test_discover_returns_a_discover_result
transport = mock
transport.expects(:send_request).with do |args|
args[:request][:method] == "server/discover"
end.returns({
"result" => {
"supportedVersions" => ["2026-07-28"],
"capabilities" => { "tools" => {} },
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "test-server", "version" => "1.0" } },
"instructions" => "Optional instructions",
"ttlMs" => 3_600_000,
"cacheScope" => "public",
},
}).once
result = Client.new(transport: transport).discover
assert_equal(["2026-07-28"], result.supported_versions)
assert_equal({ "tools" => {} }, result.capabilities)
assert_equal({ "name" => "test-server", "version" => "1.0" }, result.server_info)
assert_equal("Optional instructions", result.instructions)
assert_equal(3_600_000, result.ttl_ms)
assert_equal("public", result.cache_scope)
end
def test_discover_maps_a_top_level_server_info_as_a_fallback
# Servers built against the frozen SEP text still stamp `serverInfo` at the top level;
# the finalized spec moved it into the result `_meta`.
transport = mock
transport.expects(:send_request).returns({
"result" => {
"supportedVersions" => ["2026-07-28"],
"serverInfo" => { "name" => "sep-era-server", "version" => "1.0" },
},
}).once
result = Client.new(transport: transport).discover
assert_equal({ "name" => "sep-era-server", "version" => "1.0" }, result.server_info)
end
def test_discover_raises_validation_error_on_missing_result
transport = mock
transport.expects(:send_request).returns({}).once
assert_raises(Client::ValidationError) do
Client.new(transport: transport).discover
end
end
def test_discover_raises_server_error_on_jsonrpc_error
transport = mock
transport.expects(:send_request).returns({
"error" => { "code" => -32601, "message" => "Method not found" },
}).once
error = assert_raises(Client::ServerError) do
Client.new(transport: transport).discover
end
assert_equal(-32601, error.code)
end
def test_era_independent_readers_on_a_legacy_result
transport = mock
transport.stubs(:server_info).returns({
"protocolVersion" => "2025-11-25",
"capabilities" => { "tools" => {} },
"serverInfo" => { "name" => "legacy-server", "version" => "1.0" },
"instructions" => "Be helpful",
})
client = Client.new(transport: transport)
assert_equal("2025-11-25", client.protocol_version)
assert_equal({ "tools" => {} }, client.server_capabilities)
assert_equal("Be helpful", client.instructions)
assert_equal({ "name" => "legacy-server", "version" => "1.0" }, client.server_implementation)
end
def test_era_independent_readers_on_a_modern_result
# A modern `DiscoverResult` has no `protocolVersion`; the adopted version comes
# from the transport. `serverInfo` sits in the result `_meta` per the final spec.
transport = mock
transport.stubs(:protocol_version).returns("2026-07-28")
transport.stubs(:server_info).returns({
"supportedVersions" => ["2026-07-28"],
"capabilities" => { "tools" => {} },
"instructions" => "Be helpful",
"_meta" => { "io.modelcontextprotocol/serverInfo" => { "name" => "modern-server", "version" => "2.0" } },
"ttlMs" => 0,
"cacheScope" => "private",
})
client = Client.new(transport: transport)
assert_equal("2026-07-28", client.protocol_version)
assert_equal({ "tools" => {} }, client.server_capabilities)
assert_equal("Be helpful", client.instructions)
assert_equal({ "name" => "modern-server", "version" => "2.0" }, client.server_implementation)
end
def test_server_implementation_falls_back_to_the_top_level_and_tolerates_absence
transport = mock
transport.stubs(:server_info).returns({
"supportedVersions" => ["2026-07-28"],
"serverInfo" => { "name" => "top-level", "version" => "1.0" },
})
assert_equal({ "name" => "top-level", "version" => "1.0" }, Client.new(transport: transport).server_implementation)
anonymous = mock
anonymous.stubs(:server_info).returns({ "supportedVersions" => ["2026-07-28"] })
assert_nil(Client.new(transport: anonymous).server_implementation)
end
def test_era_independent_readers_return_nil_before_connect
transport = mock
transport.stubs(:respond_to?).with(:server_info).returns(false)
transport.stubs(:respond_to?).with(:protocol_version).returns(false)
client = Client.new(transport: transport)
assert_nil(client.protocol_version)
assert_nil(client.server_capabilities)
assert_nil(client.instructions)
assert_nil(client.server_implementation)
end
def test_connected_delegates_to_transport_when_supported
transport = mock
transport.expects(:connected?).returns(true)

View file

@ -583,7 +583,7 @@ module MCP
modern_ping_request(id: 3),
])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal :modern, session_era
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)

View file

@ -660,7 +660,7 @@ module MCP
assert_equal 200, response[0]
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
refute response[1].key?("mcp-session-id")
end
@ -678,7 +678,7 @@ module MCP
assert_equal 200, response[0]
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
end
test "rejects duplicate SSE connection with 409" do
@ -5426,7 +5426,7 @@ module MCP
assert_equal 200, response[0]
refute response[1].key?("mcp-session-id")
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
end
test "legacy POST rejects a modern envelope body without the modern header as -32020" do

View file

@ -129,10 +129,13 @@ module MCP
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
result = response[:result]
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, result[:supportedVersions]
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions]
assert_equal @server.capabilities, result[:capabilities]
assert_equal @server_name, result.dig(:serverInfo, :name)
assert_equal "1.2.3", result.dig(:serverInfo, :version)
# Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp,
# not a top-level `serverInfo` field.
assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name)
assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version)
refute result.key?(:serverInfo)
assert_equal "Optional instructions for the client", result[:instructions]
end
@ -142,7 +145,7 @@ module MCP
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
end
test "#handle server/discover does not mark the session initialized" do
@ -167,6 +170,26 @@ module MCP
refute response[:result].key?(:instructions)
end
test "#handle server/discover always carries the required cache hints with spec defaults" do
# Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope`
# are REQUIRED on `DiscoverResult`.
server = Server.new(name: "discover_test")
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
assert_equal 0, result[:ttlMs]
assert_equal "private", result[:cacheScope]
end
test "#handle server/discover reuses the configured SEP-2549 cache hints" do
server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public")
result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result]
assert_equal 3_600_000, result[:ttlMs]
assert_equal "public", result[:cacheScope]
end
test "#define_custom_method raises for server/discover" do
assert_raises(Server::MethodAlreadyDefinedError) do
@server.define_custom_method(method_name: "server/discover") { {} }