ruby-sdk/test/mcp/configuration_test.rb
Koichi ITO d3aec1c3e6 Add Stateless Lifecycle Foundations per SEP-2575
## Motivation and Context

SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release) replaces
the `initialize` handshake with a stateless "modern" lifecycle: every request carries its protocol version,
client info, and client capabilities in reserved `_meta` keys, and the server validates each request independently.
This change lays the shared vocabulary for that lifecycle without changing any behavior:

- `MCP::Configuration` gains `LATEST_MODERN_PROTOCOL_VERSION` ("2026-07-28"), `SUPPORTED_MODERN_PROTOCOL_VERSIONS`,
  and `Configuration.modern_protocol_version?`. Modern versions are deliberately kept out of `SUPPORTED_STABLE_PROTOCOL_VERSIONS`
  so `initialize` can never negotiate them and `protocol_version=` keeps rejecting them.
- `MCP::ErrorCodes` gains `HEADER_MISMATCH` (-32020), completing the spec's stateless lifecycle error code block alongside
   the existing -32021/-32022 constants.
- New `MCP::RequestEnvelope` parses and validates the per-request `_meta` triple (`io.modelcontextprotocol/protocolVersion`,
  `clientInfo`, `clientCapabilities`, plus the optional deprecated `logLevel`). A request is classified as modern only
  when the full triple is present, matching the TypeScript SDK's `RequestMetaEnvelopeSchema` and the Python SDK's `_has_modern_envelope`,
  so partial or legacy `_meta` entries keep flowing through the legacy path untouched.
- New typed errors `Server::UnsupportedProtocolVersionError` (-32022 with `data: { supported:, requested: }`) and
  `Server::MissingRequiredClientCapabilityError` (-32021 with `data: { requiredCapabilities: }`) reuse the established `RequestHandlerError`
  `error_code`/`error_data` plumbing (the -32042 URL elicitation precedent).

Nothing dispatches through `RequestEnvelope` yet; the server-side modern handling, transport routing, and client support build on
this in follow-up changes.

Refs #389.

## How Has This Been Tested?

New `test/mcp/request_envelope_test.rb` covers the modern-triple classification (full triple, partial triple, string keys,
legacy `progressToken`), parsed field access, the -32022 error data shape, and invalid-request errors for incomplete or mistyped triples.
`test/mcp/configuration_test.rb` asserts the new constants, the predicate, and that modern versions stay rejected by `protocol_version=`.
`test/mcp/server_test.rb` asserts both typed errors surface on the wire with their SEP-2575 codes and data through `Server#handle`.
`test/mcp/error_codes_test.rb` pins -32020.

## Breaking Changes

None. Constants and classes are additive; no request path changes behavior.
2026-07-26 20:03:57 +09:00

251 lines
9.2 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class ConfigurationTest < ActiveSupport::TestCase
test "initializes with a default no-op exception reporter" do
config = Configuration.new
assert_respond_to config, :exception_reporter
# The default reporter should be callable but do nothing
exception = StandardError.new("test error")
server_context = { test: "context" }
assert_nothing_raised do
config.exception_reporter.call(exception, server_context)
end
end
test "allows setting a custom exception reporter" do
config = Configuration.new
reported_exception = nil
reported_context = nil
config.exception_reporter = ->(exception, server_context) do
reported_exception = exception
reported_context = server_context
end
test_exception = StandardError.new("test error")
test_context = { foo: "bar" }
config.exception_reporter.call(test_exception, test_context)
assert_equal test_exception, reported_exception
assert_equal test_context, reported_context
end
# https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15
test "initializes with default protocol version" do
config = Configuration.new
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version
end
test "uses the draft protocol version when protocol_version is set to nil" do
config = Configuration.new(protocol_version: nil)
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, config.protocol_version
end
test "raises ArgumentError when setting the draft protocol version" do
exception = assert_raises(ArgumentError) do
# DRAFT-2025-v3 is the latest draft protocol version:
# https://github.com/modelcontextprotocol/modelcontextprotocol/blob/14ec41c/schema/draft/schema.ts#L15
Configuration.new(protocol_version: "DRAFT-2025-v3")
end
assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
end
test "raises ArgumentError when protocol_version is not a supported protocol version" do
config = Configuration.new
exception = assert_raises(ArgumentError) do
custom_version = "2025-03-27"
config.protocol_version = custom_version
end
assert_equal("protocol_version must be 2025-11-25, 2025-06-18, 2025-03-26, or 2024-11-05", exception.message)
end
test "exposes the SEP-2575 modern protocol versions" do
assert_equal "2026-07-28", Configuration::LATEST_MODERN_PROTOCOL_VERSION
assert_equal ["2026-07-28"], Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS
assert Configuration.modern_protocol_version?("2026-07-28")
refute Configuration.modern_protocol_version?("2025-11-25")
end
test "raises ArgumentError when setting a modern protocol version" do
# Modern versions (SEP-2575) are never negotiated via `initialize`, so they are deliberately not settable as
# the legacy fallback version.
assert_raises(ArgumentError) do
Configuration.new(protocol_version: Configuration::LATEST_MODERN_PROTOCOL_VERSION)
end
end
test "raises ArgumentError when protocol_version is not a boolean value" do
config = Configuration.new
exception = assert_raises(ArgumentError) do
config.validate_tool_call_arguments = "true"
end
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
end
test "raises ArgumentError when validate_tool_call_results is not a boolean value" do
config = Configuration.new
exception = assert_raises(ArgumentError) do
config.validate_tool_call_results = "true"
end
assert_equal("validate_tool_call_results must be a boolean", exception.message)
end
test "merges protocol version from other configuration" do
config1 = Configuration.new(protocol_version: "2025-03-26")
config2 = Configuration.new(protocol_version: "2025-06-18")
config3 = Configuration.new
merged = config1.merge(config2)
assert_equal "2025-06-18", merged.protocol_version
merged = config1.merge(config3)
assert_equal "2025-03-26", merged.protocol_version
merged = config3.merge(config1)
assert_equal "2025-03-26", merged.protocol_version
end
test "defaults validate_tool_call_arguments to true" do
config = Configuration.new
assert config.validate_tool_call_arguments
end
test "can set validate_tool_call_arguments to false" do
config = Configuration.new(validate_tool_call_arguments: false)
refute config.validate_tool_call_arguments
end
test "validate_tool_call_arguments? returns false when set" do
config = Configuration.new(validate_tool_call_arguments: false)
refute config.validate_tool_call_arguments?
end
test "validate_tool_call_arguments? returns true when not set" do
config = Configuration.new
assert config.validate_tool_call_arguments?
end
test "merge preserves validate_tool_call_arguments from other config" do
config1 = Configuration.new(validate_tool_call_arguments: false)
config2 = Configuration.new
merged = config1.merge(config2)
assert merged.validate_tool_call_arguments?
end
test "merge preserves validate_tool_call_arguments from self when other not set" do
config1 = Configuration.new(validate_tool_call_arguments: false)
config2 = Configuration.new
merged = config2.merge(config1)
refute merged.validate_tool_call_arguments
end
test "defaults validate_tool_call_results to false" do
config = Configuration.new
refute config.validate_tool_call_results
end
test "can set validate_tool_call_results to true" do
config = Configuration.new(validate_tool_call_results: true)
assert config.validate_tool_call_results
end
test "validate_tool_call_results? returns false when not set" do
config = Configuration.new
refute config.validate_tool_call_results?
end
test "validate_tool_call_results? returns true when set" do
config = Configuration.new(validate_tool_call_results: true)
assert config.validate_tool_call_results?
end
test "merge preserves validate_tool_call_results from other config" do
config1 = Configuration.new(validate_tool_call_results: true)
config2 = Configuration.new
merged = config1.merge(config2)
refute merged.validate_tool_call_results?
end
test "merge preserves validate_tool_call_results from self when other set" do
config1 = Configuration.new(validate_tool_call_results: true)
config2 = Configuration.new
merged = config2.merge(config1)
assert merged.validate_tool_call_results
end
test "initializes with a default pass-through around_request" do
config = Configuration.new
called = false
config.around_request.call({}) { called = true }
assert called
end
test "allows setting a custom around_request" do
config = Configuration.new
call_log = []
config.around_request = ->(_data, &request_handler) {
call_log << :before
request_handler.call
call_log << :after
}
config.around_request.call({}) { call_log << :execute }
assert_equal([:before, :execute, :after], call_log)
end
test "around_request? returns false by default" do
config = Configuration.new
refute config.around_request?
end
test "around_request? returns true when set" do
config = Configuration.new
config.around_request = ->(_data, &request_handler) { request_handler.call }
assert config.around_request?
end
test "merge preserves around_request from other config" do
custom = ->(_data, &request_handler) { request_handler.call }
config1 = Configuration.new
config2 = Configuration.new(around_request: custom)
merged = config1.merge(config2)
assert_equal custom, merged.around_request
end
test "merge preserves around_request from self when other not set" do
custom = ->(_data, &request_handler) { request_handler.call }
config1 = Configuration.new(around_request: custom)
config2 = Configuration.new
merged = config1.merge(config2)
assert_equal custom, merged.around_request
end
test "raises ArgumentError when protocol_version is not a supported value" do
exception = assert_raises(ArgumentError) do
Configuration.new(protocol_version: "1999-12-31")
end
assert_match(/\Aprotocol_version must be/, exception.message)
end
test "raises ArgumentError when validate_tool_call_arguments is not a boolean" do
exception = assert_raises(ArgumentError) do
Configuration.new(validate_tool_call_arguments: "true")
end
assert_equal("validate_tool_call_arguments must be a boolean", exception.message)
end
test "raises ArgumentError when validate_tool_call_results is not a boolean" do
exception = assert_raises(ArgumentError) do
Configuration.new(validate_tool_call_results: "true")
end
assert_equal("validate_tool_call_results must be a boolean", exception.message)
end
end
end