# frozen_string_literal: true require "test_helper" module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper include InitializeParamsTestHelper include DeprecationWarningTestHelper setup do @tool = Tool.define( name: "test_tool", title: "Test tool", description: "A test tool", meta: { foo: "bar" }, ) @tool_that_raises = Tool.define( name: "tool_that_raises", title: "Tool that raises", description: "A tool that raises", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, ) { raise StandardError, "Tool error" } @tool_with_no_args = Tool.define( name: "tool_with_no_args", title: "Tool with no args", description: "This tool performs specific functionality...", annotations: { read_only_hint: true, }, ) do Tool::Response.new([{ type: "text", content: "OK" }]) end @prompt = Prompt.define( name: "test_prompt", title: "Test Prompt", description: "Test prompt", arguments: [ Prompt::Argument.new(name: "test_argument", description: "Test argument", required: true), ], ) do Prompt::Result.new( description: "Hello, world!", messages: [ Prompt::Message.new(role: "user", content: Content::Text.new("Hello, world!")), ], ) end @resource = Resource.new( uri: "https://test_resource.invalid", name: "test-resource", title: "Test Resource", description: "Test resource", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @resource_template = ResourceTemplate.new( uri_template: "https://test_resource.invalid/{id}", name: "test-resource", title: "Test Resource", description: "Test resource", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], mime_type: "text/plain", ) @server_name = "test_server" configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new( description: "Test server", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", instructions: "Optional instructions for the client", tools: [@tool, @tool_that_raises], prompts: [@prompt], resources: [@resource], resource_templates: [@resource_template], configuration: configuration, ) end # https://modelcontextprotocol.io/specification/latest/basic/utilities/ping#behavior-requirements test "#handle ping request returns empty response" do request = { jsonrpc: "2.0", method: "ping", id: "123", } response = @server.handle(request) assert_equal( { jsonrpc: "2.0", id: "123", result: {}, }, response, ) assert_instrumentation_data({ method: "ping" }) end test "#handle_json ping request returns empty response" do request = JSON.generate({ jsonrpc: "2.0", method: "ping", id: "123", }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) assert_equal( { jsonrpc: "2.0", id: "123", result: {}, }, response, ) 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_MODERN_PROTOCOL_VERSIONS, result[:supportedVersions] # Without a `subscriptions/listen`-serving transport, the `listChanged` flags are # stripped from the advertised capabilities (see the dedicated tests below). assert_equal @server.capabilities.keys, result[:capabilities].keys # Per the finalized spec (PR #3002), the server identity is the optional `_meta` stamp, # not a top-level `serverInfo` field. assert_equal @server_name, result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :name) assert_equal "1.2.3", result.dig(:_meta, RequestEnvelope::SERVER_INFO_META_KEY, :version) refute result.key?(:serverInfo) assert_equal "Optional instructions for the client", result[:instructions] end test "#handle server/discover strips listChanged and subscribe flags without a listen-serving transport" do server = Server.new(name: "discover_test", capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true }, logging: {}, }) result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] assert_equal({ tools: {}, resources: {}, logging: {} }, result[:capabilities]) end test "#handle server/discover keeps listChanged flags when the transport serves subscriptions/listen" do server = Server.new(name: "discover_test", capabilities: { tools: { listChanged: true } }) transport = mock transport.stubs(:serves_subscriptions_listen?).returns(true) server.transport = transport result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] assert_equal({ tools: { listChanged: true } }, result[:capabilities]) 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_MODERN_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: initialize_params }, 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 "#handle server/discover always carries the required cache hints with spec defaults" do # Unlike the opt-in SEP-2549 hints on list/read results, `ttlMs`/`cacheScope` # are REQUIRED on `DiscoverResult`. server = Server.new(name: "discover_test") result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] assert_equal 0, result[:ttlMs] assert_equal "private", result[:cacheScope] end test "#handle server/discover reuses the configured SEP-2549 cache hints" do server = Server.new(name: "discover_test", ttl_ms: 3_600_000, cache_scope: "public") result = server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })[:result] assert_equal 3_600_000, result[:ttlMs] assert_equal "public", result[:cacheScope] 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 "UnsupportedProtocolVersionError surfaces as -32022 with the SEP-2575 data shape" do server = Server.new(name: "error_test", tools: [TestTool]) server.define_tool(name: "unsupported_version_tool") do raise Server::UnsupportedProtocolVersionError, "1900-01-01" end response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "unsupported_version_tool" }, }) assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code) assert_equal "Unsupported protocol version", response.dig(:error, :message) assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported) assert_equal "1900-01-01", response.dig(:error, :data, :requested) end test "UnsupportedProtocolVersionError reports an unknown requested version" do error = Server::UnsupportedProtocolVersionError.new(nil) 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 raise Server::MissingRequiredClientCapabilityError, { elicitation: {} } end response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "missing_capability_tool" }, }) assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) assert_equal "Missing required client capability", response.dig(:error, :message) 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 rejects a claimed but incomplete envelope with -32602 naming the missing key" do # A request carrying the `protocolVersion` claim key must be validated, never silently served as legacy: # the spec maps missing required envelope fields to Invalid params, and the TypeScript and Python classifiers # answer the same way. server = Server.new(name: "modern_test", tools: []) server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) } response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, }, }) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities" end test "#handle serves an envelope without the optional clientInfo" do # `clientInfo` became optional after the SEP was finalized (spec PR #3002). 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", "io.modelcontextprotocol/clientCapabilities": { elicitation: {} }, }, }, }) refute_nil response[:result] assert_predicate received_context, :modern? assert_nil received_context.client_info assert_equal({ elicitation: {} }, received_context.client_capabilities) 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_PARAMS, response.dig(:error, :code) assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion" end test "#handle rejects removed lifecycle methods on a modern-locked session with -32601" do session = ServerSession.new(server: @server, transport: mock, era: :modern) Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index| response = @server.handle( { jsonrpc: "2.0", method: method, id: index + 1 }, session: session, ) assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}" end end test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do # `StdioTransport` locks the era only after a response succeeds, so the first request of a connection # arrives with `era == nil`. The envelope on the request is what identifies it as modern there. Methods::MODERN_REMOVED_METHODS.each do |method| session = ServerSession.new(server: @server, transport: mock) response = @server.handle(modern_request(method, {}), session: session) assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}" end end test "#handle does not let an enveloped initialize lock the era before it is settled" do # Serving it would negotiate the legacy lifecycle for a request that declared the modern one, # pinning the connection to the wrong era for its whole lifetime. session = ServerSession.new(server: @server, transport: mock) @server.handle(modern_request("initialize", initialize_params), session: session) assert_nil session.era refute_predicate session, :initialized? end test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do session = ServerSession.new(server: @server, transport: mock) response = @server.handle(modern_request("tools/list", {}), session: session) refute_nil response[:result] end test "#handle counter-offers the latest handshake version for a modern initialize request and locks the legacy era" do # Per the SEP-2575 era model, the handshake never lands on a modern version: the client is # counter-offered 2025-11-25 and the connection proceeds on the legacy lifecycle it selected. session = ServerSession.new(server: @server, transport: mock) response = @server.handle( { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28"), }, session: session, ) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response.dig(:result, :protocolVersion) assert_equal :legacy, session.era end test "#handle server/discover requires the envelope on a modern-locked session" do # The 2026-07-28 conformance requirements reject an envelope-less `server/discover` # on the modern era with -32602 (SEP-2575). session = ServerSession.new(server: @server, transport: mock, era: :modern) response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) end test "#handle server/discover succeeds without an envelope outside the modern era" do response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }) 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 "#handle tools/call serializes an input_required result on a modern request" do server = Server.new(name: "mrtr_test", tools: []) server.define_tool(name: "mrtr_tool") do |server_context:| if server_context.input_responses Tool::Response.new([{ type: "text", text: "done" }]) else Server::InputRequiredResult.new( input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, request_state: "state-1", ) end end response = server.handle( modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }), ) result = response[:result] assert_equal "input_required", result[:resultType] assert_equal "state-1", result[:requestState] assert_equal( { method: "elicitation/create", params: { message: "Which region?" } }, result.dig(:inputRequests, "region"), ) end test "#handle tools/call retry leg exposes input responses and request state to the handler" do server = Server.new(name: "mrtr_test", tools: []) seen = nil server.define_tool(name: "mrtr_tool") do |server_context:| seen = { input_responses: server_context.input_responses, request_state: server_context.request_state, region: server_context.input_response(:region), } Tool::Response.new([{ type: "text", text: "done" }]) end request = modern_request("tools/call", { name: "mrtr_tool", arguments: {}, inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } }, requestState: "state-1", }) response = server.handle(request) refute_nil response[:result] assert_equal "state-1", seen[:request_state] assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region]) assert_equal seen[:region], seen[:input_responses][:region] end test "#handle rejects an input_required result on a legacy request with an internal error" do # The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat # an unknown resultType as a final result, so it must never reach them. server = Server.new(name: "mrtr_test", tools: []) server.define_tool(name: "mrtr_tool") do Server::InputRequiredResult.new(request_state: "state-1") end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "mrtr_tool" }, }) assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code) end test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do server = Server.new(name: "mrtr_test", tools: []) server.define_tool(name: "mrtr_tool") do Server::InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: { message: "?" } }, roots: { method: "roots/list" }, }) end response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {})) assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) assert_equal( { elicitation: { form: {} }, roots: {} }, response.dig(:error, :data, :requiredCapabilities), ) end test "#handle tools/call input_required bypasses output schema validation" do configuration = Configuration.new(validate_tool_call_results: true) server = Server.new(name: "mrtr_test", tools: [], configuration: configuration) server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do Server::InputRequiredResult.new(request_state: "state-1") end response = server.handle(modern_request("tools/call", { name: "mrtr_tool" })) assert_equal "input_required", response.dig(:result, :resultType) end test "#handle seals and unseals requestState transparently when request_state_security is set" do security = Server::RequestStateSecurity.new(key: "k" * 32) server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) seen_state = nil server.define_tool(name: "mrtr_tool") do |server_context:| if server_context.request_state seen_state = server_context.request_state Tool::Response.new([{ type: "text", text: "done" }]) else Server::InputRequiredResult.new(request_state: "plain-state") end end first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) sealed = first.dig(:result, :requestState) assert sealed.start_with?("v1.") refute_equal "plain-state", sealed retry_response = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {}, requestState: sealed, })) refute_nil retry_response[:result] # The handler reads the plaintext it originally wrote. assert_equal "plain-state", seen_state end test "#handle rejects a tampered or cross-call requestState echo with -32602" do security = Server::RequestStateSecurity.new(key: "k" * 32) server = Server.new(name: "mrtr_test", tools: [], request_state_security: security) server.define_tool(name: "mrtr_tool") do |server_context:| if server_context.request_state Tool::Response.new([{ type: "text", text: "done" }]) else Server::InputRequiredResult.new(request_state: "plain-state") end end first = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {} })) sealed = first.dig(:result, :requestState) tampered = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: {}, requestState: sealed.sub("v1.", "v1.x"), })) # Different arguments than the sealing call: the digest claim no longer matches. cross_call = server.handle(modern_request("tools/call", { name: "mrtr_tool", arguments: { other: true }, requestState: sealed, })) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, tampered.dig(:error, :code) assert_equal "Invalid or expired requestState", tampered.dig(:error, :message) assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, cross_call.dig(:error, :code) end test "#handle passes requestState through untouched without request_state_security" do server = Server.new(name: "mrtr_test", tools: []) seen_state = nil server.define_tool(name: "mrtr_tool") do |server_context:| seen_state = server_context.request_state Tool::Response.new([{ type: "text", text: "done" }]) end server.handle(modern_request("tools/call", { name: "mrtr_tool", requestState: "raw-state" })) assert_equal "raw-state", seen_state end test "#handle prompts/get serializes an input_required result on a modern request" do server = Server.new(name: "mrtr_test") server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| server_context.request_state # participates via server_context: Server::InputRequiredResult.new(request_state: "prompt-state") end response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} })) assert_equal "input_required", response.dig(:result, :resultType) assert_equal "prompt-state", response.dig(:result, :requestState) end test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public") server.resources_read_handler do |_params, server_context:| server_context.request_state Server::InputRequiredResult.new(request_state: "resource-state") end response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" })) result = response[:result] assert_equal "input_required", result[:resultType] assert_equal "resource-state", result[:requestState] refute result.key?(:contents) refute result.key?(:ttlMs) refute result.key?(:cacheScope) 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", method: "initialize", id: 1, params: initialize_params, } response = @server.handle(request) refute_nil response expected_result = { jsonrpc: "2.0", id: 1, result: { protocolVersion: Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, capabilities: { prompts: { listChanged: true }, resources: { listChanged: true }, tools: { listChanged: true }, logging: {}, }, serverInfo: { description: "Test server", icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }], name: @server_name, title: "Example Server Display Name", version: "1.2.3", }, instructions: "Optional instructions for the client", }, } assert_equal expected_result, response assert_instrumentation_data({ method: "initialize", client: { name: "test-client", version: "1.0.0" } }) end test "#handle initialize result carries declared capability extensions" do server = Server.new( name: "extensions_test", capabilities: { tools: { listChanged: true }, extensions: { "com.example/feature" => { enabled: true } }, }, ) response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) assert_equal( { "com.example/feature" => { enabled: true } }, response.dig(:result, :capabilities, :extensions), ) end test "Server.new accepts an MCP::Server::Capabilities instance" do capabilities = Server::Capabilities.new capabilities.support_tools capabilities.support_extensions("io.modelcontextprotocol/tasks" => {}) server = Server.new(name: "extensions_test", capabilities: capabilities) response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) assert_equal( { tools: {}, extensions: { "io.modelcontextprotocol/tasks" => {} }, }, response.dig(:result, :capabilities), ) end test "client-declared capability extensions are readable via client_capabilities" do extensions = { "com.example/feature": { enabled: true } } request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(capabilities: { extensions: extensions }), } @server.handle(request) assert_equal extensions, @server.client_capabilities[:extensions] end test "client-declared capability extensions are readable via the session" do session = ServerSession.new(server: @server, transport: mock) extensions = { "com.example/feature": {} } request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(capabilities: { extensions: extensions }), } @server.handle(request, session: session) assert_equal extensions, session.client_capabilities[:extensions] end test "#handle initialize request with clientInfo includes client in instrumentation data" do client_info = { name: "test_client", version: "1.0.0" } request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(clientInfo: client_info), } @server.handle(request) assert_instrumentation_data({ method: "initialize", client: client_info }) end test "instrumentation data includes client info for subsequent requests after initialize" do client_info = { name: "test_client", version: "1.0.0" } initialize_request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(clientInfo: client_info), } @server.handle(initialize_request) ping_request = { jsonrpc: "2.0", method: "ping", id: 2, } @server.handle(ping_request) assert_instrumentation_data({ method: "ping", client: client_info }) end test "#handle rejects duplicate initialize on an already-initialized session with -32600" do session = ServerSession.new(server: @server, transport: mock) first_request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(clientInfo: { name: "original", version: "1.0" }), } first_response = @server.handle(first_request, session: session) refute_nil first_response[:result] second_request = { jsonrpc: "2.0", method: "initialize", id: 2, params: initialize_params(clientInfo: { name: "intruder", version: "9.9" }, protocolVersion: "2024-11-05"), } second_response = @server.handle(second_request, session: session) assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, second_response[:error][:code] assert_equal "Invalid Request", second_response[:error][:message] assert_equal({ name: "original", version: "1.0" }, session.client) end test "#handle initialize with empty params returns -32602 and does not initialize the session" do session = ServerSession.new(server: @server, transport: mock) response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }, session: session) assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] refute_predicate session, :initialized? end test "#handle initialize without params returns -32602" do response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] end test "#handle initialize with a missing required field returns -32602" do [:protocolVersion, :capabilities, :clientInfo].each do |field| params = initialize_params.tap { |hash| hash.delete(field) } response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: params }) assert_equal(-32602, response[:error][:code], "expected -32602 when #{field} is missing") assert_includes response[:error][:data], field.to_s end end test "#handle initialize with clientInfo missing name or version returns -32602" do [{ name: "client-without-version" }, { version: "1.0.0" }].each do |client_info| response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(clientInfo: client_info), }) assert_equal(-32602, response[:error][:code]) assert_includes response[:error][:data], "clientInfo" end end test "#handle initialize with an unsupported protocolVersion still negotiates the fallback version" do response = @server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "1999-01-01"), }) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "instrumentation data does not include client key when no clientInfo provided" do request = { jsonrpc: "2.0", method: "ping", id: 1, } @server.handle(request) assert_instrumentation_data({ method: "ping" }) end test "unsupported method instrumentation includes client from session" do session = ServerSession.new(server: @server, transport: mock) session.store_client_info(client: { name: "session-client", version: "1.0" }) request = { jsonrpc: "2.0", method: "does/not/exist", id: 1, } @server.handle(request, session: session) assert_instrumentation_data({ method: "unsupported_method", client: { name: "session-client", version: "1.0" } }) end test "#handle returns nil for notification requests" do request = { jsonrpc: "2.0", method: "some_notification", } assert_nil @server.handle(request) assert_instrumentation_data({ method: "unsupported_method" }) end test "#handle notifications/initialized returns nil response" do request = { jsonrpc: "2.0", method: "notifications/initialized", } assert_nil @server.handle(request) assert_instrumentation_data({ method: "notifications/initialized" }) end test "#handle_json notifications/initialized returns nil response" do request = JSON.generate({ jsonrpc: "2.0", method: "notifications/initialized", }) assert_nil @server.handle_json(request) assert_instrumentation_data({ method: "notifications/initialized" }) end test "#handle tools/list returns available tools" do request = { jsonrpc: "2.0", method: "tools/list", id: 1, } response = @server.handle(request) result = response[:result] assert_kind_of Array, result[:tools] assert_equal "test_tool", result[:tools][0][:name] assert_equal "Test tool", result[:tools][0][:title] assert_equal "A test tool", result[:tools][0][:description] assert_equal( { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }, result[:tools][0][:inputSchema] ) assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) assert_instrumentation_data({ method: "tools/list" }) end test "#handle_json tools/list returns available tools" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/list", id: 1, }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) result = response[:result] assert_kind_of Array, result[:tools] assert_equal "test_tool", result[:tools][0][:name] assert_equal "Test tool", result[:tools][0][:title] assert_equal "A test tool", result[:tools][0][:description] assert_equal({ foo: "bar" }, result[:tools][0][:_meta]) end test "#handle tools/list emits 2020-12 $schema on inputSchema and outputSchema" do tool_with_output = Tool.define( name: "tool_with_output", description: "tool with output schema", input_schema: { properties: { msg: { type: "string" } } }, output_schema: { properties: { result: { type: "string" } } }, ) do Tool::Response.new([{ type: "text", content: "OK" }]) end server = Server.new(name: "test_server", tools: [tool_with_output]) response = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) tool = response[:result][:tools][0] assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:inputSchema][:"$schema"] assert_equal "https://json-schema.org/draft/2020-12/schema", tool[:outputSchema][:"$schema"] end test "#handle tools/call executes tool and returns result" do tool_name = "test_tool" tool_args = { arg: "value" } tool_response = Tool::Response.new([{ result: "success" }]) if RUBY_VERSION >= "3.1" # Ruby 3.1+: Mocha stub preserves `method.parameters` info. @tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response) else # Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false. @tool.expects(:call).with(arg: "value").returns(tool_response) end request = { jsonrpc: "2.0", method: "tools/call", params: { name: tool_name, arguments: tool_args, }, id: 1, } response = @server.handle(request) assert_equal tool_response.to_h, response[:result] assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: tool_args }) end test "#handle_json tools/call delivers nested object arguments with symbol keys at every level" do received_payload = nil server = Server.new(name: "test_server") server.define_tool( name: "nested_args_tool", input_schema: { properties: { message: { type: "string" }, payload: { type: "object" } }, required: ["message"] }, ) do |message:, payload: nil, server_context:| received_payload = payload Tool::Response.new([{ type: "text", text: "#{message} #{server_context.class}" }]) end request_json = JSON.generate( jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "nested_args_tool", arguments: { message: "hi", payload: { subject: "greet", nested: { deep: "value" } } }, }, ) server.handle_json(request_json) assert_equal({ subject: "greet", nested: { deep: "value" } }, received_payload) assert_equal "greet", received_payload[:subject] assert_nil received_payload["subject"] end test "tool receives symbol keys when called under the JSON-round-tripped argument shape" do received_payload = nil tool = Tool.define( name: "nested_args_tool", input_schema: { properties: { payload: { type: "object" } } }, ) do |payload: nil, server_context:| received_payload = payload Tool::Response.new([{ type: "text", text: server_context.class.to_s }]) end # Round-trip the arguments through JSON the way a transport does, so the tool # is exercised under the symbolized shape it actually receives at runtime. arguments = { payload: { "subject" => "greet" } } delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true) tool.call(**delivered, server_context: nil) assert_equal({ subject: "greet" }, received_payload) assert_nil received_payload["subject"] end test "#handle tools/call returns tool execution error if required tool arguments are missing" do tool_with_required_argument = Tool.define( name: "test_tool", title: "Test tool", description: "A test tool", input_schema: { properties: { message: { type: "string" } }, required: ["message"] }, ) do |message: nil| Tool::Response.new("success #{message}") end server = Server.new( name: "test_server", tools: [tool_with_required_argument], ) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "test_tool", arguments: {} }, id: 1, } response = server.handle(request) assert_nil response[:error] assert(response[:result][:isError]) assert_equal "text", response[:result][:content][0][:type] assert_includes response[:result][:content][0][:text], "Missing required arguments: message" end test "#handle_json tools/call executes tool and returns result" do tool_name = "test_tool" tool_args = { arg: "value" } tool_response = Tool::Response.new([{ result: "success" }]) if RUBY_VERSION >= "3.1" # Ruby 3.1+: Mocha stub preserves `method.parameters` info. @tool.expects(:call).with(arg: "value", server_context: is_a(ServerContext)).returns(tool_response) else # Ruby 3.0: Mocha stub changes `method.parameters`, so `accepts_server_context?` returns false. @tool.expects(:call).with(arg: "value").returns(tool_response) end request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", params: { name: tool_name, arguments: tool_args }, id: 1, }) raw_response = @server.handle_json(request) response = JSON.parse(raw_response, symbolize_names: true) if raw_response assert_equal tool_response.to_h, response[:result] if response assert_instrumentation_data({ method: "tools/call", tool_name: tool_name, tool_arguments: { arg: "value" } }) end test "#handle_json tools/call executes tool and returns result, when the tool is typed with Sorbet" do skip "Sorbet is not available" unless defined?(T::Sig) class TypedTestTool < Tool tool_name "test_tool" description "a test tool for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) class << self extend T::Sig sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) } def call(message:, server_context: nil) Tool::Response.new([{ type: "text", content: "OK" }]) end end end request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", params: { name: "test_tool", arguments: { message: "Hello, world!" } }, id: 1, }) server = Server.new( name: @server_name, tools: [TypedTestTool], prompts: [@prompt], resources: [@resource], resource_templates: [@resource_template], ) raw_response = server.handle_json(request) response = JSON.parse(raw_response, symbolize_names: true) if raw_response assert_equal({ content: [{ type: "text", content: "OK" }], isError: false }, response[:result]) end test "#handle tools/call returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = { jsonrpc: "2.0", method: "tools/call", params: { name: "tool_that_raises", arguments: { message: "test" }, }, id: 1, } @server.configuration.exception_reporter.expects(:call).with do |exception, server_context| refute_kind_of MCP::Server::RequestHandlerError, exception assert_equal({ request: request }, server_context) end response = @server.handle(request) assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end test "registers tools with the same class name in different namespaces" do module Foo class Example < Tool end end module Bar class Example < Tool end end error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Foo::Example, Bar::Example]) } assert_equal(<<~MESSAGE, error.message) Tool names should be unique. Use `tool_name` to assign unique names to: example MESSAGE end test "registers tools with the same tool name" do module Baz class Example < Tool tool_name "foo" end end module Qux class Example < Tool tool_name "foo" end end error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Baz::Example, Qux::Example]) } assert_equal(<<~MESSAGE, error.message) Tool names should be unique. Use `tool_name` to assign unique names to: foo MESSAGE end test "#handle_json returns protocol error in JSON-RPC format if the tool raises an uncaught exception" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", params: { name: "tool_that_raises", arguments: { message: "test" }, }, id: 1, }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool tool_that_raises", response[:error][:data] assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises", tool_arguments: { message: "test" }, error: :internal_error }) end test "#handle tools/call returns protocol error in JSON-RPC format if input_schema raises an error during validation" do tool = Tool.define( name: "tool_with_faulty_schema", title: "Tool with faulty schema", description: "A tool with a faulty schema", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, ) { Tool::Response.new("success") } tool.input_schema.expects(:missing_required_arguments?).raises(RuntimeError, "Unexpected schema error") server = Server.new(name: "test_server", tools: [tool]) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "tool_with_faulty_schema", arguments: { message: "test" }, }, id: 1, } response = server.handle(request) assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool tool_with_faulty_schema", response[:error][:data] end test "#handle tools/call returns JSON-RPC error for unknown tool" do request = { jsonrpc: "2.0", method: "tools/call", params: { name: "unknown_tool", arguments: { message: "test" }, }, id: 1, } response = @server.handle(request) assert_nil response[:result] assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] assert_includes response[:error][:data], "Tool not found: unknown_tool" assert_instrumentation_data({ method: "tools/call", tool_name: "unknown_tool", error: :invalid_params }) end test "#handle_json returns JSON-RPC error for unknown tool" do request = JSON.generate({ jsonrpc: "2.0", method: "tools/call", params: { name: "unknown_tool", arguments: {}, }, id: 1, }) response = JSON.parse(@server.handle_json(request), symbolize_names: true) assert_nil response[:result] assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] assert_includes response[:error][:data], "Tool not found: unknown_tool" end test "#handle prompts/list returns list of prompts" do request = { jsonrpc: "2.0", method: "prompts/list", id: 1, } response = @server.handle(request) assert_equal({ prompts: [@prompt.to_h] }, response[:result]) assert_instrumentation_data({ method: "prompts/list" }) end test "#handle prompts/get returns templated prompt" do request = { jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "test_prompt", arguments: { test_argument: "Hello, friend!" }, }, } expected_result = { description: "Hello, world!", messages: [ { role: "user", content: { text: "Hello, world!", type: "text" } }, ], } response = @server.handle(request) assert_equal(expected_result, response[:result]) assert_instrumentation_data({ method: "prompts/get", prompt_name: "test_prompt" }) end test "#handle prompts/get returns error if prompt is not found" do request = { jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "unknown_prompt", arguments: {}, }, } response = @server.handle(request) # An unknown prompt is client input, so it maps to Invalid Params (-32602), not the # default Internal Error (-32603); matches the tools/call and completion/complete siblings. assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal("Prompt not found unknown_prompt", response[:error][:data]) assert_instrumentation_data({ method: "prompts/get", error: :prompt_not_found }) end test "#handle prompts/get returns error if prompt arguments are invalid" do request = { jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "test_prompt", arguments: { "unknown_argument" => "Hello, friend!" }, }, } response = @server.handle(request) # A missing required argument is client input, so it maps to Invalid Params (-32602), # not the default Internal Error (-32603). assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code) assert_equal "Missing required arguments: test_argument", response[:error][:data] assert_instrumentation_data({ method: "prompts/get", prompt_name: "test_prompt", error: :missing_required_arguments, }) end test "#handle resources/list returns a list of resources" do request = { jsonrpc: "2.0", method: "resources/list", id: 1, } response = @server.handle(request) assert_equal({ resources: [@resource.to_h] }, response[:result]) assert_instrumentation_data({ method: "resources/list" }) end test "#resources_list_handler replaces the served resource collection" do other = Resource.new(uri: "https://other.invalid", name: "other", mime_type: "text/plain") @server.resources_list_handler { |_params| [other] } response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal({ resources: [other.to_h] }, response[:result]) end test "#resources_list_handler receives server_context when it opts in" do real = Resource.new(uri: "https://real.invalid", name: "real", mime_type: "text/plain") demo = Resource.new(uri: "https://demo.invalid", name: "demo", mime_type: "text/plain") @server.resources_list_handler do |_params, server_context:| server_context[:authenticated] ? [real] : [demo] end @server.server_context = { authenticated: false } anon = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) @server.server_context = { authenticated: true } authed = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal({ resources: [demo.to_h] }, anon[:result]) assert_equal({ resources: [real.to_h] }, authed[:result]) end test "#resources_list_handler paginates and stamps cache hints on the returned collection" do first = Resource.new(uri: "https://first.invalid", name: "first", mime_type: "text/plain") second = Resource.new(uri: "https://second.invalid", name: "second", mime_type: "text/plain") server = Server.new(name: @server_name, resources: [], page_size: 1, ttl_ms: 60_000) server.resources_list_handler { |_params| [first, second] } response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal([first.to_h], response[:result][:resources]) assert_equal("1", response[:result][:nextCursor]) assert_equal(60_000, response[:result][:ttlMs]) end test "#resources_list_handler serves a server with no constructor-provided resources" do only = Resource.new(uri: "https://only.invalid", name: "only", mime_type: "text/plain") server = Server.new(name: @server_name, resources: []) server.resources_list_handler { |_params| [only] } response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal({ resources: [only.to_h] }, response[:result]) end test "#handle resources/read returns an empty array of contents by default" do request = { jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: "https://test_resource.invalid", }, } response = @server.handle(request) assert_equal({ contents: [] }, response[:result]) assert_instrumentation_data({ method: "resources/read", resource_uri: "https://test_resource.invalid" }) end test "#resources_read_handler sets the resources/read handler" do @server.resources_read_handler do |request| { uri: request[:uri], mimeType: "text/plain", text: "Lorem ipsum dolor sit amet", } end request = { jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: "https://test_resource.invalid/my_resource", }, } response = @server.handle(request) assert_equal( { contents: { uri: "https://test_resource.invalid/my_resource", mimeType: "text/plain", text: "Lorem ipsum dolor sit amet" } }, response[:result], ) end test "#handle resources/read returns -32602 with the uri in error data when the handler raises ResourceNotFoundError" do # Per SEP-2164, resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) # and carry the requested URI in `data`. @server.resources_read_handler do |request| raise Server::ResourceNotFoundError.new(request[:uri], request) end response = @server.handle({ jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: "file:///missing.txt" }, }) assert_equal(-32602, response[:error][:code]) assert_equal("Resource not found: file:///missing.txt", response[:error][:message]) assert_equal({ uri: "file:///missing.txt" }, response[:error][:data]) end test "#handle resources/templates/list returns a list of resource templates" do request = { jsonrpc: "2.0", method: "resources/templates/list", id: 1, } response = @server.handle(request) assert_equal( { resourceTemplates: [@resource_template.to_h], }, response[:result], ) assert_instrumentation_data({ method: "resources/templates/list" }) end class GreetingResource < Resource uri "greeting://hello" resource_name "greeting" mime_type "text/plain" class << self def contents [Resource::TextContents.new(uri: uri, mime_type: mime_type, text: "hello")] end end end class UserProfileTemplate < ResourceTemplate uri_template "users://{user_id}/profile" resource_template_name "user_profile" mime_type "text/plain" class << self def contents(user_id:) [Resource::TextContents.new(uri: "users://#{user_id}/profile", mime_type: mime_type, text: "profile of #{user_id}")] end end end def read_resource_request(uri) { jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: uri }, } end test "#handle resources/read auto-routes to a class-based resource by exact URI" do configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback server = Server.new(name: "test_server", resources: [GreetingResource], configuration: configuration) response = server.handle(read_resource_request("greeting://hello")) expected = [{ uri: "greeting://hello", mimeType: "text/plain", text: "hello" }] assert_equal({ contents: expected }, response[:result]) assert_instrumentation_data({ method: "resources/read", resource_uri: "greeting://hello" }) end test "#handle resources/read wraps a single contents object returned by a class-based resource" do resource = Resource.define(uri: "single://one", name: "single") do Resource::TextContents.new(uri: "single://one", mime_type: "text/plain", text: "only one") end server = Server.new(name: "test_server", resources: [resource]) response = server.handle(read_resource_request("single://one")) expected = [{ uri: "single://one", mimeType: "text/plain", text: "only one" }] assert_equal({ contents: expected }, response[:result]) end test "#handle resources/read passes server_context to contents that opt in" do received = nil resource = Resource.define(uri: "ctx://resource", name: "ctx") do |server_context:| received = server_context [Resource::TextContents.new(uri: "ctx://resource", mime_type: "text/plain", text: "ctx")] end server = Server.new(name: "test_server", resources: [resource]) server.handle(read_resource_request("ctx://resource")) assert_instance_of ServerContext, received end test "#handle resources/read auto-routes to a class-based resource template with extracted params" do configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate], configuration: configuration) response = server.handle(read_resource_request("users://42/profile")) expected = [{ uri: "users://42/profile", mimeType: "text/plain", text: "profile of 42" }] assert_equal({ contents: expected }, response[:result]) assert_instrumentation_data({ method: "resources/read", resource_uri: "users://42/profile", resource_template: "users://{user_id}/profile", }) end test "#handle resources/read prefers an exact resource match over a template match" do resource = Resource.define(uri: "users://42/profile", name: "pinned_profile") do [Resource::TextContents.new(uri: "users://42/profile", mime_type: "text/plain", text: "pinned")] end server = Server.new(name: "test_server", resources: [resource], resource_templates: [UserProfileTemplate]) response = server.handle(read_resource_request("users://42/profile")) assert_equal "pinned", response[:result][:contents].first[:text] end test "#handle resources/read returns -32602 for an unknown URI when class-based resources are registered" do server = Server.new(name: "test_server", resources: [GreetingResource]) response = server.handle(read_resource_request("greeting://unknown")) assert_equal(-32602, response[:error][:code]) assert_equal("Resource not found: greeting://unknown", response[:error][:message]) assert_equal({ uri: "greeting://unknown" }, response[:error][:data]) end test "#handle resources/read returns -32602 for a non-matching URI when class-based templates are registered" do server = Server.new(name: "test_server", resource_templates: [UserProfileTemplate]) response = server.handle(read_resource_request("users://42/settings")) assert_equal(-32602, response[:error][:code]) assert_equal({ uri: "users://42/settings" }, response[:error][:data]) end test "#handle resources/read keeps returning [] for unknown URIs when only instance-based resources are registered" do server = Server.new(name: "test_server", resources: [@resource], resource_templates: [@resource_template]) response = server.handle(read_resource_request("unknown://resource")) assert_equal({ contents: [] }, response[:result]) end test "#resources_read_handler overrides auto-routing for class-based resources" do server = Server.new(name: "test_server", resources: [GreetingResource]) server.resources_read_handler do |request| [{ uri: request[:uri], mimeType: "text/plain", text: "handler wins" }] end response = server.handle(read_resource_request("greeting://hello")) assert_equal "handler wins", response[:result][:contents].first[:text] end test "#handle resources/list and resources/templates/list render class-based and instance-based entries together" do server = Server.new( name: "test_server", resources: [@resource, GreetingResource], resource_templates: [@resource_template, UserProfileTemplate], ) list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal [@resource.to_h, GreetingResource.to_h], list_response[:result][:resources] templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) assert_equal [@resource_template.to_h, UserProfileTemplate.to_h], templates_response[:result][:resourceTemplates] end test "#resources= rebuilds the resource index used for auto-routing" do server = Server.new(name: "test_server", resources: [GreetingResource]) replacement = Resource.define(uri: "replacement://res", name: "replacement") do [Resource::TextContents.new(uri: "replacement://res", mime_type: "text/plain", text: "replaced")] end server.resources = [replacement] response = server.handle(read_resource_request("replacement://res")) assert_equal "replaced", response[:result][:contents].first[:text] stale_response = server.handle(read_resource_request("greeting://hello")) assert_equal(-32602, stale_response[:error][:code]) end test "#define_resource registers a resource served by auto-routing" do server = Server.new(name: "test_server") server.define_resource(uri: "defined://res", name: "defined", mime_type: "text/plain") do [Resource::TextContents.new(uri: "defined://res", mime_type: "text/plain", text: "defined body")] end list_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal ["defined"], list_response[:result][:resources].map { |resource| resource[:name] } response = server.handle(read_resource_request("defined://res")) assert_equal "defined body", response[:result][:contents].first[:text] end test "#define_resource_template registers a template served by auto-routing" do server = Server.new(name: "test_server") server.define_resource_template(uri_template: "items://{item_id}", name: "item_template") do |item_id:| [Resource::TextContents.new(uri: "items://#{item_id}", mime_type: "text/plain", text: "item #{item_id}")] end templates_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) assert_equal ["item_template"], templates_response[:result][:resourceTemplates].map { |template| template[:name] } response = server.handle(read_resource_request("items://7")) assert_equal "item 7", response[:result][:contents].first[:text] end test "#configure_logging_level returns empty hash on success" do response = @server.handle( { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info", }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_equal({}, response[:result]) refute response.key?(:error) end test "#configure_logging_level does not warn after a modern initialize request is counter-offered" do # `initialize` asking for 2026-07-28 lands on 2025-11-25, where logging is not deprecated, # so no warning fires. On the modern lifecycle itself `logging/setLevel` is a removed method # and never reaches this handler. server = Server.new(tools: [TestTool]) server.handle( { jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2026-07-28", capabilities: {}, clientInfo: { name: "test-client", version: "1.0" }, }, }, ) response = nil assert_no_deprecation_warning do response = server.handle( { jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: "info", }, }, ) end assert_empty response[:result] end test "#configure_logging_level does not warn when negotiated protocol version is older" do server = Server.new(tools: [TestTool]) server.handle( { jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test-client", version: "1.0" }, }, }, ) assert_no_deprecation_warning do server.handle( { jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: "info", }, }, ) end end test "#configure_logging_level returns an error object when invalid log level is provided" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: true), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "invalid_level", }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_equal(-32602, response[:error][:code]) assert_includes response[:error][:data], "Invalid log level invalid_level" end test "#configure_logging_level returns an error object when server has not logging capability" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: true), capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true }, }, ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "debug", }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_equal(-32603, response[:error][:code]) assert_includes response[:error][:data], "Server does not support logging" end test "#handle method with missing required top-level capability returns an error" do @server.capabilities = {} response = @server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) assert_equal "Server does not support prompts (required for prompts/list)", response[:error][:data] response = @server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) assert_equal "Server does not support resources (required for resources/list)", response[:error][:data] end test "#handle method with missing required nested capability returns an error" do @server.capabilities = { resources: {} } response = @server.handle({ jsonrpc: "2.0", method: "resources/subscribe", id: 1 }) assert_equal "Server does not support resources.subscribe (required for resources/subscribe)", response[:error][:data] end test "#handle unknown method returns method not found error" do request = { jsonrpc: "2.0", id: 1, method: "unknown_method", } response = @server.handle(request) assert_equal "Method not found", response[:error][:message] assert_equal "unknown_method", response[:error][:data] assert_instrumentation_data({ method: "unsupported_method" }) end test "#handle handles custom methods" do @server.define_custom_method(method_name: "add") do |params| params[:a] + params[:b] end request = { jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 }, } response = @server.handle(request) assert_equal 3, response[:result] assert_instrumentation_data({ method: "add" }) end test "#handle handles custom notifications" do @server.define_custom_method(method_name: "notify") do nil end request = { jsonrpc: "2.0", method: "notify", } response = @server.handle(request) assert_nil response assert_instrumentation_data({ method: "notify" }) end test "#handle tools/call invokes around_request with correct data" do call_log = [] data_before = nil data_after = nil configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback configuration.around_request = ->(data, &request_handler) { data_before = data.dup call_log << :before request_handler.call call_log << :after data_after = data.dup } tool = Tool.define(name: "around_test_tool", description: "Test") do |arg:| Tool::Response.new([{ type: "text", text: arg }]) end server = Server.new(name: "test_server", tools: [tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "around_test_tool", arguments: { arg: "hello" } }, id: 1, } server.handle(request) assert_equal([:before, :after], call_log) assert_equal("tools/call", data_before[:method]) assert_nil(data_before[:tool_name]) assert_equal("around_test_tool", data_after[:tool_name]) assert_equal({ arg: "hello" }, data_after[:tool_arguments]) end test "#handle around_request and instrumentation_callback coexist" do around_called = false callback_data = nil configuration = MCP::Configuration.new configuration.around_request = ->(_data, &request_handler) { around_called = true request_handler.call } configuration.instrumentation_callback = ->(data) { callback_data = data.dup } server = Server.new(name: "test_server", configuration: configuration) request = { jsonrpc: "2.0", method: "ping", id: 1, } server.handle(request) assert(around_called) assert_equal("ping", callback_data[:method]) assert(callback_data[:duration]) end test "#handle reports exception and sets error when around_request raises" do reported_exception = nil reported_context = nil callback_data = nil configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, server_context) { reported_exception = e reported_context = server_context } configuration.instrumentation_callback = ->(data) { callback_data = data.dup } configuration.around_request = ->(_data, &_request_handler) { raise "around_request failure" } server = Server.new(name: "test_server", configuration: configuration) request = { jsonrpc: "2.0", method: "ping", id: 1, } response = server.handle(request) assert_equal("around_request failure", reported_exception.message) assert_equal({ request: request }, reported_context) assert_equal(:internal_error, callback_data[:error]) assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) end test "#handle does not double-report exception_reporter when a tool handler raises" do report_count = 0 configuration = MCP::Configuration.new configuration.exception_reporter = ->(_e, _server_context) { report_count += 1 } failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do raise "tool failure" end server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "failing_tool", arguments: {} }, id: 1, } server.handle(request) assert_equal(1, report_count) end test "#handle reports both exceptions when around_request ensure raises after tool failure" do reported = [] configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, _server_context) { reported << e.message } configuration.around_request = ->(_data, &request_handler) do request_handler.call ensure raise "around ensure boom" end failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do raise "tool failure" end server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "failing_tool", arguments: {} }, id: 1, } response = server.handle(request) assert_equal(["tool failure", "around ensure boom"], reported) assert_equal(JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response[:error][:code]) assert_nil(response[:error][:data]) end test "#handle reports the same exception object reused across requests on every call" do reported = [] configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, _server_context) { reported << e } shared_error = RuntimeError.new("reused") shared_tool = Tool.define(name: "shared_failing_tool", description: "Always fails") do raise shared_error end server = Server.new(name: "test_server", tools: [shared_tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "shared_failing_tool", arguments: {} }, id: 1, } server.handle(request) server.handle(request) assert_equal(2, reported.size) assert_same(shared_error, reported[0]) assert_same(shared_error, reported[1]) end test "#handle reports frozen exceptions raised by tool handlers without wrapping them" do reported = [] configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, _server_context) { reported << e } frozen_error = RuntimeError.new("frozen failure").freeze frozen_tool = Tool.define(name: "frozen_tool", description: "Raises frozen") do raise frozen_error end server = Server.new(name: "test_server", tools: [frozen_tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "frozen_tool", arguments: {} }, id: 1, } response = server.handle(request) assert_equal([frozen_error], reported) assert_equal("Internal error calling tool frozen_tool", response[:error][:data]) end test "#handle still reports via exception_reporter when around_request swallows the tool failure" do reported = [] configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, _server_context) { reported << e.message } configuration.around_request = ->(_data, &request_handler) do request_handler.call rescue StandardError { swallowed: true } end failing_tool = Tool.define(name: "failing_tool", description: "Always fails") do raise "tool failure" end server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) request = { jsonrpc: "2.0", method: "tools/call", params: { name: "failing_tool", arguments: {} }, id: 1, } response = server.handle(request) assert_equal(["tool failure"], reported) assert_equal({ swallowed: true }, response[:result]) end test "#handle concurrent requests on a shared server report exceptions independently" do reported = Queue.new configuration = MCP::Configuration.new configuration.exception_reporter = ->(e, _server_context) { reported << e.message } failing_tool = Tool.define(name: "concurrent_tool", description: "Raises per-thread") do |i:| raise "thread #{i}" end server = Server.new(name: "test_server", tools: [failing_tool], configuration: configuration) threads = 10.times.map do |i| Thread.new do server.handle({ jsonrpc: "2.0", method: "tools/call", params: { name: "concurrent_tool", arguments: { i: i } }, id: i, }) end end threads.each(&:join) messages = [] messages << reported.pop until reported.empty? assert_equal(10.times.map { |i| "thread #{i}" }.sort, messages.sort) end test "#define_custom_method raises an error if the method is already defined" do assert_raises(Server::MethodAlreadyDefinedError) do @server.define_custom_method(method_name: "tools/call") do nil end end end test "the global configuration is used if no configuration is passed to the server" do server = Server.new(name: "test_server") assert_equal MCP.configuration.instrumentation_callback, server.configuration.instrumentation_callback assert_equal MCP.configuration.exception_reporter, server.configuration.exception_reporter end test "the server configuration takes precedence over the global configuration" do configuration = MCP::Configuration.new local_callback = ->(data) { puts "Local callback #{data.inspect}" } local_exception_reporter = ->(exception, server_context) { puts "Local exception reporter #{exception.inspect} #{server_context.inspect}" } configuration.instrumentation_callback = local_callback configuration.exception_reporter = local_exception_reporter server = Server.new(name: "test_server", configuration: configuration) assert_equal local_callback, server.configuration.instrumentation_callback assert_equal local_exception_reporter, server.configuration.exception_reporter end test "server uses the latest handshake version when not configured" do request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = @server.handle(request) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server response does not include optional parameters when configured" do server = Server.new(title: "Example Server Display Name", name: "test_server", website_url: "https://example.com") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) server_info = response[:result][:serverInfo] assert_equal("Example Server Display Name", server_info[:title]) assert_equal("https://example.com", server_info[:websiteUrl]) end test "server response does not include optional parameters when not configured" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) refute response[:result][:serverInfo].key?(:title) refute response[:result][:serverInfo].key?(:website_url) end test "server response does not include icons when icons is empty" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) refute response[:result][:serverInfo].key?(:icons) end test "server response does not include icons when icons is nil" do server = Server.new(name: "test_server", icons: nil) request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) refute response[:result][:serverInfo].key?(:icons) end test "server response includes icons when icons is present" do server = Server.new( name: "test_server", icons: [Icon.new(mime_type: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light")], ) request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) expected_icons = [{ mimeType: "image/png", sizes: ["48x48"], src: "https://example.com", theme: "light" }] assert_equal expected_icons, response[:result][:serverInfo][:icons] end test "server uses default version when not configured" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) assert_equal Server::DEFAULT_VERSION, response[:result][:serverInfo][:version] end test "server uses instructions when not configured" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params, } response = server.handle(request) refute response[:result].key?(:instructions) end test "server uses description when configured with protocol version 2025-11-25" do configuration = Configuration.new(protocol_version: "2025-11-25") server = Server.new(description: "This is the MCP server used during tests.", name: "test_server", configuration: configuration) assert_equal("This is the MCP server used during tests.", server.description) end test "raises error if description is used with protocol version 2025-06-18" do configuration = Configuration.new(protocol_version: "2025-06-18") exception = assert_raises(ArgumentError) do Server.new(description: "This is the MCP server used during tests.", name: "test_server", configuration: configuration) end assert_equal("Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier", exception.message) end test "server uses instructions when configured with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") server = Server.new(name: "test_server", instructions: "The server instructions.", configuration: configuration) assert_equal("The server instructions.", server.instructions) end test "raises error if instructions are used with protocol version 2024-11-05" do configuration = Configuration.new(protocol_version: "2024-11-05") exception = assert_raises(ArgumentError) do Server.new(name: "test_server", instructions: "The server instructions.", configuration: configuration) end assert_equal("`instructions` supported by protocol version 2025-03-26 or higher", exception.message) end test "server uses annotations when configured with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") server = Server.new(name: "test_server", configuration: configuration) server.define_tool( name: "defined_tool", annotations: { title: "test server" }, ) assert_equal({ destructiveHint: true, idempotentHint: false, openWorldHint: true, readOnlyHint: false, title: "test server" }, server.tools.first[1].annotations.to_h) end test "raises error if annotations are used with protocol version 2024-11-05" do configuration = Configuration.new(protocol_version: "2024-11-05") exception = assert_raises(ArgumentError) do server = Server.new(name: "test_server", configuration: configuration) server.define_tool( name: "defined_tool", annotations: { title: "test server" }, ) end assert_equal("Error occurred in defined_tool. `annotations` are supported by protocol version 2025-03-26 or higher", exception.message) end test "raises error if `title` of `server_info` is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") exception = assert_raises(ArgumentError) do Server.new(name: "test_server", title: "Example Server Display Name", configuration: configuration) end assert_equal("Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier", exception.message) end test "raises error if `website_url` of `server_info` is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") exception = assert_raises(ArgumentError) do Server.new(name: "test_server", website_url: "https://example.com", configuration: configuration) end assert_equal("Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier", exception.message) end test "raises error if `title` of tool is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") server = Server.new(name: "test_server", configuration: configuration) exception = assert_raises(ArgumentError) do server.define_tool( title: "Test tool", ) end assert_equal("Error occurred in Test tool. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end test "raises error if `title` of prompt is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") server = Server.new(name: "test_server", configuration: configuration) exception = assert_raises(ArgumentError) do server.define_prompt( title: "Test prompt", ) end assert_equal("Error occurred in Test prompt. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end test "raises error if `title` of resource is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") resource = Resource.new( uri: "https://test_resource.invalid", name: "test-resource", title: "Test resource", ) exception = assert_raises(ArgumentError) do Server.new(name: "test_server", resources: [resource], configuration: configuration) end assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end test "raises error if `title` of class-based resource is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") resource = Resource.define( uri: "https://test_resource.invalid", name: "test-resource", title: "Test resource", ) exception = assert_raises(ArgumentError) do Server.new(name: "test_server", resources: [resource], configuration: configuration) end assert_equal("Error occurred in Test resource. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end test "allows `$ref` in tool input schema with protocol version 2025-11-25" do tool = Tool.define( name: "ref_tool", description: "Tool with $ref", input_schema: { type: "object", "$defs": { address: { type: "object", properties: { city: { type: "string" } } } }, properties: { address: { "$ref": "#/$defs/address" } }, }, ) configuration = Configuration.new(protocol_version: "2025-11-25") assert_nothing_raised do Server.new(name: "test_server", tools: [tool], configuration: configuration) end end test "raises error if `$ref` in tool input schema is used with protocol version 2025-06-18" do tool = Tool.define( name: "ref_tool", description: "Tool with $ref", input_schema: { type: "object", "$defs": { address: { type: "object", properties: { city: { type: "string" } } } }, properties: { address: { "$ref": "#/$defs/address" } }, }, ) configuration = Configuration.new(protocol_version: "2025-06-18") exception = assert_raises(ArgumentError) do Server.new(name: "test_server", tools: [tool], configuration: configuration) end assert_equal( "Error occurred in ref_tool. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher", exception.message, ) end test "raises error if `title` of resource template is used with protocol version 2025-03-26" do configuration = Configuration.new(protocol_version: "2025-03-26") resource = Resource.new( uri: "https://test_resource.invalid", name: "test-resource", title: "Test resource template", ) exception = assert_raises(ArgumentError) do Server.new(name: "test_server", resources: [resource], configuration: configuration) end assert_equal("Error occurred in Test resource template. `title` is not supported in protocol version 2025-03-26 or earlier", exception.message) end test "#define_tool adds a tool to the server" do @server.define_tool( name: "defined_tool", description: "Defined tool", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, output_schema: { type: "object", properties: { response: { type: "string" } }, required: ["response"] }, meta: { foo: "bar" }, ) do |message:| Tool::Response.new({ "response" => message }) end stored_tool = @server.tools["defined_tool"] assert_not_nil(stored_tool) assert_equal(MCP::Tool::OutputSchema.new({ type: "object", properties: { response: { type: "string" } }, required: ["response"] }), stored_tool.output_schema) response = @server.handle({ jsonrpc: "2.0", method: "tools/call", params: { name: "defined_tool", arguments: { message: "success" } }, id: 1, }) assert_equal({ content: { "response" => "success" }, isError: false }, response[:result]) end test "#define_tool adds a tool with duplicated tool name to the server" do error = assert_raises(MCP::ToolNotUnique) do @server.define_tool( name: "test_tool", # NOTE: Already registered tool name description: "Defined tool", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, meta: { foo: "bar" }, ) do |message:| Tool::Response.new(message) end end assert_match(/\ATool names should be unique. Use `tool_name` to assign unique names to/, error.message) end test "#define_tool call definition allows tool arguments and server context" do @server.server_context = { user_id: "123" } @server.define_tool( name: "defined_tool", description: "Defined tool", input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, ) do |message:, server_context:| Tool::Response.new("success #{message} #{server_context[:user_id]}") end response = @server.handle({ jsonrpc: "2.0", method: "tools/call", params: { name: "defined_tool", arguments: { message: "hello" } }, id: 1, }) assert_equal({ content: "success hello 123", isError: false }, response[:result]) end test "#define_prompt adds a tool to the server" do @server.define_prompt(name: "defined_prompt", description: "Defined prompt", arguments: []) do Prompt::Result.new( description: "a prompt description", messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))], ) end response = @server.handle({ jsonrpc: "2.0", method: "prompts/get", params: { name: "defined_prompt", arguments: {} }, id: 1, }) assert_equal( { description: "a prompt description", messages: [{ role: "user", content: { text: "a prompt message", type: "text" } }], }, response[:result], ) end test "server protocol version can be overridden via configuration" do custom_version = "2025-03-26" configuration = Configuration.new(protocol_version: custom_version) server = Server.new(name: "test_server", configuration: configuration) request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "1999-01-01"), } response = server.handle(request) assert_equal custom_version, response[:result][:protocolVersion] end test "server negotiates protocol version when client requests a supported version" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2025-06-18"), } response = server.handle(request) assert_equal "2025-06-18", response[:result][:protocolVersion] end test "server falls back to the latest handshake version when client requests unsupported version" do server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "1999-01-01"), } response = server.handle(request) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, response[:result][:protocolVersion] end test "server counter-offers 2025-11-25 when the client requests 2026-07-28 via initialize" do # Per the SEP-2575 era model, `initialize` negotiates legacy versions only: a modern version carries # its own version on every request and has no handshake at all. The TypeScript and Python servers # answer the same way. server = Server.new(name: "test_server") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28"), } response = server.handle(request) assert_equal "2025-11-25", response[:result][:protocolVersion] end test "server removes description and icons from server_info when negotiating to 2025-06-18" do server = Server.new( name: "test_server", description: "A test server", icons: [Icon.new(src: "https://example.com/icon.png")], ) request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2025-06-18"), } response = server.handle(request) assert_equal "2025-06-18", response[:result][:protocolVersion] refute response[:result][:serverInfo].key?(:description) refute response[:result][:serverInfo].key?(:icons) end test "server removes title and websiteUrl when negotiating to 2025-03-26" do server = Server.new(name: "test_server", title: "Test Server", website_url: "https://example.com") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2025-03-26"), } response = server.handle(request) assert_equal "2025-03-26", response[:result][:protocolVersion] refute response[:result][:serverInfo].key?(:title) refute response[:result][:serverInfo].key?(:websiteUrl) end test "server removes instructions when negotiating to 2024-11-05" do server = Server.new(name: "test_server", instructions: "Some instructions") request = { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2024-11-05"), } response = server.handle(request) assert_equal "2024-11-05", response[:result][:protocolVersion] refute response[:result].key?(:instructions) end test "tools/call returns tool execution error for missing arguments" do configuration = Configuration.new(validate_tool_call_arguments: true) configuration.instrumentation_callback = instrumentation_helper.callback server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "test_tool", }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_nil response[:error] assert(response[:result][:isError]) assert_equal "text", response[:result][:content][0][:type] assert_includes response[:result][:content][0][:text], "Missing required arguments" assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", tool_arguments: {}, error: :missing_required_arguments, }) end test "tools/call returns tool execution error for invalid arguments when validate_tool_call_arguments is true" do configuration = Configuration.new(validate_tool_call_arguments: true) configuration.instrumentation_callback = instrumentation_helper.callback server = Server.new(tools: [TestTool], configuration: configuration) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "test_tool", arguments: { message: 123 }, }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_nil response[:error] assert(response[:result][:isError]) assert_equal "text", response[:result][:content][0][:type] assert_includes response[:result][:content][0][:text], "Invalid arguments" assert_instrumentation_data({ method: "tools/call", tool_name: "test_tool", tool_arguments: { message: 123 }, error: :invalid_schema, }) end test "tools/call returns tool execution error for nested schema validation failure" do server = Server.new( tools: [ComplexTypesTool], configuration: Configuration.new(validate_tool_call_arguments: true), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "complex_types_tool", arguments: { numbers: [1, 2, 3], strings: ["a", "b", "c"], objects: [{ name: 123 }], }, }, }, ) assert_nil response[:error] assert(response[:result][:isError]) assert_equal "text", response[:result][:content][0][:type] assert_includes response[:result][:content][0][:text], "Invalid arguments" end test "tools/call skips argument validation when validate_tool_call_arguments is false" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: false), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "test_tool", arguments: { message: 123 }, }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert response[:result], "Expected result key in response" assert_equal "text", response[:result][:content][0][:type] assert_equal "OK", response[:result][:content][0][:content] end test "tools/call validates arguments with complex types" do server = Server.new( tools: [ComplexTypesTool], configuration: Configuration.new(validate_tool_call_arguments: true), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "complex_types_tool", arguments: { numbers: [1, 2, 3], strings: ["a", "b", "c"], objects: [{ name: "test" }], }, }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert response[:result], "Expected result key in response" assert_equal "text", response[:result][:content][0][:type] assert_equal "OK", response[:result][:content][0][:content] end test "tools/call allows additional properties by default" do server = Server.new( tools: [TestTool], configuration: Configuration.new(validate_tool_call_arguments: true), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "test_tool", arguments: { message: "Hello, world!", other_property: "I am allowed", }, }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert response[:result], "Expected result key in response" assert_equal "text", response[:result][:content][0][:type] assert_equal "OK", response[:result][:content][0][:content] end test "tools/call returns tool execution error when additionalProperties set to false" do server = Server.new( tools: [TestToolWithAdditionalPropertiesSetToFalse], configuration: Configuration.new(validate_tool_call_arguments: true), ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "test_tool_with_additional_properties_set_to_false", arguments: { message: "Hello, world!", extra: 123, }, }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert_nil response[:error] assert(response[:result][:isError]) assert_equal "text", response[:result][:content][0][:type] assert_includes response[:result][:content][0][:text], "Invalid arguments" end test "tools/call skips output schema validation by default" do tool = Tool.define( name: "invalid_structured_content_tool", output_schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"], }, ) do Tool::Response.new( [{ type: "text", text: "ok" }], structured_content: { result: 123 }, ) end server = Server.new(tools: [tool]) response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "invalid_structured_content_tool" }, }) assert_nil response[:error] assert_equal({ result: 123 }, response[:result][:structuredContent]) end test "tools/call validates structuredContent against output schema when enabled" do tool = Tool.define( name: "valid_structured_content_tool", output_schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"], }, ) do Tool::Response.new( [{ type: "text", text: "ok" }], structured_content: { result: "success" }, ) end server = Server.new( tools: [tool], configuration: Configuration.new(validate_tool_call_results: true), ) response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "valid_structured_content_tool" }, }) assert_nil response[:error] assert_equal({ result: "success" }, response[:result][:structuredContent]) end test "tools/call returns JSON-RPC error for invalid structuredContent when output schema validation is enabled" do tool = Tool.define( name: "invalid_structured_content_tool", output_schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"], }, ) do Tool::Response.new( [{ type: "text", text: "ok" }], structured_content: { result: 123 }, ) end server = Server.new( tools: [tool], configuration: Configuration.new(validate_tool_call_results: true), ) response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "invalid_structured_content_tool" }, }) assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool invalid_structured_content_tool", response[:error][:data] end test "tools/call returns JSON-RPC error when output schema validation is enabled and structuredContent is missing" do tool = Tool.define( name: "missing_structured_content_tool", output_schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"], }, ) do Tool::Response.new([{ type: "text", text: "ok" }]) end server = Server.new( tools: [tool], configuration: Configuration.new(validate_tool_call_results: true), ) response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "missing_structured_content_tool" }, }) assert_nil response[:result] assert_equal(-32603, response[:error][:code]) assert_equal "Internal error", response[:error][:message] assert_equal "Internal error calling tool missing_structured_content_tool", response[:error][:data] end test "tools/call skips output schema validation for error responses" do tool = Tool.define( name: "error_response_tool", output_schema: { type: "object", properties: { result: { type: "string" } }, required: ["result"], }, ) do Tool::Response.new( [{ type: "text", text: "failed" }], error: true, structured_content: { result: 123 }, ) end server = Server.new( tools: [tool], configuration: Configuration.new(validate_tool_call_results: true), ) response = server.handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "error_response_tool" }, }) assert_nil response[:error] assert response[:result][:isError] assert_equal({ result: 123 }, response[:result][:structuredContent]) end test "tools/call returns JSON-RPC -32602 protocol error when tool is not found" do server = Server.new( tools: [TestTool], ) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "unknown_tool", arguments: {}, }, }, ) assert_nil response[:result] assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] assert_includes response[:error][:data], "Tool not found: unknown_tool" end test "#handle completion/complete returns default completion result" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg", value: "val" }, }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: { completion: { values: [], hasMore: false } }, }, response, ) end test "#handle completion/complete with custom handler for ref/prompt" do prompt = Prompt.define( name: "code_review", arguments: [Prompt::Argument.new(name: "language", required: true)], ) {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.completion_handler do |_params| { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "code_review" }, argument: { name: "language", value: "py" }, }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: { completion: { values: ["python", "pytorch", "pyside"], total: 10, hasMore: true } }, }, response, ) end test "#handle completion/complete with custom handler for ref/resource" do template = ResourceTemplate.new( uri_template: "file:///{path}", name: "file", ) server = Server.new( name: "test_server", resource_templates: [template], capabilities: { completions: {} }, ) server.completion_handler do |_params| { completion: { values: ["file:///src", "file:///spec"], hasMore: false } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/resource", uri: "file:///{path}" }, argument: { name: "path", value: "s" }, }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: { completion: { values: ["file:///src", "file:///spec"], hasMore: false } }, }, response, ) end test "#handle completion/complete passes context arguments to handler" do prompt = Prompt.define( name: "code_review", arguments: [ Prompt::Argument.new(name: "language", required: true), Prompt::Argument.new(name: "framework", required: false), ], ) {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) received_params = nil server.completion_handler do |params| received_params = params { completion: { values: ["flask"], hasMore: false } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "code_review" }, argument: { name: "framework", value: "fla" }, context: { arguments: { language: "python" } }, }, }) assert_equal({ language: "python" }, received_params.dig(:context, :arguments)) end test "#handle completion/complete truncates values exceeding 100 items" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.completion_handler do |_params| { completion: { values: (1..150).map(&:to_s), hasMore: false } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg", value: "" }, }, }) completion = response[:result][:completion] assert_equal 100, completion[:values].length assert_equal "1", completion[:values].first assert_equal "100", completion[:values].last assert(completion[:hasMore]) assert_equal 150, completion[:total] end test "#handle completion/complete returns error for nonexistent prompt" do server = Server.new( name: "test_server", capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "nonexistent" }, argument: { name: "arg", value: "val" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete returns error for nonexistent resource template" do server = Server.new( name: "test_server", capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/resource", uri: "unknown://template" }, argument: { name: "arg", value: "val" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete resource-not-found error carries the uri in error data" do server = Server.new( name: "test_server", capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/resource", uri: "unknown://template" }, argument: { name: "arg", value: "val" }, }, }) assert_equal(-32602, response[:error][:code]) assert_equal("Resource not found: unknown://template", response[:error][:message]) assert_equal({ uri: "unknown://template" }, response[:error][:data]) end test "#handle completion/complete returns error for invalid ref type" do server = Server.new( name: "test_server", capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/invalid" }, argument: { name: "arg", value: "val" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete returns error for missing ref" do server = Server.new( name: "test_server", capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: {}, argument: { name: "arg", value: "val" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete with custom handler for ref/resource with resource URI" do resource = Resource.new( uri: "file:///README.md", name: "readme", ) server = Server.new( name: "test_server", resources: [resource], capabilities: { completions: {} }, ) server.completion_handler do |_params| { completion: { values: ["file:///README.md"], hasMore: false } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/resource", uri: "file:///README.md" }, argument: { name: "path", value: "R" }, }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: { completion: { values: ["file:///README.md"], hasMore: false } }, }, response, ) end test "#handle completion/complete returns error for missing argument" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete returns error for missing argument value" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg" }, }, }) assert_equal(-32_602, response[:error][:code]) end test "#handle completion/complete returns default when handler returns nil" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.completion_handler do |_params| nil end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg", value: "" }, }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: { completion: { values: [], hasMore: false } }, }, response, ) end test "#handle completion/complete with string-keyed handler result" do prompt = Prompt.define(name: "test") {} server = Server.new( name: "test_server", prompts: [prompt], capabilities: { completions: {} }, ) server.completion_handler do |_params| { "completion" => { "values" => ["alpha", "beta"], "hasMore" => true } } end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg", value: "" }, }, }) assert_equal ["alpha", "beta"], response[:result][:completion][:values] assert response[:result][:completion][:hasMore] end test "#handle completion/complete returns invalid params for non-Hash params" do server = Server.new( name: "test_server", prompts: [], capabilities: { completions: {} }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: "invalid", }) assert_equal(-32602, response[:error][:code]) end test "#handle completion/complete returns error when completions capability is not declared" do server = Server.new( name: "test_server", prompts: [], ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "completion/complete", params: { ref: { type: "ref/prompt", name: "test" }, argument: { name: "arg", value: "" }, }, }) assert response[:error] assert_includes response[:error][:data], "completions" end test "#handle resources/subscribe returns empty result" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/subscribe", params: { uri: "https://example.com/resource" }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: {}, }, response, ) end test "#handle resources/unsubscribe returns empty result" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/unsubscribe", params: { uri: "https://example.com/resource" }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: {}, }, response, ) end test "#handle resources/subscribe with custom handler calls the handler" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) received_params = nil server.resources_subscribe_handler do |params| received_params = params {} end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/subscribe", params: { uri: "https://example.com/resource" }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: {}, }, response, ) assert_equal "https://example.com/resource", received_params[:uri] end test "#handle resources/unsubscribe with custom handler calls the handler" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) received_params = nil server.resources_unsubscribe_handler do |params| received_params = params {} end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/unsubscribe", params: { uri: "https://example.com/resource" }, }) assert_equal( { jsonrpc: "2.0", id: 2, result: {}, }, response, ) assert_equal "https://example.com/resource", received_params[:uri] end test "#handle resources/subscribe without uri returns -32602" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/subscribe", params: {}, }) assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] assert_includes response[:error][:data], "uri" end test "#handle resources/unsubscribe without uri returns -32602" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/unsubscribe", params: {}, }) assert_equal(-32602, response[:error][:code]) assert_equal "Invalid params", response[:error][:message] end # Builds an initialized server that advertises the `resources.subscribe` capability. def subscription_server server = Server.new(name: "test_server", capabilities: { resources: { subscribe: true } }) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) server end # Sends `method` (`resources/subscribe` or `resources/unsubscribe`) and returns the JSON-RPC result. def handle_subscription(server, method) server.handle({ jsonrpc: "2.0", id: 2, method: method, params: { uri: "https://example.com/resource" }, })[:result] end test "#handle resources/subscribe passes a handler-returned _meta through to the result" do server = subscription_server server.resources_subscribe_handler { |_params| { _meta: { "acme.example/subscriptionId" => "sub-1" } } } result = handle_subscription(server, "resources/subscribe") assert_equal({ _meta: { "acme.example/subscriptionId" => "sub-1" } }, result) end test "#handle resources/unsubscribe passes a handler-returned _meta through to the result" do server = subscription_server server.resources_unsubscribe_handler { |_params| { _meta: { "acme.example/note" => "gone" } } } result = handle_subscription(server, "resources/unsubscribe") assert_equal({ _meta: { "acme.example/note" => "gone" } }, result) end test "#handle resources/subscribe drops a handler-returned field that is not _meta" do # The spec's result defines no member other than `_meta`, so a top-level field the handler adds is not # a subscription protocol; it stays out of the response. server = subscription_server server.resources_subscribe_handler { |_params| { subscriptionId: "sub-1" } } result = handle_subscription(server, "resources/subscribe") assert_equal({}, result) end test "#handle resources/subscribe accepts a string _meta key from the handler" do server = subscription_server server.resources_subscribe_handler { |_params| { "_meta" => { "k" => "v" } } } result = handle_subscription(server, "resources/subscribe") assert_equal({ _meta: { "k" => "v" } }, result) end test "#handle resources/subscribe ignores a handler-returned _meta that is not a hash" do server = subscription_server server.resources_subscribe_handler { |_params| { _meta: "not-a-hash" } } result = handle_subscription(server, "resources/subscribe") assert_equal({}, result) end test "#handle resources/subscribe keeps an empty result when the handler returns a non-hash" do server = subscription_server server.resources_subscribe_handler { |_params| nil } result = handle_subscription(server, "resources/subscribe") assert_equal({}, result) end test "#handle resources/subscribe without uri does not invoke a custom handler" do server = Server.new( name: "test_server", capabilities: { resources: { subscribe: true } }, ) handler_called = false server.resources_subscribe_handler do |_params| handler_called = true {} end server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params }) server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) response = server.handle({ jsonrpc: "2.0", id: 2, method: "resources/subscribe", params: {}, }) assert_equal(-32602, response[:error][:code]) refute handler_called end test "tools/call with no args" do server = Server.new(tools: [@tool_with_no_args]) response = server.handle( { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "tool_with_no_args", }, }, ) assert_equal "2.0", response[:jsonrpc] assert_equal 1, response[:id] assert response[:result], "Expected result key in response" assert_equal "text", response[:result][:content][0][:type] assert_equal "OK", response[:result][:content][0][:content] end class TestTool < Tool tool_name "test_tool" description "a test tool for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"] }) class << self def call(server_context: nil, **kwargs) Tool::Response.new([{ type: "text", content: "OK" }]) end end end class TestToolWithAdditionalPropertiesSetToFalse < Tool tool_name "test_tool_with_additional_properties_set_to_false" description "a test tool with additionalProperties set to false for testing" input_schema({ properties: { message: { type: "string" } }, required: ["message"], additionalProperties: false }) class << self def call(server_context: nil, **kwargs) Tool::Response.new([{ type: "text", content: "OK" }]) end end end class ComplexTypesTool < Tool tool_name "complex_types_tool" description "a test tool with complex types" input_schema({ properties: { numbers: { type: "array", items: { type: "number" } }, strings: { type: "array", items: { type: "string" } }, objects: { type: "array", items: { type: "object", properties: { name: { type: "string" }, }, required: ["name"], }, }, }, required: ["numbers", "strings", "objects"], }) class << self def call(numbers:, strings:, objects:, server_context: nil) Tool::Response.new([{ type: "text", content: "OK" }]) end end end class JsonObjectValue def initialize(value) @value = value end def to_json(*args) @value.to_json(*args) end end test "server_context_with_meta uses accessor method, not ivar directly" do subclass = Class.new(Server) do def server_context { custom: "from_accessor" } end end server = subclass.new(name: "test", tools: []) received_context = nil server.define_tool(name: "ctx_tool") do |server_context:| received_context = server_context Tool::Response.new([{ type: "text", text: "ok" }]) end request = { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "ctx_tool", arguments: {} }, } server.handle(request) assert_equal "from_accessor", received_context[:custom] end test "#handle tools/call passes W3C trace context _meta keys through to the handler" do # Per SEP-414, `traceparent`, `tracestate`, and `baggage` are reserved # un-prefixed `_meta` keys and must never be stripped by the SDK. server = Server.new(name: "trace_test", tools: []) received_context = nil server.define_tool(name: "trace_tool") do |server_context:| received_context = server_context Tool::Response.new([{ type: "text", text: "ok" }]) end server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "trace_tool", arguments: {}, _meta: { traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", tracestate: "vendor=value", baggage: "userId=alice", progressToken: "token-1", }, }, }) meta = received_context[:_meta] assert_equal "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", meta[:traceparent] assert_equal "vendor=value", meta[:tracestate] assert_equal "userId=alice", meta[:baggage] assert_equal "token-1", meta[:progressToken] end test "#handle prompts/get passes W3C trace context _meta keys through to the handler" do server = Server.new(name: "trace_test", prompts: []) received_context = nil server.define_prompt(name: "trace_prompt", arguments: []) do |_args, server_context:| received_context = server_context Prompt::Result.new( description: "a prompt description", messages: [Prompt::Message.new(role: "user", content: Content::Text.new("a prompt message"))], ) end server.handle({ jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "trace_prompt", arguments: {}, _meta: { traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", tracestate: "vendor=value", baggage: "userId=alice", }, }, }) meta = received_context[:_meta] MCP::TraceContext::META_KEYS.each do |key| assert meta.key?(key.to_sym), "expected _meta to retain #{key}" end end test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, # so the server adds a serialized fallback when the tool provided no content blocks. server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "nil_content_tool") do Tool::Response.new(nil, structured_content: [1, 2]) end server.define_tool(name: "omitted_content_tool") do Tool::Response.new(structured_content: [1, 2]) end ["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index| response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: index + 1, params: { name: tool_name, arguments: {} }, }) assert_equal [1, 2], response.dig(:result, :structuredContent) assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) end end test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "array_tool") do Tool::Response.new([{ type: "text", text: "two items" }], structured_content: [1, 2]) end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "array_tool", arguments: {} }, }) assert_equal [{ type: "text", text: "two items" }], response.dig(:result, :content) end test "#handle tools/call leaves object structuredContent without a text fallback" do server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "object_tool") do Tool::Response.new(nil, structured_content: { answer: 42 }) end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "object_tool", arguments: {} }, }) assert_equal({ answer: 42 }, response.dig(:result, :structuredContent)) assert_empty response.dig(:result, :content) end test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do structured_content = JsonObjectValue.new(answer: 42) server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "object_tool") do Tool::Response.new(nil, structured_content: structured_content) end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "object_tool", arguments: {} }, }) assert_equal structured_content, response.dig(:result, :structuredContent) assert_empty response.dig(:result, :content) end test "#handle tools/call preserves explicit empty content for non-object structuredContent" do server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "array_tool") do Tool::Response.new([], structured_content: [1, 2]) end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "array_tool", arguments: {} }, }) assert_equal [1, 2], response.dig(:result, :structuredContent) assert_empty response.dig(:result, :content) end test "#handle tools/call preserves explicit empty content for object-like structuredContent" do structured_content = JsonObjectValue.new(answer: 42) server = Server.new(name: "structured_test", tools: []) server.define_tool(name: "object_tool") do Tool::Response.new([], structured_content: structured_content) end response = server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "object_tool", arguments: {} }, }) assert_equal structured_content, response.dig(:result, :structuredContent) assert_empty response.dig(:result, :content) end test "list results carry ttlMs and cacheScope when ttl_ms is configured" do # SEP-2549 cache hints. The unset cacheScope fills as "private", the side that cannot # leak a user-dependent result through a shared cache, matching the TypeScript and # Python SDK defaults (the spec names no default scope). server = Server.new(name: "ttl_test", ttl_ms: 5000) ["tools/list", "prompts/list", "resources/list", "resources/templates/list"].each_with_index do |method, index| result = server.handle({ jsonrpc: "2.0", method: method, id: index + 1 })[:result] assert_equal 5000, result[:ttlMs], "#{method} missing ttlMs" assert_equal "private", result[:cacheScope], "#{method} missing cacheScope" end end test "resources/read carries cache hints when cache_scope is configured" do # The ttlMs default is 0 (do not cache), the only universally safe value. server = Server.new(name: "ttl_test", cache_scope: "private") result = server.handle({ jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: "file:///x" }, })[:result] assert_equal 0, result[:ttlMs] assert_equal "private", result[:cacheScope] end test "results omit cache hints when ttl_ms and cache_scope are not configured" do # Wire-format regression: opt-in emission keeps default output unchanged. list_result = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] read_result = @server.handle({ jsonrpc: "2.0", method: "resources/read", id: 2, params: { uri: "file:///x" }, })[:result] refute list_result.key?(:ttlMs) refute list_result.key?(:cacheScope) refute read_result.key?(:ttlMs) refute read_result.key?(:cacheScope) end test "cache hints appear alongside nextCursor when paginating" do tool_a = Tool.define(name: "tool_a", description: "Tool A") tool_b = Tool.define(name: "tool_b", description: "Tool B") server = Server.new(name: "ttl_test", tools: [tool_a, tool_b], page_size: 1, ttl_ms: 1000, cache_scope: "private") result = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 })[:result] assert_not_nil result[:nextCursor] assert_equal 1000, result[:ttlMs] assert_equal "private", result[:cacheScope] end test "a resources_read_handler can override the server-level cache hints per result" do server = Server.new(name: "ttl_test", ttl_ms: 5000) server.resources_read_handler do |params| { contents: [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }], ttlMs: 60_000 } end result = server.handle({ jsonrpc: "2.0", method: "resources/read", id: 1, params: { uri: "file:///x" }, })[:result] assert_equal 60_000, result[:ttlMs] assert_equal "private", result[:cacheScope] assert_equal [{ uri: "file:///x", mimeType: "text/plain", text: "hi" }], result[:contents] end test "ttl_ms and cache_scope writers reject invalid values" do assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: -1) } assert_raises(ArgumentError) { Server.new(name: "ttl_test", ttl_ms: 1.5) } assert_raises(ArgumentError) { Server.new(name: "ttl_test", cache_scope: "internal") } end test "#handle tools/list returns paginated results when page_size is set" do tool_a = Tool.define(name: "tool_a", title: "Tool A", description: "Tool A") tool_b = Tool.define(name: "tool_b", title: "Tool B", description: "Tool B") tool_c = Tool.define(name: "tool_c", title: "Tool C", description: "Tool C") server = Server.new( name: "pagination_test", tools: [tool_a, tool_b, tool_c], page_size: 2, ) first_request = { jsonrpc: "2.0", method: "tools/list", id: 1 } first_response = server.handle(first_request) first_result = first_response[:result] assert_equal 2, first_result[:tools].size assert_equal "tool_a", first_result[:tools][0][:name] assert_equal "tool_b", first_result[:tools][1][:name] assert_not_nil first_result[:nextCursor] second_request = { jsonrpc: "2.0", method: "tools/list", id: 2, params: { cursor: first_result[:nextCursor] } } second_response = server.handle(second_request) second_result = second_response[:result] assert_equal 1, second_result[:tools].size assert_equal "tool_c", second_result[:tools][0][:name] # Final page omits the nextCursor key entirely (not just sets it to nil). refute second_result.key?(:nextCursor) end test "#handle tools/list returns all tools when page_size is not set" do response = @server.handle({ jsonrpc: "2.0", method: "tools/list", id: 1 }) result = response[:result] assert_kind_of Array, result[:tools] assert_nil result[:nextCursor] end test "#handle tools/list returns error for invalid cursor" do server = Server.new(name: "pagination_test", tools: [@tool], page_size: 1) request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: { cursor: "!!!invalid!!!" } } response = server.handle(request) assert_not_nil response[:error] assert_equal(-32602, response[:error][:code]) end test "#handle prompts/list returns paginated results when page_size is set" do prompt_a = Prompt.define(name: "prompt_a", title: "Prompt A", description: "A") { Prompt::Result.new(description: "A", messages: []) } prompt_b = Prompt.define(name: "prompt_b", title: "Prompt B", description: "B") { Prompt::Result.new(description: "B", messages: []) } server = Server.new(name: "pagination_test", prompts: [prompt_a, prompt_b], page_size: 1) first_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 1 }) first_result = first_response[:result] assert_equal 1, first_result[:prompts].size assert_equal "prompt_a", first_result[:prompts][0][:name] assert_not_nil first_result[:nextCursor] second_response = server.handle({ jsonrpc: "2.0", method: "prompts/list", id: 2, params: { cursor: first_result[:nextCursor] } }) second_result = second_response[:result] assert_equal 1, second_result[:prompts].size assert_equal "prompt_b", second_result[:prompts][0][:name] assert_nil second_result[:nextCursor] end test "#handle resources/list returns paginated results when page_size is set" do resource_a = Resource.new(uri: "https://a.invalid", name: "a", description: "A", mime_type: "text/plain") resource_b = Resource.new(uri: "https://b.invalid", name: "b", description: "B", mime_type: "text/plain") server = Server.new(name: "pagination_test", resources: [resource_a, resource_b], page_size: 1) first_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 1 }) first_result = first_response[:result] assert_equal 1, first_result[:resources].size assert_equal "a", first_result[:resources][0][:name] assert_not_nil first_result[:nextCursor] second_response = server.handle({ jsonrpc: "2.0", method: "resources/list", id: 2, params: { cursor: first_result[:nextCursor] } }) second_result = second_response[:result] assert_equal 1, second_result[:resources].size assert_equal "b", second_result[:resources][0][:name] assert_nil second_result[:nextCursor] end test "Server.new raises ArgumentError when page_size is zero" do assert_raises(ArgumentError) do Server.new(name: "test", page_size: 0) end end test "Server.new raises ArgumentError when page_size is negative" do assert_raises(ArgumentError) do Server.new(name: "test", page_size: -1) end end test "Server.new raises ArgumentError when page_size is non-Integer" do assert_raises(ArgumentError) do Server.new(name: "test", page_size: "10") end end test "page_size= raises ArgumentError for invalid values" do server = Server.new(name: "test") assert_raises(ArgumentError) { server.page_size = 0 } assert_raises(ArgumentError) { server.page_size = -1 } assert_raises(ArgumentError) { server.page_size = "5" } server.page_size = nil server.page_size = 10 assert_equal 10, server.page_size end test "#handle tools/list returns -32602 for non-Hash params" do server = Server.new(name: "test", tools: [@tool], page_size: 1) request = { jsonrpc: "2.0", method: "tools/list", id: 1, params: [1, 2, 3] } response = server.handle(request) assert_not_nil response[:error] assert_equal(-32602, response[:error][:code]) end test "#handle_json tools/list returns -32602 for numeric cursor (spec requires string)" do server = Server.new(name: "test", tools: [@tool], page_size: 1) request_json = '{"jsonrpc":"2.0","method":"tools/list","id":1,"params":{"cursor":0}}' response = JSON.parse(server.handle_json(request_json), symbolize_names: true) assert_not_nil response[:error] assert_equal(-32602, response[:error][:code]) end test "#handle resources/templates/list returns paginated results when page_size is set" do template_a = ResourceTemplate.new(uri_template: "https://a.invalid/{id}", name: "a", description: "A", mime_type: "text/plain") template_b = ResourceTemplate.new(uri_template: "https://b.invalid/{id}", name: "b", description: "B", mime_type: "text/plain") server = Server.new(name: "pagination_test", resource_templates: [template_a, template_b], page_size: 1) first_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 1 }) first_result = first_response[:result] assert_equal 1, first_result[:resourceTemplates].size assert_equal "a", first_result[:resourceTemplates][0][:name] assert_not_nil first_result[:nextCursor] second_response = server.handle({ jsonrpc: "2.0", method: "resources/templates/list", id: 2, params: { cursor: first_result[:nextCursor] } }) second_result = second_response[:result] assert_equal 1, second_result[:resourceTemplates].size assert_equal "b", second_result[:resourceTemplates][0][:name] assert_nil second_result[:nextCursor] end test "an MCP Apps server advertises the extension and serves ui:// templates" do # SEP-1865: the extension rides the SEP-2133 `capabilities.extensions` machinery, # UI templates are ordinary resources, and tools link to them via `_meta.ui.resourceUri`. dashboard = Apps.ui_resource(uri: "ui://weather/dashboard", name: "weather_dashboard") capabilities = Server::Capabilities.new capabilities.support_tools capabilities.support_resources capabilities.support_extensions(Apps.capability) server = Server.new(name: "apps_test", capabilities: capabilities, resources: [dashboard]) server.define_tool( name: "get_weather", meta: Apps.tool_meta(resource_uri: "ui://weather/dashboard"), ) do Tool::Response.new([{ type: "text", text: "Sunny" }]) end server.resources_read_handler do |params| [{ uri: params[:uri], mimeType: Apps::RESOURCE_MIME_TYPE, text: "dashboard" }] end init_response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2025-11-25", clientInfo: { name: "host", version: "1.0" }, capabilities: { extensions: Apps.capability }, }, }) tools_response = server.handle({ jsonrpc: "2.0", method: "tools/list", id: 2, }) read_response = server.handle({ jsonrpc: "2.0", method: "resources/read", id: 3, params: { uri: "ui://weather/dashboard" }, }) assert_equal( { mimeTypes: [Apps::RESOURCE_MIME_TYPE] }, init_response.dig(:result, :capabilities, :extensions, Apps::EXTENSION_ID), ) tool_entry = tools_response.dig(:result, :tools, 0) assert_equal "ui://weather/dashboard", tool_entry.dig(:_meta, :ui, :resourceUri) assert_equal "dashboard", read_response.dig(:result, :contents, 0, :text) # The server can gate the text-only fallback on the client's declaration. assert Apps.client_supports?(server.client_capabilities) end test "an MCP Apps tool falls back to text for clients without the extension" do capabilities = Server::Capabilities.new capabilities.support_extensions(Apps.capability) server = Server.new(name: "apps_test", capabilities: capabilities) server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2025-11-25", clientInfo: { name: "plain_client", version: "1.0" }, capabilities: {}, }, }) refute Apps.client_supports?(server.client_capabilities) end # SEP-2322 makes `resultType` REQUIRED on every result of a 2026-07-28 server; # `"complete"` is the standard shape, and legacy results stay unstamped. test "modern results carry resultType complete" do server = Server.new(name: "result_type_test", tools: [result_type_tool]) # Every method here has to be one the modern lifecycle still has. `ping` does not qualify: # SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result. [ modern_request(Methods::TOOLS_LIST, {}), modern_request(Methods::PROMPTS_LIST, {}), modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }), ].each do |request| response = server.handle(request) assert_equal "complete", response.dig(:result, :resultType), "expected stamp for #{request[:method]}" end end test "legacy results carry no resultType" do server = Server.new(name: "result_type_test", tools: [result_type_tool]) [ { jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }, { jsonrpc: "2.0", method: Methods::PING, id: 2 }, { jsonrpc: "2.0", method: Methods::TOOLS_CALL, id: 3, params: { name: "result_type_tool", arguments: {} } }, ].each do |request| response = server.handle(request) refute response[:result].key?(:resultType), "expected no stamp for #{request[:method]}" end end test "results after a counter-offered modern initialize carry no resultType" do # The scenario from issue #512: a client asking `initialize` for 2026-07-28 lands on # 2025-11-25, where `resultType` does not exist, so its absence is the correct shape # (clients MUST read an absent `resultType` as "complete" on that revision). server = Server.new(name: "result_type_test", tools: [result_type_tool]) session = ServerSession.new(server: server, transport: mock) init_response = server.handle( { jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params(protocolVersion: "2026-07-28") }, session: session, ) response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 2 }, session: session) assert_equal Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION, init_response.dig(:result, :protocolVersion) refute response[:result].key?(:resultType) end test "server/discover carries resultType complete" do server = Server.new(name: "result_type_test", tools: [result_type_tool]) response = server.handle({ jsonrpc: "2.0", method: Methods::SERVER_DISCOVER, id: 1 }) assert_equal "complete", response.dig(:result, :resultType) end # SEP-2549 makes the `ttlMs`/`cacheScope` cache hints REQUIRED on cacheable results at 2026-07-28; # unset hints get the spec defaults, and stable protocol versions keep the opt-in emission. test "modern cacheable results carry the default cache hints" do server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) response = server.handle(modern_request(Methods::TOOLS_LIST, {})) assert_equal 0, response.dig(:result, :ttlMs) assert_equal "private", response.dig(:result, :cacheScope) end test "modern non-cacheable results carry no cache hints" do server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) # `tools/call` is the non-cacheable example here: the spec types `CallToolResult` as a plain `Result`, # while the results it types as `CacheableResult` are the discover, list, and read families. # `ping` cannot stand in for it - SEP-2575 removed that method, so a modern request naming it is refused. response = server.handle(modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} })) refute response[:result].key?(:ttlMs) refute response[:result].key?(:cacheScope) end test "configured cache hints win over the modern defaults" do server = Server.new(name: "cache_hints_test", tools: [result_type_tool], ttl_ms: 5000, cache_scope: "private") response = server.handle(modern_request(Methods::TOOLS_LIST, {})) assert_equal 5000, response.dig(:result, :ttlMs) assert_equal "private", response.dig(:result, :cacheScope) end test "modern resources/read results carry the default cache hints" do server = Server.new(name: "cache_hints_test") server.resources_read_handler do |params| [{ uri: params[:uri], mimeType: "text/plain", text: "hi" }] end response = server.handle(modern_request(Methods::RESOURCES_READ, { uri: "file:///x" })) assert_equal 0, response.dig(:result, :ttlMs) assert_equal "private", response.dig(:result, :cacheScope) end test "legacy cacheable results keep the opt-in cache hint emission" do server = Server.new(name: "cache_hints_test", tools: [result_type_tool]) response = server.handle({ jsonrpc: "2.0", method: Methods::TOOLS_LIST, id: 1 }) refute response[:result].key?(:ttlMs) refute response[:result].key?(:cacheScope) 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 def result_type_tool @result_type_tool ||= Tool.define(name: "result_type_tool") do |**| Tool::Response.new([{ type: "text", text: "ok" }]) end end end end