## Motivation and Context The specification asks implementations to establish timeouts for all sent requests, "to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes, and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input, and then simply never answers holds that worker thread for good. A handful of such requests exhausts a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it, and only the GET stream has a keepalive to notice a vanished peer. `StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds (`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through `ServerContext` and `ServerSession` down to the transport. The option spells out its direction because the same constructor already takes `max_request_bytes:` for the requests arriving the other way. Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default. On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`. Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests and its sessionless requests never register the session `send_request` looks up. Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here. The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline. It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown) unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it. The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter the same non-reentrant mutex. stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters `timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer as `notifications/cancelled`, a response arriving before the deadline still returning normally, and the constructor rejecting a non-positive `server_to_client_request_timeout`. `test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five `ServerSession` entry points, and being dropped for transports that never declared it. `test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:` not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection, cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new wait primitive. `bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`: the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve the same object without deadlocking. ## Breaking Changes Server-to-client requests that previously waited forever now fail after ten minutes by default. This is the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob.
1117 lines
38 KiB
Ruby
1117 lines
38 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class ServerContextTest < ActiveSupport::TestCase
|
|
test "ServerContext delegates method calls to the underlying context" do
|
|
context = { user: "test_user" }
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
assert_equal "test_user", server_context[:user]
|
|
end
|
|
|
|
test "ServerContext respond_to? returns true for methods on the underlying context" do
|
|
context = { user: "test_user" }
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
assert_respond_to server_context, :[]
|
|
assert_respond_to server_context, :keys
|
|
end
|
|
|
|
test "ServerContext respond_to? returns false for methods not on the underlying context" do
|
|
context = { user: "test_user" }
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
refute_respond_to server_context, :nonexistent_method
|
|
end
|
|
|
|
test "ServerContext raises NoMethodError for methods not on the underlying context" do
|
|
context = { user: "test_user" }
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
assert_raises(NoMethodError) { server_context.nonexistent_method }
|
|
end
|
|
|
|
test "ServerContext#list_roots delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:list_roots).with(
|
|
related_request_id: nil,
|
|
).returns({ roots: [{ uri: "file:///project", name: "Project" }] })
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.list_roots
|
|
|
|
assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots]
|
|
end
|
|
|
|
test "ServerContext#list_roots stamps the originating request id" do
|
|
# Per SEP-2260, server-to-client requests are associated with the originating client request automatically.
|
|
notification_target = mock
|
|
notification_target.expects(:list_roots).with(related_request_id: "req-1").returns({ roots: [] })
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(
|
|
mock,
|
|
progress: progress,
|
|
notification_target: notification_target,
|
|
related_request_id: "req-1",
|
|
)
|
|
|
|
server_context.list_roots
|
|
end
|
|
|
|
test "ServerContext#create_sampling_message stamps related_request_id non-overridably" do
|
|
# A caller-supplied `related_request_id:` must not detach the request from its originating client request (SEP-2260):
|
|
# the literal keyword after `**kwargs` wins.
|
|
notification_target = mock
|
|
notification_target.expects(:create_sampling_message).with(
|
|
messages: [],
|
|
max_tokens: 5,
|
|
related_request_id: "req-1",
|
|
).returns({})
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(
|
|
mock,
|
|
progress: progress,
|
|
notification_target: notification_target,
|
|
related_request_id: "req-1",
|
|
)
|
|
|
|
server_context.create_sampling_message(messages: [], max_tokens: 5, related_request_id: "attacker")
|
|
end
|
|
|
|
test "ServerContext#create_form_elicitation stamps related_request_id non-overridably" do
|
|
notification_target = mock
|
|
notification_target.expects(:create_form_elicitation).with(
|
|
message: "hi",
|
|
requested_schema: {},
|
|
related_request_id: "req-1",
|
|
).returns({})
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(
|
|
mock,
|
|
progress: progress,
|
|
notification_target: notification_target,
|
|
related_request_id: "req-1",
|
|
)
|
|
|
|
server_context.create_form_elicitation(message: "hi", requested_schema: {}, related_request_id: "attacker")
|
|
end
|
|
|
|
test "ServerContext#create_url_elicitation stamps related_request_id non-overridably" do
|
|
notification_target = mock
|
|
notification_target.expects(:create_url_elicitation).with(
|
|
message: "hi",
|
|
url: "https://example.com",
|
|
elicitation_id: "el-1",
|
|
related_request_id: "req-1",
|
|
).returns({})
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(
|
|
mock,
|
|
progress: progress,
|
|
notification_target: notification_target,
|
|
related_request_id: "req-1",
|
|
)
|
|
|
|
server_context.create_url_elicitation(
|
|
message: "hi",
|
|
url: "https://example.com",
|
|
elicitation_id: "el-1",
|
|
related_request_id: "attacker",
|
|
)
|
|
end
|
|
|
|
test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do
|
|
notification_target = mock
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
assert_raises(NoMethodError) { server_context.list_roots }
|
|
end
|
|
|
|
test "ServerContext#ping delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:ping).with(related_request_id: nil).returns({})
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.ping
|
|
|
|
assert_equal({}, result)
|
|
end
|
|
|
|
test "ServerContext forwards a per-request timeout to the notification target" do
|
|
notification_target = mock
|
|
notification_target.expects(:ping).with(related_request_id: nil, timeout: 12).returns({})
|
|
notification_target.expects(:list_roots).with(related_request_id: nil, timeout: 34).returns({ roots: [] })
|
|
notification_target.expects(:create_form_elicitation).with(
|
|
message: "Approve?",
|
|
requested_schema: { type: "object", properties: {} },
|
|
related_request_id: nil,
|
|
timeout: 56,
|
|
).returns({ action: "accept" })
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target)
|
|
|
|
server_context.ping(timeout: 12)
|
|
server_context.list_roots(timeout: 34)
|
|
server_context.create_form_elicitation(
|
|
message: "Approve?",
|
|
requested_schema: { type: "object", properties: {} },
|
|
timeout: 56,
|
|
)
|
|
end
|
|
|
|
test "ServerContext omits timeout entirely when the caller does not ask for one" do
|
|
# A notification target predating the keyword (a custom object standing in for a session) keeps working,
|
|
# and the transport applies its own default.
|
|
notification_target = mock
|
|
notification_target.expects(:ping).with(related_request_id: nil).returns({})
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target)
|
|
|
|
server_context.ping
|
|
end
|
|
|
|
test "ServerContext#ping raises NoMethodError when notification_target does not respond" do
|
|
notification_target = mock
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
assert_raises(NoMethodError) { server_context.ping }
|
|
end
|
|
|
|
test "ServerContext#create_sampling_message delegates to notification_target over context" do
|
|
notification_target = mock
|
|
notification_target.expects(:create_sampling_message).with(
|
|
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
|
max_tokens: 100,
|
|
related_request_id: nil,
|
|
).returns({ role: "assistant", content: { type: "text", text: "Hi" } })
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.create_sampling_message(
|
|
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
|
max_tokens: 100,
|
|
)
|
|
|
|
assert_equal "Hi", result[:content][:text]
|
|
end
|
|
|
|
test "ServerContext#create_sampling_message falls back to context when notification_target does not respond" do
|
|
notification_target = mock
|
|
context = mock
|
|
context.expects(:create_sampling_message).with(
|
|
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
|
max_tokens: 100,
|
|
related_request_id: nil,
|
|
).returns({ role: "assistant", content: { type: "text", text: "Fallback" } })
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.create_sampling_message(
|
|
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
|
max_tokens: 100,
|
|
)
|
|
|
|
assert_equal "Fallback", result[:content][:text]
|
|
end
|
|
|
|
test "ServerContext#create_form_elicitation delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:create_form_elicitation).with(
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
related_request_id: nil,
|
|
).returns(action: "accept", content: { name: "test_user" })
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.create_form_elicitation(
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
)
|
|
|
|
assert_equal "accept", result[:action]
|
|
end
|
|
|
|
test "ServerContext#create_url_elicitation delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:create_url_elicitation).with(
|
|
message: "Please authorize",
|
|
url: "https://example.com/oauth",
|
|
elicitation_id: "abc-123",
|
|
related_request_id: nil,
|
|
).returns(action: "accept")
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
result = server_context.create_url_elicitation(
|
|
message: "Please authorize",
|
|
url: "https://example.com/oauth",
|
|
elicitation_id: "abc-123",
|
|
)
|
|
|
|
assert_equal "accept", result[:action]
|
|
end
|
|
|
|
test "ServerContext#notify_elicitation_complete delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:notify_elicitation_complete).with(elicitation_id: "abc-123")
|
|
|
|
context = mock
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: notification_target)
|
|
|
|
server_context.notify_elicitation_complete(elicitation_id: "abc-123")
|
|
end
|
|
|
|
test "ServerContext delegates to custom object context" do
|
|
context = Object.new
|
|
def context.custom_method
|
|
"custom_value"
|
|
end
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
assert_equal "custom_value", server_context.custom_method
|
|
end
|
|
|
|
test "ServerContext forwards positional, keyword, and block arguments to the context" do
|
|
context = Object.new
|
|
def context.combine(prefix, suffix:, &block)
|
|
block.call("#{prefix}-#{suffix}")
|
|
end
|
|
progress = Progress.new(notification_target: mock, progress_token: nil)
|
|
|
|
server_context = ServerContext.new(context, progress: progress, notification_target: mock)
|
|
|
|
result = server_context.combine("a", suffix: "b", &:upcase)
|
|
|
|
assert_equal "A-B", result
|
|
end
|
|
|
|
test "ServerContext#report_progress works with nil context" do
|
|
progress = mock
|
|
progress.expects(:report).with(50, total: 100, message: nil).once
|
|
|
|
server_context = ServerContext.new(nil, progress: progress, notification_target: mock)
|
|
server_context.report_progress(50, total: 100)
|
|
end
|
|
|
|
test "ServerContext#notify_log_message is a no-op when notification_target is nil" do
|
|
progress = Progress.new(notification_target: nil, progress_token: nil)
|
|
server_context = ServerContext.new(nil, progress: progress, notification_target: nil)
|
|
|
|
assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") }
|
|
end
|
|
|
|
test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do
|
|
# Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send
|
|
# any `notifications/message` for the request.
|
|
notification_target = mock
|
|
notification_target.expects(:notify_log_message).never
|
|
server_context = build_modern_server_context(notification_target, log_level: nil)
|
|
|
|
server_context.notify_log_message(data: "test", level: "error")
|
|
end
|
|
|
|
test "ServerContext#notify_log_message drops messages below the modern request logLevel" do
|
|
notification_target = mock
|
|
notification_target.expects(:notify_log_message).never
|
|
server_context = build_modern_server_context(notification_target, log_level: "error")
|
|
|
|
server_context.notify_log_message(data: "test", level: "info")
|
|
end
|
|
|
|
test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do
|
|
notification_target = mock
|
|
notification_target.expects(:notify_log_message).with(
|
|
data: "test", level: "error", logger: nil, related_request_id: nil,
|
|
).once
|
|
server_context = build_modern_server_context(notification_target, log_level: "warning")
|
|
|
|
server_context.notify_log_message(data: "test", level: "error")
|
|
end
|
|
|
|
test "ServerContext exposes envelope data and falls back to the session on legacy requests" do
|
|
notification_target = mock
|
|
notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" })
|
|
notification_target.stubs(:client_capabilities).returns({ roots: {} })
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
|
|
legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
|
|
modern_context = build_modern_server_context(notification_target)
|
|
|
|
refute_predicate legacy_context, :modern?
|
|
assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info)
|
|
assert_equal({ roots: {} }, legacy_context.client_capabilities)
|
|
assert_nil legacy_context.protocol_version
|
|
|
|
assert_predicate modern_context, :modern?
|
|
assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info)
|
|
assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities)
|
|
assert_equal "2026-07-28", modern_context.protocol_version
|
|
end
|
|
|
|
test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do
|
|
envelope = RequestEnvelope.new(
|
|
protocol_version: "2026-07-28",
|
|
client_info: { name: "c", version: "1" },
|
|
client_capabilities: { "elicitation" => { "form" => {} } },
|
|
)
|
|
progress = Progress.new(notification_target: nil, progress_token: nil)
|
|
server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope)
|
|
|
|
assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) }
|
|
assert_raises(ArgumentError) { server_context.require_client_capability! }
|
|
error = assert_raises(Server::MissingRequiredClientCapabilityError) do
|
|
server_context.require_client_capability!(:sampling)
|
|
end
|
|
assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data)
|
|
end
|
|
|
|
test "ServerContext#notify_resources_updated delegates to notification_target" do
|
|
notification_target = mock
|
|
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
|
|
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
server_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
|
|
|
|
server_context.notify_resources_updated(uri: "test://resource-1")
|
|
end
|
|
|
|
test "ServerContext#notify_resources_updated is a no-op when notification_target is nil" do
|
|
progress = Progress.new(notification_target: nil, progress_token: nil)
|
|
server_context = ServerContext.new(nil, progress: progress, notification_target: nil)
|
|
|
|
assert_nothing_raised { server_context.notify_resources_updated(uri: "test://resource-1") }
|
|
end
|
|
|
|
# Tool without server_context parameter
|
|
class SimpleToolWithoutContext < Tool
|
|
tool_name "simple_without_context"
|
|
description "A tool that doesn't use server_context"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:)
|
|
Tool::Response.new([
|
|
{ type: "text", content: "SimpleToolWithoutContext: #{message}" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
# Tool with optional server_context parameter
|
|
class ToolWithOptionalContext < Tool
|
|
tool_name "tool_with_optional_context"
|
|
description "A tool with optional server_context"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:, server_context: nil)
|
|
user = server_context.respond_to?(:[]) ? server_context[:user] : nil
|
|
context_info = user ? "with context: #{user}" : "no context"
|
|
Tool::Response.new([
|
|
{ type: "text", content: "ToolWithOptionalContext: #{message} (#{context_info})" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
# Tool with required server_context parameter
|
|
class ToolWithRequiredContext < Tool
|
|
tool_name "tool_with_required_context"
|
|
description "A tool that requires server_context"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:, server_context:)
|
|
Tool::Response.new([
|
|
{ type: "text", content: "ToolWithRequiredContext: #{message} for user #{server_context[:user]}" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
setup do
|
|
@server_with_context = Server.new(
|
|
name: "test_server",
|
|
tools: [SimpleToolWithoutContext, ToolWithOptionalContext, ToolWithRequiredContext],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
@server_without_context = Server.new(
|
|
name: "test_server_no_context",
|
|
tools: [SimpleToolWithoutContext, ToolWithOptionalContext],
|
|
)
|
|
end
|
|
|
|
test "tool without server_context parameter works when server has context" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "simple_without_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = @server_with_context.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "SimpleToolWithoutContext: Hello", response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "tool with optional server_context receives context when server has it" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_optional_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = @server_with_context.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "ToolWithOptionalContext: Hello (with context: test_user)",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "tool with optional server_context works when server has no context" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_optional_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = @server_without_context.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "ToolWithOptionalContext: Hello (no context)",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "tool with required server_context receives context" do
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_required_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = @server_with_context.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "ToolWithRequiredContext: Hello for user test_user",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "tool with required server_context returns protocol error in JSON-RPC format when server has no context" do
|
|
server_no_context = Server.new(
|
|
name: "test_server_no_context",
|
|
tools: [ToolWithRequiredContext],
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_required_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server_no_context.handle(request)
|
|
|
|
assert_nil response[:result]
|
|
assert_equal(-32603, response[:error][:code])
|
|
assert_equal "Internal error", response[:error][:message]
|
|
assert_equal "Internal error calling tool tool_with_required_context", response[:error][:data]
|
|
end
|
|
|
|
test "call_tool_with_args correctly detects server_context parameter presence" do
|
|
# Tool without server_context
|
|
refute SimpleToolWithoutContext.method(:call).parameters.any? { |_type, name| name == :server_context }
|
|
|
|
# Tool with optional server_context
|
|
assert ToolWithOptionalContext.method(:call).parameters.any? { |_type, name| name == :server_context }
|
|
|
|
# Tool with required server_context
|
|
assert ToolWithRequiredContext.method(:call).parameters.any? { |_type, name| name == :server_context }
|
|
end
|
|
|
|
test "tools can use splat kwargs to accept any arguments including server_context" do
|
|
class FlexibleTool < Tool
|
|
tool_name "flexible_tool"
|
|
|
|
class << self
|
|
def call(**kwargs)
|
|
message = kwargs[:message]
|
|
context = kwargs[:server_context]
|
|
|
|
Tool::Response.new([
|
|
{
|
|
type: "text",
|
|
content: "FlexibleTool: #{message} (context: #{context ? "present" : "absent"})",
|
|
},
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
tools: [FlexibleTool],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "flexible_tool",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "FlexibleTool: Hello (context: present)",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
# Prompt tests
|
|
|
|
# Prompt without server_context parameter
|
|
class SimplePromptWithoutContext < Prompt
|
|
prompt_name "simple_prompt_without_context"
|
|
description "A prompt that doesn't use server_context"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args)
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new("SimplePromptWithoutContext: #{args[:message]}"),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Prompt with optional server_context parameter
|
|
class PromptWithOptionalContext < Prompt
|
|
prompt_name "prompt_with_optional_context"
|
|
description "A prompt with optional server_context"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args, server_context: nil)
|
|
user = server_context.respond_to?(:[]) ? server_context[:user] : nil
|
|
context_info = user ? "with context: #{user}" : "no context"
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new("PromptWithOptionalContext: #{args[:message]} (#{context_info})"),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Prompt with required server_context parameter
|
|
class PromptWithRequiredContext < Prompt
|
|
prompt_name "prompt_with_required_context"
|
|
description "A prompt that requires server_context"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args, server_context:)
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new(
|
|
"PromptWithRequiredContext: #{args[:message]} for user #{server_context[:user]}",
|
|
),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
test "prompt without server_context parameter works when server has context" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [SimplePromptWithoutContext],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "simple_prompt_without_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "SimplePromptWithoutContext: Hello", response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "prompt with optional server_context receives context when server has it" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [PromptWithOptionalContext],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "prompt_with_optional_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "PromptWithOptionalContext: Hello (with context: test_user)",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "prompt with optional server_context works when server has no context" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [PromptWithOptionalContext],
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "prompt_with_optional_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "PromptWithOptionalContext: Hello (no context)",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "prompt with required server_context receives context" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [PromptWithRequiredContext],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "prompt_with_required_context",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "PromptWithRequiredContext: Hello for user test_user",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "prompts can use splat kwargs to accept any arguments including server_context" do
|
|
class FlexiblePrompt < Prompt
|
|
prompt_name "flexible_prompt"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args, **kwargs)
|
|
message = args[:message]
|
|
context = kwargs[:server_context]
|
|
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new("FlexiblePrompt: #{message} (context: #{context ? "present" : "absent"})"),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [FlexiblePrompt],
|
|
server_context: { user: "test_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "flexible_prompt",
|
|
arguments: { message: "Hello" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "FlexiblePrompt: Hello (context: present)",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "tool receives _meta when provided in request params" do
|
|
class ToolWithMeta < Tool
|
|
tool_name "tool_with_meta"
|
|
description "A tool that uses _meta"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:, server_context:)
|
|
meta_info = server_context.dig(:_meta, :provider, :metadata)
|
|
Tool::Response.new([
|
|
{ type: "text", content: "Message: #{message}, Metadata: #{meta_info}" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
tools: [ToolWithMeta],
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_meta",
|
|
arguments: { message: "Hello" },
|
|
_meta: {
|
|
provider: {
|
|
metadata: "test_value",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "Message: Hello, Metadata: test_value",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "_meta is nested within server_context" do
|
|
class ToolWithNestedMeta < Tool
|
|
tool_name "tool_with_nested_meta"
|
|
description "A tool that uses nested _meta"
|
|
input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
|
|
|
|
class << self
|
|
def call(message:, server_context:)
|
|
user = server_context[:user]
|
|
session_id = server_context.dig(:_meta, :session_id)
|
|
Tool::Response.new([
|
|
{ type: "text", content: "User: #{user}, Session: #{session_id}, Message: #{message}" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
tools: [ToolWithNestedMeta],
|
|
server_context: { user: "test_user", original_field: "value" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_nested_meta",
|
|
arguments: { message: "Hello" },
|
|
_meta: {
|
|
session_id: "abc123",
|
|
},
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "User: test_user, Session: abc123, Message: Hello",
|
|
response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "_meta preserves original server_context" do
|
|
class ToolPreservesContext < Tool
|
|
tool_name "tool_preserves_context"
|
|
description "A tool that checks context preservation"
|
|
|
|
class << self
|
|
def call(server_context:)
|
|
priority = server_context[:priority]
|
|
meta_priority = server_context.dig(:_meta, :priority)
|
|
Tool::Response.new([
|
|
{ type: "text", content: "Context priority: #{priority}, Meta priority: #{meta_priority}" },
|
|
])
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
tools: [ToolPreservesContext],
|
|
server_context: { priority: "low" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_preserves_context",
|
|
arguments: {},
|
|
_meta: {
|
|
priority: "high",
|
|
},
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "Context priority: low, Meta priority: high", response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "prompt receives _meta when provided in request params" do
|
|
class PromptWithMeta < Prompt
|
|
prompt_name "prompt_with_meta"
|
|
description "A prompt that uses _meta"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args, server_context:)
|
|
meta_info = server_context.dig(:_meta, :request_id)
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new("Message: #{args[:message]}, Request ID: #{meta_info}"),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [PromptWithMeta],
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "prompt_with_meta",
|
|
arguments: { message: "Hello" },
|
|
_meta: {
|
|
request_id: "req_12345",
|
|
},
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "Message: Hello, Request ID: req_12345",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
test "non-Hash server_context is preserved when _meta is present" do
|
|
server_obj = Object.new
|
|
def server_obj.custom_method
|
|
"custom_value"
|
|
end
|
|
|
|
tool_class = Class.new(Tool) do
|
|
tool_name "tool_with_non_hash_context"
|
|
|
|
define_singleton_method(:call) do |server_context:, **_args|
|
|
Tool::Response.new([
|
|
{ type: "text", content: "custom: #{server_context.custom_method}" },
|
|
])
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
tools: [tool_class],
|
|
server_context: server_obj,
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: {
|
|
name: "tool_with_non_hash_context",
|
|
arguments: {},
|
|
_meta: { progressToken: "token-1" },
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "custom: custom_value", response[:result][:content][0][:content]
|
|
end
|
|
|
|
test "_meta is nested within server_context for prompts" do
|
|
class PromptWithNestedContext < Prompt
|
|
prompt_name "prompt_with_nested_context"
|
|
description "A prompt that uses nested context"
|
|
arguments [Prompt::Argument.new(name: "message", required: true)]
|
|
|
|
class << self
|
|
def template(args, server_context:)
|
|
user = server_context[:user]
|
|
trace_id = server_context.dig(:_meta, :trace_id)
|
|
Prompt::Result.new(
|
|
messages: [
|
|
Prompt::Message.new(
|
|
role: "user",
|
|
content: Content::Text.new("User: #{user}, Trace: #{trace_id}, Message: #{args[:message]}"),
|
|
),
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
server = Server.new(
|
|
name: "test_server",
|
|
prompts: [PromptWithNestedContext],
|
|
server_context: { user: "prompt_user" },
|
|
)
|
|
|
|
request = {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "prompts/get",
|
|
params: {
|
|
name: "prompt_with_nested_context",
|
|
arguments: { message: "World" },
|
|
_meta: {
|
|
trace_id: "trace_xyz789",
|
|
},
|
|
},
|
|
}
|
|
|
|
response = server.handle(request)
|
|
|
|
assert response[:result]
|
|
assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World",
|
|
response[:result][:messages][0][:content][:text]
|
|
end
|
|
|
|
private
|
|
|
|
def build_modern_server_context(notification_target, log_level: nil)
|
|
envelope = RequestEnvelope.new(
|
|
protocol_version: "2026-07-28",
|
|
client_info: { name: "modern_client", version: "2.0" },
|
|
client_capabilities: { elicitation: { form: {} } },
|
|
log_level: log_level,
|
|
)
|
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
|
ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope)
|
|
end
|
|
end
|
|
end
|