Add server/discover and stateless lifecycle error codes per SEP-2575
## Motivation and Context SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration and are purely additive: - The `server/discover` method, which servers MUST implement: sessionless capability discovery returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`. The new `Server#discover` handler reuses the same data `init` already builds, but is state-free and idempotent: it stores no client info, never marks a session initialized, and responds regardless of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`). `serverInfo` is returned unfiltered because discovery precedes version negotiation; the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work. On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with a bare POST in both stateful and stateless modes. - The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003` and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet (that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of `URLElicitationRequiredError` is intentionally left untouched. The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25, where those methods are required. Part of #389. ## How Has This Been Tested? - `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities, server info, and instructions; it responds before `initialize` and with empty capabilities; it does not mark the session initialized (a subsequent `initialize` still succeeds); `instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")` now raises `MethodAlreadyDefinedError`. - `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities. - `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`. - New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None at the wire level for existing clients. One narrow API consequence: servers that previously registered their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because the method is built in.
This commit is contained in:
parent
8b1a41aedd
commit
c145876bb9
4 changed files with 149 additions and 2 deletions
13
test/mcp/error_codes_test.rb
Normal file
13
test/mcp/error_codes_test.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
class ErrorCodesTest < ActiveSupport::TestCase
|
||||
test "exposes the SEP-2575 stateless lifecycle error codes" do
|
||||
# The exact values are wire vocabulary shared with other SDKs.
|
||||
assert_equal(-32021, ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY)
|
||||
assert_equal(-32022, ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -24,6 +24,8 @@ module MCP
|
|||
|
||||
# Server methods and notifications
|
||||
ensure_capability_does_not_raise_for Methods::INITIALIZE
|
||||
# `server/discover` (SEP-2575) must respond regardless of declared capabilities.
|
||||
ensure_capability_does_not_raise_for Methods::SERVER_DISCOVER
|
||||
|
||||
ensure_capability_raises_error_for Methods::PROMPTS_LIST, required_capability_name: "prompts"
|
||||
ensure_capability_raises_error_for Methods::PROMPTS_GET, required_capability_name: "prompts"
|
||||
|
|
|
|||
|
|
@ -551,6 +551,41 @@ module MCP
|
|||
assert_equal 200, response[0]
|
||||
end
|
||||
|
||||
test "allows server/discover POST without session ID in stateful mode" do
|
||||
# Per SEP-2575, `server/discover` is sessionless capability discovery and is exempt
|
||||
# from the session requirement, like `initialize`.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json,
|
||||
)
|
||||
|
||||
response = @transport.handle_request(request)
|
||||
|
||||
assert_equal 200, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
refute response[1].key?("Mcp-Session-Id")
|
||||
end
|
||||
|
||||
test "allows server/discover POST without session ID in stateless mode" do
|
||||
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "server/discover", id: "1" }.to_json,
|
||||
)
|
||||
|
||||
response = stateless_transport.handle_request(request)
|
||||
|
||||
assert_equal 200, response[0]
|
||||
body = JSON.parse(response[2][0])
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||
end
|
||||
|
||||
test "rejects duplicate SSE connection with 409" do
|
||||
# Create a session
|
||||
init_request = create_rack_request(
|
||||
|
|
|
|||
|
|
@ -123,6 +123,54 @@ module MCP
|
|||
assert_instrumentation_data({ method: "ping" })
|
||||
end
|
||||
|
||||
test "#handle server/discover returns supported versions, capabilities, server info, and instructions" do
|
||||
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 @server.capabilities, result[:capabilities]
|
||||
assert_equal @server_name, result.dig(:serverInfo, :name)
|
||||
assert_equal "1.2.3", result.dig(:serverInfo, :version)
|
||||
assert_equal "Optional instructions for the client", result[:instructions]
|
||||
end
|
||||
|
||||
test "#handle server/discover responds before initialize and regardless of capabilities" do
|
||||
# Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate.
|
||||
server = Server.new(name: "discover_test", capabilities: {})
|
||||
|
||||
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
|
||||
|
||||
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, response.dig(:result, :supportedVersions)
|
||||
end
|
||||
|
||||
test "#handle server/discover does not mark the session initialized" do
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
@server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session)
|
||||
|
||||
refute_predicate session, :initialized?
|
||||
# `initialize` must still succeed afterwards.
|
||||
response = @server.handle(
|
||||
{ jsonrpc: "2.0", method: "initialize", id: 2, params: { clientInfo: { name: "c", version: "1" } } },
|
||||
session: session,
|
||||
)
|
||||
refute_nil response[:result]
|
||||
end
|
||||
|
||||
test "#handle server/discover omits instructions when the server has none" do
|
||||
server = Server.new(name: "discover_test")
|
||||
|
||||
response = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
|
||||
|
||||
refute response[:result].key?(:instructions)
|
||||
end
|
||||
|
||||
test "#define_custom_method raises for server/discover" do
|
||||
assert_raises(Server::MethodAlreadyDefinedError) do
|
||||
@server.define_custom_method(method_name: "server/discover") { {} }
|
||||
end
|
||||
end
|
||||
|
||||
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue