ruby-sdk/test/mcp/server_elicitation_test.rb
Koichi ITO 6ddcc49865 Associate server-to-client requests with the originating client request per SEP-2260
## 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.
2026-07-12 02:10:16 +09:00

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