ruby-sdk/conformance/server.rb
Koichi ITO ebed930927
Merge pull request #495 from koic/subscriptions_listen
Serve the `subscriptions/listen` notification stream per SEP-2575
2026-08-13 23:05:35 +09:00

1107 lines
37 KiB
Ruby

# frozen_string_literal: true
require "rackup"
require "json"
require "securerandom"
require "set"
require "uri"
require_relative "../lib/mcp"
module Conformance
# 1x1 red PNG pixel (matches TypeScript SDK and Python SDK)
BASE64_1X1_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
# Minimal WAV file (matches TypeScript SDK and Python SDK)
BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
# SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start,
# so every round reads the client's earlier answers back through these.
module Mrtr
module_function
# The transport parses JSON with symbolized keys, but retries may also arrive through
# `Server#handle` with string keys; tolerate both like the SDK's own readers.
def read(hash, key)
return unless hash.is_a?(Hash)
value = hash[key.to_sym]
value.nil? ? hash[key.to_s] : value
end
# The `content` of an accepted `elicitation/create` response, or `nil` for a declined,
# malformed, or missing one (a `nil` return re-requests the input).
def accepted_content(response)
content = read(response, :content)
content if read(response, :action) == "accept" && content.is_a?(Hash)
end
# The `text` of a `sampling/createMessage` response, or `nil` when malformed.
def sampled_text(response)
text = read(read(response, :content), :text)
text if text.is_a?(String)
end
def parse_state(request_state)
return unless request_state.is_a?(String)
parsed = JSON.parse(request_state)
parsed.is_a?(Hash) ? parsed : nil
rescue JSON::ParserError
nil
end
end
module Tools
class TestSimpleText < MCP::Tool
tool_name "test_simple_text"
description "A tool that returns simple text content"
class << self
def call(**_args)
MCP::Tool::Response.new([MCP::Content::Text.new("This is a simple text response for testing.").to_h])
end
end
end
class TestImageContent < MCP::Tool
tool_name "test_image_content"
description "A tool that returns image content"
class << self
def call(**_args)
MCP::Tool::Response.new([MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h])
end
end
end
class TestAudioContent < MCP::Tool
tool_name "test_audio_content"
description "A tool that returns audio content"
class << self
def call(**_args)
MCP::Tool::Response.new([MCP::Content::Audio.new(BASE64_MINIMAL_WAV, "audio/wav").to_h])
end
end
end
class TestEmbeddedResource < MCP::Tool
tool_name "test_embedded_resource"
description "A tool that returns embedded resource content"
class << self
def call(**_args)
text_contents = MCP::Resource::TextContents.new(
uri: "test://embedded-resource",
mime_type: "text/plain",
text: "This is an embedded resource content.",
)
MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(text_contents).to_h])
end
end
end
class TestMultipleContentTypes < MCP::Tool
tool_name "test_multiple_content_types"
description "A tool that returns multiple content types"
class << self
def call(**_args)
MCP::Tool::Response.new([
MCP::Content::Text.new("Multiple content types test:").to_h,
MCP::Content::Image.new(BASE64_1X1_PNG, "image/png").to_h,
MCP::Content::EmbeddedResource.new(
MCP::Resource::TextContents.new(
uri: "test://mixed-content-resource",
mime_type: "application/json",
text: '{"test":"data","value":123}',
),
).to_h,
])
end
end
end
class TestErrorHandling < MCP::Tool
tool_name "test_error_handling"
description "A tool that intentionally returns an error for testing"
class << self
def call(**_args)
MCP::Tool::Response.new(
[MCP::Content::Text.new("This tool intentionally returns an error for testing").to_h],
error: true,
)
end
end
end
class JsonSchema202012Tool < MCP::Tool
tool_name "json_schema_2020_12_tool"
description "Tool with JSON Schema 2020-12 features"
input_schema(
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
address: {
type: "object",
properties: {
street: { type: "string" },
city: { type: "string" },
},
},
},
properties: {
name: { type: "string" },
address: { "$ref": "#/$defs/address" },
},
additionalProperties: false,
)
class << self
def call(**_args)
MCP::Tool::Response.new([MCP::Content::Text.new("Processed with JSON Schema 2020-12").to_h])
end
end
end
class TestToolWithLogging < MCP::Tool
tool_name "test_tool_with_logging"
description "A tool that sends log messages during execution"
class << self
def call(server_context:, **_args)
server_context.notify_log_message(data: "Tool execution started", level: "info", logger: "test_logger")
sleep(0.05) # Required by the conformance test to verify clients handle interleaved notifications (same as TypeScript SDK).
server_context.notify_log_message(data: "Tool processing data", level: "info", logger: "test_logger")
sleep(0.05) # Same as above.
server_context.notify_log_message(data: "Tool execution completed", level: "info", logger: "test_logger")
MCP::Tool::Response.new([MCP::Content::Text.new("Logging complete (3 messages sent)").to_h])
end
end
end
class TestToolWithProgress < MCP::Tool
tool_name "test_tool_with_progress"
description "A tool that reports progress notifications"
class << self
def call(server_context:, **_args)
server_context.report_progress(0, total: 100)
server_context.report_progress(50, total: 100)
server_context.report_progress(100, total: 100)
MCP::Tool::Response.new([MCP::Content::Text.new("Progress complete").to_h])
end
end
end
class TestSampling < MCP::Tool
tool_name "test_sampling"
description "A tool that requests LLM sampling from the client"
input_schema(
properties: { prompt: { type: "string" } },
required: ["prompt"],
)
class << self
def call(prompt:, server_context:)
result = server_context.create_sampling_message(
messages: [{ role: "user", content: { type: "text", text: prompt } }],
max_tokens: 100,
)
model = result[:model] || "unknown"
text = result.dig(:content, :text) || ""
MCP::Tool::Response.new([MCP::Content::Text.new("LLM response: #{text} (model: #{model})").to_h])
end
end
end
class TestElicitation < MCP::Tool
tool_name "test_elicitation"
description "A tool that requests user input from the client"
input_schema(
properties: { message: { type: "string" } },
required: ["message"],
)
class << self
def call(server_context:, message:)
result = server_context.create_form_elicitation(
message: message,
requested_schema: {
type: "object",
properties: {
username: { type: "string", description: "User's response" },
email: { type: "string", description: "User's email address" },
},
required: ["username", "email"],
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("User response: #{result}").to_h])
end
end
end
class TestElicitationSep1034Defaults < MCP::Tool
tool_name "test_elicitation_sep1034_defaults"
description "A tool that tests elicitation with default values"
class << self
def call(server_context:, **_args)
result = server_context.create_form_elicitation(
message: "Please provide your information (with defaults)",
requested_schema: {
type: "object",
properties: {
name: { type: "string", default: "John Doe" },
age: { type: "integer", default: 30 },
score: { type: "number", default: 95.5 },
status: MCP::Elicitation::EnumSchema.untitled_single_select(
values: ["active", "inactive", "pending"], default: "active",
).to_h,
verified: { type: "boolean", default: true },
},
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h])
end
end
end
class TestElicitationSep1330Enums < MCP::Tool
tool_name "test_elicitation_sep1330_enums"
description "A tool that tests elicitation with enum schemas"
class << self
def call(server_context:, **_args)
# Built with the SEP-1330 builders so the conformance run exercises the same schemas
# the SDK produces for users.
result = server_context.create_form_elicitation(
message: "Please select options",
requested_schema: {
type: "object",
properties: {
untitledSingle: MCP::Elicitation::EnumSchema.untitled_single_select(
values: ["option1", "option2", "option3"],
).to_h,
titledSingle: MCP::Elicitation::EnumSchema.titled_single_select(
options: [
{ value: "value1", title: "First Option" },
{ value: "value2", title: "Second Option" },
{ value: "value3", title: "Third Option" },
],
).to_h,
legacyEnum: MCP::Elicitation::EnumSchema.legacy_titled(
values: ["opt1", "opt2", "opt3"],
value_titles: ["Option One", "Option Two", "Option Three"],
).to_h,
untitledMulti: MCP::Elicitation::EnumSchema.untitled_multi_select(
values: ["option1", "option2", "option3"],
).to_h,
titledMulti: MCP::Elicitation::EnumSchema.titled_multi_select(
options: [
{ value: "value1", title: "First Choice" },
{ value: "value2", title: "Second Choice" },
{ value: "value3", title: "Third Choice" },
],
).to_h,
},
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h])
end
end
end
class TestReconnection < MCP::Tool
tool_name "test_reconnection"
description "A tool that triggers SSE stream closure to test client reconnection behavior"
class << self
def call(**_args)
MCP::Tool::Response.new([MCP::Content::Text.new("Reconnection test completed").to_h])
end
end
end
class TestMissingCapability < MCP::Tool
tool_name "test_missing_capability"
description "A tool that requires the sampling client capability (SEP-2575)"
class << self
def call(server_context:, **_args)
server_context.require_client_capability!(:sampling)
MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h])
end
end
end
class TestStreamingElicitation < MCP::Tool
tool_name "test_streaming_elicitation"
description "A tool whose response stream carries only notifications, never independent requests (SEP-2575)"
class << self
def call(server_context:, **_args)
# No independent server-to-client request is ever written to the response stream;
# the progress notification only goes out when the request carried a progressToken.
server_context.report_progress(50, total: 100)
MCP::Tool::Response.new([MCP::Content::Text.new("Streaming complete").to_h])
end
end
end
class TestInputRequiredResultElicitation < MCP::Tool
tool_name "test_input_required_result_elicitation"
description "A tool that asks for the user's name through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
unless name.is_a?(String)
return MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
},
)
end
MCP::Tool::Response.new([MCP::Content::Text.new("Hello, #{name}!").to_h])
end
end
end
class TestInputRequiredResultSampling < MCP::Tool
tool_name "test_input_required_result_sampling"
description "A tool that asks for an LLM completion through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
text = Mrtr.sampled_text(server_context.input_response("sample_request"))
unless text
return MCP::Server::InputRequiredResult.new(
input_requests: {
sample_request: {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }],
maxTokens: 100,
},
},
},
)
end
MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h])
end
end
end
class TestInputRequiredResultListRoots < MCP::Tool
tool_name "test_input_required_result_list_roots"
description "A tool that asks for the client's roots through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
roots = Mrtr.read(server_context.input_response("roots_request"), :roots)
unless roots.is_a?(Array)
return MCP::Server::InputRequiredResult.new(
input_requests: { roots_request: { method: "roots/list" } },
)
end
uris = roots.filter_map { |root| Mrtr.read(root, :uri) }
MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h])
end
end
end
class TestInputRequiredResultRequestState < MCP::Tool
tool_name "test_input_required_result_request_state"
description "A tool whose input_required result carries an opaque requestState (SEP-2322)"
class << self
def call(server_context:, **_args)
state = Mrtr.parse_state(server_context.request_state)
confirmed = Mrtr.accepted_content(server_context.input_response("confirm"))
if state && state["kind"] == "request-state" && confirmed
return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h])
end
MCP::Server::InputRequiredResult.new(
input_requests: {
confirm: {
method: "elicitation/create",
params: {
message: "Confirm to continue",
requestedSchema: {
type: "object",
properties: { ok: { type: "boolean" } },
},
},
},
},
request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }),
)
end
end
end
class TestInputRequiredResultMultipleInputs < MCP::Tool
tool_name "test_input_required_result_multiple_inputs"
description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
greeting = Mrtr.sampled_text(server_context.input_response("greeting"))
roots = Mrtr.read(server_context.input_response("client_roots"), :roots)
if name.is_a?(String) && greeting && roots.is_a?(Array)
return MCP::Tool::Response.new(
[MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h],
)
end
MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
greeting: {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }],
maxTokens: 50,
},
},
client_roots: { method: "roots/list" },
},
request_state: JSON.generate({ kind: "multiple-inputs" }),
)
end
end
end
class TestInputRequiredResultMultiRound < MCP::Tool
tool_name "test_input_required_result_multi_round"
description "A tool that needs two elicitation rounds before completing (SEP-2322)"
class << self
def call(server_context:, **_args)
state = Mrtr.parse_state(server_context.request_state)
case state && state["round"]
when 1
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
return second_round(name) if name.is_a?(String)
when 2
content = Mrtr.accepted_content(server_context.input_response("favorite_color"))
color = content && Mrtr.read(content, :color)
if color.is_a?(String)
return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h])
end
end
first_round
end
private
def first_round
MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
},
request_state: JSON.generate({ round: 1 }),
)
end
def second_round(name)
MCP::Server::InputRequiredResult.new(
input_requests: {
favorite_color: {
method: "elicitation/create",
params: {
message: "What is your favorite color?",
requestedSchema: {
type: "object",
properties: { color: { type: "string" } },
required: ["color"],
},
},
},
},
request_state: JSON.generate({ round: 2, name: name }),
)
end
end
end
class TestInputRequiredResultTamperedState < MCP::Tool
tool_name "test_input_required_result_tampered_state"
description "A tool whose sealed requestState rejects tampered echoes (SEP-2322)"
class << self
# A tampered `requestState` never reaches this handler: the server-level unsealing rejects it with -32602
# before dispatch, so the tool body is a plain MRTR flow.
def call(server_context:, **_args)
state = Mrtr.parse_state(server_context.request_state)
confirmed = Mrtr.accepted_content(server_context.input_response("confirm"))
if state && state["kind"] == "tamper-check" && confirmed
return MCP::Tool::Response.new([MCP::Content::Text.new("requestState integrity verified").to_h])
end
MCP::Server::InputRequiredResult.new(
input_requests: {
confirm: {
method: "elicitation/create",
params: {
message: "Confirm to continue",
requestedSchema: {
type: "object",
properties: { ok: { type: "boolean" } },
},
},
},
},
request_state: JSON.generate({ kind: "tamper-check", nonce: SecureRandom.hex(8) }),
)
end
end
end
class TestInputRequiredResultCapabilities < MCP::Tool
tool_name "test_input_required_result_capabilities"
description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)"
class << self
def call(server_context:, **_args)
capabilities = server_context.client_capabilities || {}
elicited = Mrtr.accepted_content(server_context.input_response("elicit_input"))
sampled = Mrtr.sampled_text(server_context.input_response("sample_input"))
requests = {}
if Mrtr.read(capabilities, :elicitation) && !elicited
requests[:elicit_input] = {
method: "elicitation/create",
params: {
message: "Provide a value",
requestedSchema: {
type: "object",
properties: { value: { type: "string" } },
},
},
}
end
if Mrtr.read(capabilities, :sampling) && !sampled
requests[:sample_input] = {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "Say hello" } }],
maxTokens: 50,
},
}
end
if requests.empty?
MCP::Tool::Response.new(
[MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h],
)
else
MCP::Server::InputRequiredResult.new(input_requests: requests)
end
end
end
end
class TestLoggingTool < MCP::Tool
tool_name "test_logging_tool"
description "A tool that logs only for requests opting in via the _meta logLevel (SEP-2575)"
class << self
def call(server_context:, **_args)
# On modern requests the notification is dropped unless `io.modelcontextprotocol/logLevel` authorized it,
# so the call itself is unconditional.
server_context.notify_log_message(data: "Log level honored", level: "info", logger: "conformance")
MCP::Tool::Response.new([MCP::Content::Text.new("Logging evaluated").to_h])
end
end
end
class TestTriggerToolChange < MCP::Tool
tool_name "test_trigger_tool_change"
description "A diagnostic tool that broadcasts notifications/tools/list_changed to listen streams (SEP-2575)"
class << self
# The broadcast alone exercises the `subscriptions/listen` delivery, so no actual
# tool-list mutation is needed, matching the suite's TypeScript reference fixture.
def call(server_context:, **_args)
server_context.notify_tools_list_changed
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
end
end
end
class TestTriggerPromptChange < MCP::Tool
tool_name "test_trigger_prompt_change"
description "A diagnostic tool that broadcasts notifications/prompts/list_changed to listen streams (SEP-2575)"
class << self
def call(server_context:, **_args)
server_context.notify_prompts_list_changed
MCP::Tool::Response.new([MCP::Content::Text.new("Mutation triggered").to_h])
end
end
end
end
module Prompts
class TestSimplePrompt < MCP::Prompt
prompt_name "test_simple_prompt"
description "A simple prompt for testing with no arguments"
class << self
def template(_args, server_context: nil)
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("This is a simple prompt for testing."),
),
],
)
end
end
end
class TestPromptWithArguments < MCP::Prompt
prompt_name "test_prompt_with_arguments"
description "A prompt with required arguments for testing"
arguments [
MCP::Prompt::Argument.new(name: "arg1", description: "First test argument", required: true),
MCP::Prompt::Argument.new(name: "arg2", description: "Second test argument", required: true),
]
class << self
def template(args, server_context: nil)
arg1 = args.dig(:arg1) || args.dig("arg1") || ""
arg2 = args.dig(:arg2) || args.dig("arg2") || ""
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Prompt with arguments: arg1='#{arg1}', arg2='#{arg2}'"),
),
],
)
end
end
end
class TestPromptWithEmbeddedResource < MCP::Prompt
prompt_name "test_prompt_with_embedded_resource"
description "A prompt with an embedded resource for testing"
arguments [
MCP::Prompt::Argument.new(name: "resourceUri", description: "URI of the resource to embed", required: true),
]
class << self
def template(args, server_context: nil)
resource_uri = args.dig(:resourceUri) || args.dig("resourceUri") || "test://example-resource"
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::EmbeddedResource.new(
MCP::Resource::TextContents.new(
uri: resource_uri,
mime_type: "text/plain",
text: "Embedded resource content for testing.",
),
),
),
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Please process the embedded resource above."),
),
],
)
end
end
end
class TestPromptWithImage < MCP::Prompt
prompt_name "test_prompt_with_image"
description "A prompt with image content for testing"
class << self
def template(_args, server_context: nil)
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Image.new(BASE64_1X1_PNG, "image/png"),
),
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Please analyze the image above."),
),
],
)
end
end
end
class TestInputRequiredResultPrompt < MCP::Prompt
prompt_name "test_input_required_result_prompt"
description "A prompt that asks for user context through an input_required result (SEP-2322)"
class << self
def template(_args, server_context: nil)
content = server_context && Mrtr.accepted_content(server_context.input_response("user_context"))
context = content && Mrtr.read(content, :context)
unless context.is_a?(String)
return MCP::Server::InputRequiredResult.new(
input_requests: {
user_context: {
method: "elicitation/create",
params: {
message: "Provide context for the prompt",
requestedSchema: {
type: "object",
properties: { context: { type: "string" } },
required: ["context"],
},
},
},
},
)
end
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Context: #{context}"),
),
],
)
end
end
end
end
class Server
DEFAULT_PORT = 9292
class DnsRebindingProtection
LOCALHOST_PATTERNS = /\A(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?\z/i.freeze
def initialize(app)
@app = app
end
def call(env)
host = env["HTTP_HOST"] || env["SERVER_NAME"] || ""
unless localhost?(host)
return [
403,
{ "Content-Type" => "application/json" },
[{ error: "Forbidden: DNS rebinding protection - invalid Host header '#{host}'" }.to_json],
]
end
origin = env["HTTP_ORIGIN"]
if origin && !origin.empty?
begin
origin_host = URI.parse(origin).host.to_s
unless localhost?(origin_host)
return [
403,
{ "Content-Type" => "application/json" },
[{ error: "Forbidden: DNS rebinding protection - invalid Origin '#{origin}'" }.to_json],
]
end
rescue URI::InvalidURIError
return [
403,
{ "Content-Type" => "application/json" },
[{ error: "Forbidden: invalid Origin header" }.to_json],
]
end
end
@app.call(env)
end
private
def localhost?(host)
host.empty? || host.match?(LOCALHOST_PATTERNS)
end
end
def initialize(port: DEFAULT_PORT)
@port = port
end
def start
server = build_server
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
configure_handlers(server)
rack_app = build_rack_app(transport)
puts <<~MESSAGE
MCP Conformance Server starting on http://localhost:#{@port}/mcp
Use Ctrl-C to stop
MESSAGE
Rackup::Handler.get("puma").run(rack_app, Port: @port, Host: "localhost", Silent: true)
end
private
def build_server
MCP::Server.new(
name: "ruby-sdk-conformance-server",
version: MCP::VERSION,
tools: [
Tools::TestSimpleText,
Tools::TestImageContent,
Tools::TestAudioContent,
Tools::TestEmbeddedResource,
Tools::TestMultipleContentTypes,
Tools::TestErrorHandling,
Tools::JsonSchema202012Tool,
Tools::TestToolWithLogging,
Tools::TestToolWithProgress,
Tools::TestSampling,
Tools::TestElicitation,
Tools::TestElicitationSep1034Defaults,
Tools::TestElicitationSep1330Enums,
Tools::TestReconnection,
Tools::TestMissingCapability,
Tools::TestInputRequiredResultElicitation,
Tools::TestInputRequiredResultSampling,
Tools::TestInputRequiredResultListRoots,
Tools::TestInputRequiredResultRequestState,
Tools::TestInputRequiredResultMultipleInputs,
Tools::TestInputRequiredResultMultiRound,
Tools::TestInputRequiredResultTamperedState,
Tools::TestInputRequiredResultCapabilities,
Tools::TestStreamingElicitation,
Tools::TestLoggingTool,
Tools::TestTriggerToolChange,
Tools::TestTriggerPromptChange,
],
prompts: [
Prompts::TestSimplePrompt,
Prompts::TestPromptWithArguments,
Prompts::TestPromptWithEmbeddedResource,
Prompts::TestPromptWithImage,
Prompts::TestInputRequiredResultPrompt,
],
resources: resources,
resource_templates: resource_templates,
# A per-boot random key is enough for conformance: every MRTR round trip completes
# within one server process, and sealing keeps the fixture's `requestState` values
# tamper-evident without any tool-level verification code.
request_state_security: MCP::Server::RequestStateSecurity.new(key: SecureRandom.bytes(32)),
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true, subscribe: true },
logging: {},
completions: {},
},
)
end
def resources
[
MCP::Resource.new(
uri: "test://static-text",
name: "static-text",
description: "A static text resource for testing",
mime_type: "text/plain",
),
MCP::Resource.new(
uri: "test://static-binary",
name: "static-binary",
description: "A static binary (PNG) resource for testing",
mime_type: "image/png",
),
MCP::Resource.new(
uri: "test://watched-resource",
name: "watched-resource",
description: "A resource for subscription testing",
mime_type: "text/plain",
),
]
end
def resource_templates
[
MCP::ResourceTemplate.new(
uri_template: "test://template/{id}/data",
name: "template-resource",
description: "A parameterized resource template for testing",
mime_type: "application/json",
),
]
end
def configure_handlers(server)
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
server.server_context = server
configure_resources_read_handler(server)
configure_subscription_handlers(server)
configure_completion_handler(server)
end
def configure_resources_read_handler(server)
server.resources_read_handler do |params|
uri = params[:uri].to_s
case uri
when "test://static-text"
[
MCP::Resource::TextContents.new(
text: "This is the content of the static text resource.",
uri: uri,
mime_type: "text/plain",
).to_h,
]
when "test://static-binary"
[
MCP::Resource::BlobContents.new(
data: BASE64_1X1_PNG,
uri: uri,
mime_type: "image/png",
).to_h,
]
when %r{\Atest://template/(.+)/data\z}
id = Regexp.last_match(1)
content = { id: id, templateTest: true, data: "Data for ID: #{id}" }.to_json
[
MCP::Resource::TextContents.new(
text: content,
uri: uri,
mime_type: "application/json",
).to_h,
]
else
# Per SEP-2164, an unknown URI answers with -32602 carrying the URI in `error.data`;
# an empty `contents` array is forbidden.
raise MCP::Server::ResourceNotFoundError.new(uri, params)
end
end
end
def configure_completion_handler(server)
server.completion_handler do |params|
ref = params[:ref]
argument = params[:argument]
value = argument[:value].to_s
case ref[:type]
when "ref/prompt"
case ref[:name]
when "test_prompt_with_arguments"
candidates = case argument[:name]
when "arg1"
["value1", "value2", "value3"]
when "arg2"
["optionA", "optionB", "optionC"]
else
[]
end
values = candidates.select { |v| v.start_with?(value) }
{ completion: { values: values, hasMore: false } }
else
{ completion: { values: [], hasMore: false } }
end
else
{ completion: { values: [], hasMore: false } }
end
end
end
def configure_subscription_handlers(server)
subscribed_uris = Set.new
server.resources_subscribe_handler do |params|
subscribed_uris.add(params[:uri].to_s)
end
server.resources_unsubscribe_handler do |params|
subscribed_uris.delete(params[:uri].to_s)
end
end
def build_rack_app(transport)
mcp_app = proc do |env|
request = Rack::Request.new(env)
if request.path_info == "/health"
[200, { "Content-Type" => "application/json" }, ['{"status":"ok"}']]
elsif request.path_info == "/mcp" || request.path_info == "/"
transport.handle_request(request)
else
[404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']]
end
end
Rack::Builder.new do
use(DnsRebindingProtection)
run(mcp_app)
end
end
end
end