ruby-sdk/test/mcp/methods_test.rb
Koichi ITO c145876bb9 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.
2026-07-04 20:24:37 +09:00

85 lines
4.7 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
require "mcp/methods"
module MCP
class MethodsTest < ActiveSupport::TestCase
class << self
def ensure_capability_raises_error_for(method, required_capability_name:, capabilities: {})
test("ensure_capability! for #{method} raises an error if #{required_capability_name} capability is not present") do
error = assert_raises(Methods::MissingRequiredCapabilityError) do
Methods.ensure_capability!(method, capabilities)
end
assert_equal("Server does not support #{required_capability_name} (required for #{method})", error.message)
end
end
def ensure_capability_does_not_raise_for(method, capabilities: {})
test("ensure_capability! does not raise for #{method}") do
assert_nothing_raised { Methods.ensure_capability!(method, capabilities) }
end
end
end
# 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"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, required_capability_name: "prompts"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED,
required_capability_name: "prompts.listChanged",
capabilities: { prompts: {} }
ensure_capability_raises_error_for Methods::RESOURCES_LIST, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::RESOURCES_READ, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::RESOURCES_TEMPLATES_LIST, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED,
required_capability_name: "resources.listChanged",
capabilities: { resources: {} }
ensure_capability_raises_error_for Methods::RESOURCES_SUBSCRIBE, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::RESOURCES_SUBSCRIBE,
required_capability_name: "resources.subscribe",
capabilities: { resources: {} }
ensure_capability_raises_error_for Methods::RESOURCES_UNSUBSCRIBE, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::RESOURCES_UNSUBSCRIBE,
required_capability_name: "resources.subscribe",
capabilities: { resources: {} }
ensure_capability_raises_error_for Methods::NOTIFICATIONS_RESOURCES_UPDATED, required_capability_name: "resources"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_RESOURCES_UPDATED,
required_capability_name: "resources.subscribe",
capabilities: { resources: {} }
ensure_capability_raises_error_for Methods::TOOLS_LIST, required_capability_name: "tools"
ensure_capability_raises_error_for Methods::TOOLS_CALL, required_capability_name: "tools"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, required_capability_name: "tools"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED,
required_capability_name: "tools.listChanged",
capabilities: { tools: {} }
ensure_capability_raises_error_for Methods::LOGGING_SET_LEVEL, required_capability_name: "logging"
ensure_capability_raises_error_for Methods::NOTIFICATIONS_MESSAGE, required_capability_name: "logging"
ensure_capability_raises_error_for Methods::COMPLETION_COMPLETE, required_capability_name: "completions"
# Client methods and notifications
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_INITIALIZED
ensure_capability_raises_error_for Methods::ROOTS_LIST, required_capability_name: "roots"
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED
ensure_capability_raises_error_for Methods::SAMPLING_CREATE_MESSAGE, required_capability_name: "sampling"
ensure_capability_raises_error_for Methods::ELICITATION_CREATE, required_capability_name: "elicitation"
# Methods and notifications of both server and client
ensure_capability_does_not_raise_for Methods::PING
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_PROGRESS
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_CANCELLED
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ELICITATION_COMPLETE
end
end