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:
Koichi ITO 2026-07-04 02:27:54 +09:00
commit c145876bb9
10 changed files with 149 additions and 2 deletions

View file

@ -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

View file

@ -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"

21
lib/mcp/error_codes.rb Normal file
View file

@ -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

View file

@ -5,6 +5,8 @@ module MCP
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 @@ module MCP
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

View file

@ -163,6 +163,7 @@ module MCP
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 @@ module MCP
}.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)

View file

@ -359,7 +359,9 @@ module MCP
# 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 @@ module MCP
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)

View 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

View file

@ -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"

View file

@ -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(

View file

@ -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",