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
5 changed files with 149 additions and 2 deletions
|
|
@ -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
21
lib/mcp/error_codes.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue