ruby-sdk/test/mcp/server_test.rb
Koichi ITO 2c295fe930 Document That Tool Arguments Arrive with Symbol Keys
## Motivation and Context

The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key,
at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs
a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream.
Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments,
and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path.
A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then
returns nil against a real client.

Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for
the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and
the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API.
This change therefore documents the existing contract rather than altering behavior.

Closes #412

## How Has This Been Tested?

New regression tests in `test/mcp/server_test.rb`:

- `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level,
  and string-key access on a nested value returns nil.
- A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime.

## Breaking Changes

None. This is a documentation and test change with no behavior change.
The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling.

## Additional context

Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement
that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and
can be designed separately, since it is a behavior and dependency decision rather than a documentation fix.
2026-06-16 03:09:55 +09:00

3048 lines
99 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
module MCP
class ServerTest < ActiveSupport::TestCase
include InstrumentationTestHelper
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 initialize request returns protocol info, server info, and capabilities" do
request = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
}
response = @server.handle(request)
refute_nil response
expected_result = {
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: Configuration::LATEST_STABLE_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" })
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 })
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 })
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: {
clientInfo: { name: "test_client", version: "1.0.0" },
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: {
clientInfo: { name: "test_client", version: "1.0.0" },
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: {
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: {
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: { 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: { 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 "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_match(/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_match(/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_match(/Internal error calling tool tool_with_faulty_schema: Unexpected schema error/, 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)
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)
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 "#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
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 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_equal("around ensure boom", 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_includes(response[:error][:data], "frozen failure")
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 default protocol version when not configured" do
request = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
}
response = @server.handle(request)
assert_equal Configuration::LATEST_STABLE_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,
}
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,
}
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,
}
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,
}
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,
}
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,
}
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,
}
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 "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,
}
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: {
protocolVersion: "2025-06-18",
},
}
response = server.handle(request)
assert_equal "2025-06-18", response[:result][:protocolVersion]
end
test "server falls back to default version when client requests unsupported version" do
server = Server.new(name: "test_server")
request = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: {
protocolVersion: "1999-01-01",
},
}
response = server.handle(request)
assert_equal Configuration::LATEST_STABLE_PROTOCOL_VERSION, 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: {
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: {
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: {
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_match(/Internal error calling tool invalid_structured_content_tool: Invalid result:/, 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_match(/Internal error calling tool missing_structured_content_tool: Invalid result:/, 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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 })
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 "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
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/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
end
end