From c145876bb9a6b8f5e76d48b310ab474bceeb4242 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Jul 2026 02:27:54 +0900 Subject: [PATCH] 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. --- README.md | 2 + lib/mcp.rb | 1 + lib/mcp/error_codes.rb | 21 ++++++++ lib/mcp/methods.rb | 4 +- lib/mcp/server.rb | 17 +++++++ .../transports/streamable_http_transport.rb | 8 +++- test/mcp/error_codes_test.rb | 13 +++++ test/mcp/methods_test.rb | 2 + .../streamable_http_transport_test.rb | 35 ++++++++++++++ test/mcp/server_test.rb | 48 +++++++++++++++++++ 10 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 lib/mcp/error_codes.rb create mode 100644 test/mcp/error_codes_test.rb diff --git a/README.md b/README.md index b986c262..296bd8d0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ It implements the Model Context Protocol specification, handling model context r ### Supported Methods - `initialize` - Initializes the protocol and returns server capabilities +- `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, + and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/lib/mcp.rb b/lib/mcp.rb index 867eb02e..97a81f9d 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -12,6 +12,7 @@ module MCP autoload :CancelledError, "mcp/cancelled_error" autoload :Client, "mcp/client" autoload :Content, "mcp/content" + autoload :ErrorCodes, "mcp/error_codes" autoload :Icon, "mcp/icon" autoload :Prompt, "mcp/prompt" autoload :Resource, "mcp/resource" diff --git a/lib/mcp/error_codes.rb b/lib/mcp/error_codes.rb new file mode 100644 index 00000000..09bb6fd3 --- /dev/null +++ b/lib/mcp/error_codes.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module MCP + # MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`. + # + # Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575): + # `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not + # support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY` + # rejects a request that requires a client capability the request did not declare + # (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise + # these codes itself yet. + # + # The values come from the spec's MCP-specific error code block, which is allocated sequentially from + # `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here. + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + module ErrorCodes + MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 + UNSUPPORTED_PROTOCOL_VERSION = -32022 + end +end diff --git a/lib/mcp/methods.rb b/lib/mcp/methods.rb index 9f1a935e..d256ab17 100644 --- a/lib/mcp/methods.rb +++ b/lib/mcp/methods.rb @@ -5,6 +5,8 @@ module Methods INITIALIZE = "initialize" PING = "ping" LOGGING_SET_LEVEL = "logging/setLevel" + # Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575). + SERVER_DISCOVER = "server/discover" PROMPTS_GET = "prompts/get" PROMPTS_LIST = "prompts/list" @@ -81,7 +83,7 @@ def ensure_capability!(method, capabilities) require_capability!(method, capabilities, :sampling) when ELICITATION_CREATE require_capability!(method, capabilities, :elicitation) - when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, + when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE # No specific capability required. end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a2556401..7bbcdf97 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -163,6 +163,7 @@ def initialize( Methods::PROMPTS_LIST => method(:list_prompts), Methods::PROMPTS_GET => method(:get_prompt), Methods::INITIALIZE => method(:init), + Methods::SERVER_DISCOVER => method(:discover), Methods::PING => ->(_) { {} }, Methods::NOTIFICATIONS_INITIALIZED => ->(_) {}, Methods::NOTIFICATIONS_PROGRESS => ->(_) {}, @@ -592,6 +593,22 @@ def init(params, session: nil) }.compact end + # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery. + # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark + # the session initialized, and responds regardless of capability declarations or initialization state, + # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered + # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints + # are not included here yet. + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 + def discover(_request) + { + supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, + capabilities: capabilities, + serverInfo: server_info, + instructions: instructions, + }.compact + end + def configure_logging_level(request, session: nil) if capabilities[:logging].nil? raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4b8f8ad7..b70872ab 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -359,7 +359,9 @@ def handle_post(request) # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # This matches the TypeScript and Python SDKs. - unless initialize_request?(body) + # `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that + # happens before (or instead of) negotiation. + unless initialize_request?(body) || discover_request?(body) return missing_session_id_response if !@stateless && !session_id protocol_version_error = validate_protocol_version_header(request) @@ -552,6 +554,10 @@ def initialize_request?(body) body.is_a?(Hash) && body[:method] == Methods::INITIALIZE end + def discover_request?(body) + body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER + end + def validate_protocol_version_header(request) header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) diff --git a/test/mcp/error_codes_test.rb b/test/mcp/error_codes_test.rb new file mode 100644 index 00000000..f00da6a6 --- /dev/null +++ b/test/mcp/error_codes_test.rb @@ -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 diff --git a/test/mcp/methods_test.rb b/test/mcp/methods_test.rb index 1be8cc64..c6ae11a5 100644 --- a/test/mcp/methods_test.rb +++ b/test/mcp/methods_test.rb @@ -24,6 +24,8 @@ def ensure_capability_does_not_raise_for(method, capabilities: {}) # 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" diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9b26949a..0382426c 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -551,6 +551,41 @@ def string 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( diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 22eed192..1f2e621d 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -123,6 +123,54 @@ class ServerTest < ActiveSupport::TestCase 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",