## Motivation and Context SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release) requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in association with an originating client request (`ping` is exempt); standalone server-initiated requests on independent streams must not be implemented. On the Streamable HTTP transport, an associated request rides the originating POST's SSE response stream instead of the standalone GET stream. The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls: `list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`, a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented. This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests): - `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers. - `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation` emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged, on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is deferred to a future protocol-version gate for 2026-07-28. - A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream and never falls back to the GET stream. Resolves #381. ## How Has This Been Tested? - `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id: "attacker"` is replaced). - `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`: each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it. The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following the existing `assert_implicit_connect_deprecation_warning` precedent. - `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id` is written to the registered POST request stream and not to the GET SSE stream. - Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output stays clean; their assertions are unchanged. ## Breaking Changes None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning but are still sent exactly as before.
248 lines
8.3 KiB
Ruby
248 lines
8.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class ServerElicitationTest < ActiveSupport::TestCase
|
|
include InstrumentationTestHelper
|
|
|
|
class MockTransport < Transport
|
|
attr_reader :requests, :notifications
|
|
|
|
def initialize(server)
|
|
super
|
|
@requests = []
|
|
@notifications = []
|
|
end
|
|
|
|
def send_request(method, params = nil, **_kwargs)
|
|
@requests << { method: method, params: params }
|
|
{ action: "accept" }
|
|
end
|
|
|
|
def send_response(response); end
|
|
|
|
def send_notification(method, params = nil, **_kwargs)
|
|
@notifications << { method: method, params: params }
|
|
end
|
|
|
|
def open; end
|
|
def close; end
|
|
def handle_request(request); end
|
|
end
|
|
|
|
setup do
|
|
configuration = MCP::Configuration.new
|
|
configuration.instrumentation_callback = instrumentation_helper.callback
|
|
|
|
@server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: configuration,
|
|
)
|
|
|
|
@mock_transport = MockTransport.new(@server)
|
|
@server.transport = @mock_transport
|
|
|
|
@session = ServerSession.new(server: @server, transport: @mock_transport)
|
|
@session.instance_variable_set(:@client_capabilities, { elicitation: {} })
|
|
end
|
|
|
|
test "#create_form_elicitation warns when called without related_request_id" do
|
|
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
|
|
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
|
|
original_verbose = $VERBOSE
|
|
$VERBOSE = false
|
|
|
|
assert_output(nil, /SEP-2260/) do
|
|
@session.create_form_elicitation(
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
)
|
|
end
|
|
ensure
|
|
$VERBOSE = original_verbose
|
|
end
|
|
|
|
test "#create_url_elicitation warns when called without related_request_id" do
|
|
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: true } })
|
|
|
|
original_verbose = $VERBOSE
|
|
$VERBOSE = false
|
|
|
|
assert_output(nil, /SEP-2260/) do
|
|
@session.create_url_elicitation(
|
|
message: "Please sign in",
|
|
url: "https://example.com/signin",
|
|
elicitation_id: "el-1",
|
|
)
|
|
end
|
|
ensure
|
|
$VERBOSE = original_verbose
|
|
end
|
|
|
|
test "#create_form_elicitation does not warn when related_request_id is given" do
|
|
assert_silent do
|
|
@session.create_form_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
)
|
|
end
|
|
end
|
|
|
|
test "#create_form_elicitation sends request through transport" do
|
|
result = @session.create_form_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please provide your name",
|
|
requested_schema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
},
|
|
required: ["name"],
|
|
},
|
|
)
|
|
|
|
assert_equal 1, @mock_transport.requests.size
|
|
|
|
request = @mock_transport.requests.first
|
|
|
|
assert_equal Methods::ELICITATION_CREATE, request[:method]
|
|
assert_equal "form", request[:params][:mode]
|
|
assert_equal "Please provide your name", request[:params][:message]
|
|
assert_equal "object", request[:params][:requestedSchema][:type]
|
|
assert_equal({ action: "accept" }, result)
|
|
end
|
|
|
|
test "#create_form_elicitation raises error when client does not support elicitation" do
|
|
@session.instance_variable_set(:@client_capabilities, {})
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
@session.create_form_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
)
|
|
end
|
|
|
|
assert_equal(
|
|
"Client does not support elicitation. The client must declare the `elicitation` capability during initialization.",
|
|
error.message,
|
|
)
|
|
end
|
|
|
|
test "#create_form_elicitation raises error when client capabilities are nil" do
|
|
@session.instance_variable_set(:@client_capabilities, nil)
|
|
@server.instance_variable_set(:@client_capabilities, nil)
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
@session.create_form_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please provide your name",
|
|
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
|
)
|
|
end
|
|
|
|
assert_equal(
|
|
"Client does not support elicitation. The client must declare the `elicitation` capability during initialization.",
|
|
error.message,
|
|
)
|
|
end
|
|
|
|
test "#create_url_elicitation sends url mode request through transport" do
|
|
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } })
|
|
|
|
result = @session.create_url_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please authorize access",
|
|
url: "https://example.com/oauth",
|
|
elicitation_id: "abc-123",
|
|
)
|
|
|
|
assert_equal 1, @mock_transport.requests.size
|
|
|
|
request = @mock_transport.requests.first
|
|
|
|
assert_equal Methods::ELICITATION_CREATE, request[:method]
|
|
assert_equal "url", request[:params][:mode]
|
|
assert_equal "Please authorize access", request[:params][:message]
|
|
assert_equal "https://example.com/oauth", request[:params][:url]
|
|
assert_equal "abc-123", request[:params][:elicitationId]
|
|
assert_equal({ action: "accept" }, result)
|
|
end
|
|
|
|
test "#create_url_elicitation raises error when client does not support url mode" do
|
|
error = assert_raises(RuntimeError) do
|
|
@session.create_url_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please authorize access",
|
|
url: "https://example.com/oauth",
|
|
elicitation_id: "abc-123",
|
|
)
|
|
end
|
|
|
|
assert_equal(
|
|
"Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.",
|
|
error.message,
|
|
)
|
|
end
|
|
|
|
test "#create_url_elicitation raises error when client capabilities are nil" do
|
|
@session.instance_variable_set(:@client_capabilities, nil)
|
|
@server.instance_variable_set(:@client_capabilities, nil)
|
|
|
|
error = assert_raises(RuntimeError) do
|
|
@session.create_url_elicitation(
|
|
related_request_id: "req-1",
|
|
message: "Please authorize access",
|
|
url: "https://example.com/oauth",
|
|
elicitation_id: "abc-123",
|
|
)
|
|
end
|
|
|
|
assert_equal(
|
|
"Client does not support URL mode elicitation. The client must declare the `elicitation.url` capability during initialization.",
|
|
error.message,
|
|
)
|
|
end
|
|
|
|
test "#notify_elicitation_complete sends notification through transport" do
|
|
@session.notify_elicitation_complete(elicitation_id: "abc-123")
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
|
|
notification = @mock_transport.notifications.first
|
|
|
|
assert_equal Methods::NOTIFICATIONS_ELICITATION_COMPLETE, notification[:method]
|
|
assert_equal "abc-123", notification[:params][:elicitationId]
|
|
end
|
|
|
|
test "URLElicitationRequiredError can be raised from a tool handler" do
|
|
@server.define_tool(name: "needs_auth", description: "Needs OAuth") do
|
|
raise Server::URLElicitationRequiredError, [
|
|
{ mode: "url", elicitationId: "abc-123", url: "https://example.com/oauth", message: "Auth required" },
|
|
]
|
|
end
|
|
|
|
response = JSON.parse(@server.handle_json(JSON.generate({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "tools/call",
|
|
params: { name: "needs_auth", arguments: {} },
|
|
})))
|
|
|
|
assert_equal(-32042, response["error"]["code"])
|
|
assert_equal "URL elicitation required", response["error"]["message"]
|
|
|
|
elicitations = response["error"]["data"]["elicitations"]
|
|
|
|
assert_equal 1, elicitations.size
|
|
assert_equal "abc-123", elicitations.first["elicitationId"]
|
|
|
|
assert_instrumentation_data(
|
|
method: "tools/call", tool_name: "needs_auth", tool_arguments: {}, error: :url_elicitation_required,
|
|
)
|
|
end
|
|
end
|
|
end
|