Finalize server/discover and Add Client Modern Lifecycle Support per SEP-2575

## Motivation and Context

Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic.

Server side, `Server#discover` reaches its spec-final shape:

- `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
  legacy versions are negotiated via `initialize`, not selected from discovery. This also closes
  an interop hazard of the interim response: auto-negotiating TS and Python clients treat
  any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`,
  so advertising only legacy versions made them fail outright.
- The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints
  on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with
  `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise.
- The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp
  instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002
  (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level
  `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and
  the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built
  against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`.

Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default:

- `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct
  (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`).
  It works before `connect`; the stdio transport's connect guard exempts `server/discover`
  and spawns the subprocess on demand.
- `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs
  (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:`
  (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover`
  and falls back to the legacy handshake on any discovery failure, including a successful discovery
  without a mutually supported modern version (rollout tolerance for servers that answer discovery
  while only serving legacy versions). Custom transports whose `connect` does not declare `mode:`
  keep receiving the historical legacy call shape, detected by keyword introspection;
  a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords
  stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError`
  instead of silently downgrading.
- The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`,
  instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried
  the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make
  the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version,
  the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here
  as `mode: :modern` with `protocol_version: "2026-07-28"`.
- An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe,
  so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle.
  `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools
  and when using server-initiated requests, which exist only on the legacy lifecycle.
- Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends
  on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference:
  `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one),
  `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta`
  `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server
  does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover.
- The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK)
  when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately,
  but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback
  instead of blocking `connect` forever. Regular requests keep the unbounded default.
- In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto
  every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names
  from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and
  keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize`
  is never sent.
- The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake
  and its scenario servers do not expect a preceding probe.

Resolves #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values);
the existing discover assertions across `server_test.rb` and both transport test files were updated to
the modern-only `supportedVersions`.

New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError`
on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`,
the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`,
`ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection,
and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence.

New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with
the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks,
and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`,
modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout
default applying only while probing, and the fallback when a server never answers the probe.

## Breaking Changes

`server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`);
this method has not shipped in a gem release yet, so no released behavior changes.

On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before
the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with
an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing
the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation
`protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable
across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
This commit is contained in:
Koichi ITO 2026-08-04 03:35:35 +09:00
commit d0d7308a89

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)