Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions lib/mcp/error_codes.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion lib/mcp/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ->(_) {},
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions test/mcp/error_codes_test.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions test/mcp/methods_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down