ruby-sdk/conformance/server.rb
Koichi ITO da2a7f80d7 Stream modern request notifications and honor the envelope logLevel per SEP-2575
## Motivation and Context

The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange,
and two delivery requirements of that model were unimplemented:

- Notifications a handler emits while serving a modern request (progress, log messages)
  had nowhere to go: the modern path answered with a single JSON object,
  and the ephemeral per-request session degraded every notification to non-delivery.
  The conformance requirement expects them as frames on the request's own response stream
  (`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28).
- The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC,
  was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement
  that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`.

The changes wire both up:

- `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session;
  `send_notification` routes into the sink ahead of the stateless guard.
  A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response;
  a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
- `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`,
  and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level:
  without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent,
  the safe direction, matching the Python SDK's `allowed_log_levels`.

The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline
rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as
not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation`
and requires that every frame on the request's response stream is a notification or the final response,
and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel`
and requires that no `notifications/message` reaches the stream.

The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes;
`test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate,
which drops the notification for modern requests that did not authorize logging through
`io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds:
with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame
ahead of the final response rather than being dropped.

One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs:
delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved
and the conformance checks are satisfied, but long-running handlers get no real-time interleaving.
The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications
in proportion to client-supplied input cannot grow one request's memory without bound,
and past the cap the notify helpers report `false`, the same non-delivery degradation
they have on every other undeliverable path. Live streaming can follow without changing the wire shape.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover:
a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying
in place without notifications, log suppression without the envelope `logLevel`, log delivery with it,
the modern-era session ignoring the server-wide level while honoring a per-request one,
and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it.

Against the conformance fixture server, the `tools-call-with-progress` progress check passes
at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change),
the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and
`sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive
directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`,
`test_logging_tool` streams its `notifications/message` frame ahead of the result, and
with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame.
The `--requirements 2025-11-25` server leg passes 78/78, unchanged.

## Breaking Changes

None for stable protocol versions: legacy requests and their notification delivery are unchanged.
Within the modern lifecycle, notifications become deliverable where they were previously dropped,
and log messages without a per-request `logLevel` stop leaking through the server-wide level,
as the specification requires.
2026-08-08 15:22:58 +09:00

1044 lines
34 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: { type: "string", enum: ["active", "inactive", "pending"], default: "active" },
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)
result = server_context.create_form_elicitation(
message: "Please select options",
requested_schema: {
type: "object",
properties: {
untitledSingle: {
type: "string",
enum: ["option1", "option2", "option3"],
},
titledSingle: {
type: "string",
oneOf: [
{ const: "value1", title: "First Option" },
{ const: "value2", title: "Second Option" },
{ const: "value3", title: "Third Option" },
],
},
legacyEnum: {
type: "string",
enum: ["opt1", "opt2", "opt3"],
enumNames: ["Option One", "Option Two", "Option Three"],
},
untitledMulti: {
type: "array",
items: {
type: "string",
enum: ["option1", "option2", "option3"],
},
},
titledMulti: {
type: "array",
items: {
anyOf: [
{ const: "value1", title: "First Choice" },
{ const: "value2", title: "Second Choice" },
{ const: "value3", title: "Third Choice" },
],
},
},
},
},
)
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 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
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::TestInputRequiredResultCapabilities,
Tools::TestStreamingElicitation,
Tools::TestLoggingTool,
],
prompts: [
Prompts::TestSimplePrompt,
Prompts::TestPromptWithArguments,
Prompts::TestPromptWithEmbeddedResource,
Prompts::TestPromptWithImage,
Prompts::TestInputRequiredResultPrompt,
],
resources: resources,
resource_templates: resource_templates,
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