diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 0baa9738..910c8d8f 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -65,13 +65,15 @@ def initialize(elicitations) # # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575 class UnsupportedProtocolVersionError < RequestHandlerError - def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS) + # No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash + # into keywords and fail with "unknown keywords". + def initialize(requested, request = nil) super( "Unsupported protocol version", request, error_type: :unsupported_protocol_version, error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, - error_data: { supported: supported, requested: requested || "unknown" }, + error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" }, ) end end @@ -560,25 +562,26 @@ def handle_request(request, method, session: nil, related_request_id: nil) server_context: { request: request }, exception_already_reported: ->(e) { reported_exception.equal?(e) }, ) do + envelope = lift_request_envelope(params, method: method, session: session) result = case method when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)) + build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) {} when Methods::TOOLS_CALL - call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::PROMPTS_GET - get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::COMPLETION_COMPLETE - complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation) + complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) when Methods::LOGGING_SET_LEVEL configure_logging_level(params, session: session) else - dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation) + dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) end client = session&.client || @client add_instrumentation_data(client: client) if client @@ -609,6 +612,34 @@ def handle_request(request, method, session: nil, related_request_id: nil) } end + # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries + # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched. + # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is + # pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is + # rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for + # every other request. + def lift_request_envelope(params, method:, session:) + return if Methods.notification?(method) + return if method == Methods::SERVER_DISCOVER + + modern_session = session.respond_to?(:era) && session.era == :modern + + if modern_session && method == Methods::INITIALIZE + requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil + raise UnsupportedProtocolVersionError.new(requested, params) + end + + if RequestEnvelope.modern?(params) + RequestEnvelope.parse!(params, request: params) + elsif modern_session + raise RequestHandlerError.new( + "Invalid Request: modern sessions require the SEP-2575 `_meta` envelope", + params, + error_type: :invalid_request, + ) + end + end + def handle_cancelled_notification(params, session: nil) return unless session return unless params.is_a?(Hash) @@ -748,7 +779,7 @@ def list_tools(request) apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact) end - def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) + def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) tool_name = request[:name] tool = tools[tool_name] @@ -781,7 +812,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) progress_token = request.dig(:_meta, :progressToken) response = call_tool_with_args( - tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation + tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope ) result = response.to_h validate_tool_call_result!(tool, result) @@ -808,7 +839,7 @@ def list_prompts(request) apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact) end - def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil) + def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) prompt_name = request[:name] prompt = @prompts[prompt_name] unless prompt @@ -826,6 +857,7 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) call_prompt_template_with_args(prompt, prompt_args, server_context) @@ -921,7 +953,7 @@ def apply_cache_metadata(result) { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result) end - def complete(params, session: nil, related_request_id: nil, cancellation: nil) + def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) validate_completion_params!(params) result = dispatch_optional_context_handler( @@ -930,6 +962,7 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil) session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) normalize_completion_result(result) @@ -938,13 +971,14 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil) # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`, # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or # `server_context.raise_if_cancelled!`. - def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil) + def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) dispatch_optional_context_handler( @handlers[Methods::RESOURCES_READ], request, session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) end @@ -952,7 +986,7 @@ def read_resource_contents(request, session: nil, related_request_id: nil, cance # `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`. # Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:` # keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing. - def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil) + def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) return handler.call(params) unless handler_declares_server_context?(handler) server_context = build_server_context( @@ -960,6 +994,7 @@ def dispatch_optional_context_handler(handler, params, session: nil, related_req session: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) handler.call(params, server_context: server_context) end @@ -984,7 +1019,7 @@ def handler_declares_server_context?(handler) # Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers # (progress, cancellation, nested server-to-client requests). - def build_server_context(request:, session:, related_request_id:, cancellation:) + def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil) meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) @@ -994,6 +1029,7 @@ def build_server_context(request:, session:, related_request_id:, cancellation:) notification_target: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) end @@ -1053,7 +1089,7 @@ def accepts_server_context?(method_object) end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. @@ -1068,6 +1104,7 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: notification_target: session, related_request_id: related_request_id, cancellation: cancellation, + envelope: envelope, ) tool.call(**args, server_context: server_context) else diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index dc5b20a5..d2abb527 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -4,12 +4,17 @@ module MCP class ServerContext attr_reader :cancellation - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil) + # The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern; + # `nil` on legacy requests. + attr_reader :envelope + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id @cancellation = cancellation + @envelope = envelope end def cancelled? @@ -20,6 +25,52 @@ def raise_if_cancelled! @cancellation&.raise_if_cancelled! end + # Whether the current request follows the stateless modern lifecycle (SEP-2575). + def modern? + !@envelope.nil? + end + + # Client identity for the current request. Modern requests carry it in the `_meta` envelope; + # legacy sessions fall back to the state stored by `initialize`. The envelope always wins + # because servers MUST NOT infer identity from prior requests. + def client_info + return @envelope.client_info if @envelope + + @notification_target.client if @notification_target.respond_to?(:client) + end + + # Client capabilities for the current request, with the same envelope-first resolution as {#client_info}. + def client_capabilities + return @envelope.client_capabilities if @envelope + + @notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities) + end + + # The protocol version the current request was made with. `nil` on legacy requests, + # where the version is a session-level negotiation result rather than per-request data. + def protocol_version + @envelope&.protocol_version + end + + # Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys, + # e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError` + # (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared. + def require_client_capability!(*path) + raise ArgumentError, "at least one capability key is required" if path.empty? + + declared = client_capabilities + value = path.reduce(declared) do |acc, key| + break unless acc.is_a?(Hash) + + symbol_value = acc[key.to_sym] + symbol_value.nil? ? acc[key.to_s] : symbol_value + end + return unless value.nil? + + required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } } + raise Server::MissingRequiredClientCapabilityError, required + end + # Reports progress for the current tool operation. # The notification is automatically scoped to the originating session. # @@ -41,6 +92,14 @@ def report_progress(progress, total: nil, message: nil) def notify_log_message(data:, level:, logger: nil) return unless @notification_target + # Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`, + # the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops + # the message the same way. Session- or server-level gating still applies downstream on delegation. + if @envelope + threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level) + return unless threshold&.valid_level? && threshold.should_notify?(level) + end + @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id) end diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index 754c1d00..7cbf2fbc 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -7,12 +7,22 @@ module MCP # Holds per-connection state for a single client session. # Created by the transport layer; delegates request handling to the shared `Server`. class ServerSession + ERAS = [:legacy, :modern].freeze + attr_reader :session_id, :client, :logging_message_notification - def initialize(server:, transport:, session_id: nil) + # Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds, + # then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions + # with `era: :modern` up front. + attr_reader :era + + def initialize(server:, transport:, session_id: nil, era: nil) + validate_era!(era) if era + @server = server @transport = transport @session_id = session_id + @era = era @client = nil @client_capabilities = nil @logging_message_notification = nil @@ -31,6 +41,19 @@ def initialized? # (the initialization phase MUST be the first interaction). def mark_initialized! @initialized = true + # A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575), + # so it also locks the connection era. + @era ||= :legacy + end + + # One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises, + # because a connection can never change eras. + def lock_era!(era) + validate_era!(era) + return if @era == era + raise "Session era already locked to #{@era}" if @era + + @era = era end # Registers a `Cancellation` token for an in-flight request. @@ -236,6 +259,10 @@ def notify_log_message(data:, level:, logger: nil, related_request_id: nil) private + def validate_era!(era) + raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era) + end + # Forwards `send_notification` to the transport with only the kwargs the transport's method signature # actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)` # contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:` diff --git a/test/mcp/server_context_test.rb b/test/mcp/server_context_test.rb index 91258052..07d60727 100644 --- a/test/mcp/server_context_test.rb +++ b/test/mcp/server_context_test.rb @@ -311,6 +311,71 @@ def context.combine(prefix, suffix:, &block) assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") } end + test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do + # Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send + # any `notifications/message` for the request. + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: nil) + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext#notify_log_message drops messages below the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).never + server_context = build_modern_server_context(notification_target, log_level: "error") + + server_context.notify_log_message(data: "test", level: "info") + end + + test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do + notification_target = mock + notification_target.expects(:notify_log_message).with( + data: "test", level: "error", logger: nil, related_request_id: nil, + ).once + server_context = build_modern_server_context(notification_target, log_level: "warning") + + server_context.notify_log_message(data: "test", level: "error") + end + + test "ServerContext exposes envelope data and falls back to the session on legacy requests" do + notification_target = mock + notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" }) + notification_target.stubs(:client_capabilities).returns({ roots: {} }) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + + legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target) + modern_context = build_modern_server_context(notification_target) + + refute_predicate legacy_context, :modern? + assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info) + assert_equal({ roots: {} }, legacy_context.client_capabilities) + assert_nil legacy_context.protocol_version + + assert_predicate modern_context, :modern? + assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info) + assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities) + assert_equal "2026-07-28", modern_context.protocol_version + end + + test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "c", version: "1" }, + client_capabilities: { "elicitation" => { "form" => {} } }, + ) + progress = Progress.new(notification_target: nil, progress_token: nil) + server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope) + + assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) } + assert_raises(ArgumentError) { server_context.require_client_capability! } + error = assert_raises(Server::MissingRequiredClientCapabilityError) do + server_context.require_client_capability!(:sampling) + end + assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data) + end + test "ServerContext#notify_resources_updated delegates to notification_target" do notification_target = mock notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once @@ -1000,5 +1065,18 @@ def template(args, server_context:) assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World", response[:result][:messages][0][:content][:text] end + + private + + def build_modern_server_context(notification_target, log_level: nil) + envelope = RequestEnvelope.new( + protocol_version: "2026-07-28", + client_info: { name: "modern_client", version: "2.0" }, + client_capabilities: { elicitation: { form: {} } }, + log_level: log_level, + ) + progress = Progress.new(notification_target: notification_target, progress_token: nil) + ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope) + end end end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c51c2109..d8cce38d 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -197,6 +197,17 @@ class ServerTest < ActiveSupport::TestCase assert_equal "unknown", error.error_data[:requested] end + test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do + # On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split + # into keywords and raise an ArgumentError. + request = { name: "echo", arguments: {}, _meta: {} } + + error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request) + + assert_equal "1900-01-01", error.error_data[:requested] + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported] + end + test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do server = Server.new(name: "error_test", tools: [TestTool]) server.define_tool(name: "missing_capability_tool") do @@ -215,6 +226,152 @@ class ServerTest < ActiveSupport::TestCase assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities)) end + test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do + server = Server.new(name: "modern_test", tools: []) + received = nil + server.define_tool(name: "modern_tool") do |server_context:| + received = { + modern: server_context.modern?, + client_info: server_context.client_info, + client_capabilities: server_context.client_capabilities, + protocol_version: server_context.protocol_version, + } + Tool::Response.new([{ type: "text", text: "ok" }]) + end + session = ServerSession.new(server: server, transport: mock) + + response = server.handle( + modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }), + session: session, + ) + + refute_nil response[:result] + assert received[:modern] + assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info]) + assert_equal({ elicitation: {} }, received[:client_capabilities]) + assert_equal "2026-07-28", received[:protocol_version] + # Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored. + assert_nil session.client + refute_predicate session, :initialized? + end + + test "#handle tools/call with an unsupported envelope version returns -32022" do + server = Server.new(name: "modern_test", tools: []) + called = false + server.define_tool(name: "modern_tool") do + called = true + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01")) + + refute called + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) + assert_equal "2027-01-01", response.dig(:error, :data, :requested) + end + + test "#handle treats a partial modern triple as a legacy request" do + server = Server.new(name: "modern_test", tools: []) + received_context = nil + server.define_tool(name: "modern_tool") do |server_context:| + received_context = server_context + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "modern_tool", + arguments: {}, + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }) + + refute_nil response[:result] + refute_predicate received_context, :modern? + end + + test "#handle requires the envelope for requests on a modern-locked session" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } + session = ServerSession.new(server: server, transport: mock, era: :modern) + + response = server.handle( + { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } }, + session: session, + ) + + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code) + end + + test "#handle rejects initialize on a modern-locked session with -32022" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + response = @server.handle( + { + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } }, + }, + session: session, + ) + + assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) + assert_equal "2025-11-25", response.dig(:error, :data, :requested) + end + + test "#handle server/discover succeeds without an envelope on a modern-locked session" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) + + refute_nil response[:result] + end + + test "#handle tools/call enforces require_client_capability! from the envelope" do + server = Server.new(name: "modern_test", tools: []) + server.define_tool(name: "guarded_tool") do |server_context:| + server_context.require_client_capability!(:elicitation, :form) + Tool::Response.new([{ type: "text", text: "ok" }]) + end + + declared = server.handle( + modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }), + ) + undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" })) + + refute_nil declared[:result] + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code) + assert_equal( + { elicitation: { form: {} } }, + undeclared.dig(:error, :data, :requiredCapabilities), + ) + end + + test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do + session = ServerSession.new(server: @server, transport: mock) + + assert_nil session.era + session.mark_initialized! + assert_equal :legacy, session.era + + session.lock_era!(:legacy) + assert_equal :legacy, session.era + assert_raises(RuntimeError) { session.lock_era!(:modern) } + end + + test "ServerSession accepts a preset era and validates era values" do + session = ServerSession.new(server: @server, transport: mock, era: :modern) + + assert_equal :modern, session.era + assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) } + assert_raises(ArgumentError) { session.lock_era!(:future) } + end + test "#handle initialize request returns protocol info, server info, and capabilities" do request = { jsonrpc: "2.0", @@ -3726,5 +3883,23 @@ def server_context refute Apps.client_supports?(server.client_capabilities) end + + private + + # Builds a request carrying the SEP-2575 modern `_meta` envelope. + def modern_request(method, params, version: "2026-07-28", capabilities: {}) + { + jsonrpc: "2.0", + method: method, + id: 1, + params: params.merge( + _meta: { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" }, + "io.modelcontextprotocol/clientCapabilities": capabilities, + }, + ), + } + end end end