Support elicitation/create per MCP specification
## Motivation and Context The MCP specification (2025-11-25) defines elicitation as a server-to-client request mechanism that allows servers to request user input during tool execution: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation This aligns with the Python SDK, which also exposes separate methods for form and URL mode elicitation. Three methods are added following the Python SDK's API design: - `create_form_elicitation`: collects structured data via JSON Schema - `create_url_elicitation`: directs users to external URLs for sensitive interactions (OAuth, credentials, etc.) - `notify_elicitation_complete`: notifies the client that a URL mode interaction has completed `URLElicitationRequiredError` (error code `-32042`) is defined as a subclass of `RequestHandlerError` with its own `error_code` and `error_data` attributes. `JsonRpcHandler#handle_request_error` is extended to check for these attributes so that MCP-specific error codes can be propagated without embedding MCP knowledge in the generic JSON-RPC layer. ## How Has This Been Tested? - Unit tests for `ServerSession` form/URL mode, capability checks, nil capabilities, notification, and `URLElicitationRequiredError` - `ServerContext` delegation tests for all three elicitation methods - `JsonRpcHandler` tests for custom error code and fallback behavior - `Methods` capability test for `NOTIFICATIONS_ELICITATION_COMPLETE` - Conformance tests pass (40/40), including `tools-call-elicitation`, `elicitation-sep1034-defaults`, and `elicitation-sep1330-enums` - All tests pass (`rake test`), RuboCop is clean - Verified form mode elicitation end-to-end with a sample MCP server running in Claude Code ## Breaking Changes None. This is a new feature addition.
This commit is contained in:
parent
88248fc4c7
commit
985fd03ecb
4 changed files with 566 additions and 36 deletions
|
|
@ -488,6 +488,43 @@ describe JsonRpcHandler do
|
|||
}
|
||||
end
|
||||
|
||||
it "returns a custom error code when RequestHandlerError has error_code set" do
|
||||
register("test_method") do
|
||||
raise MCP::Server::RequestHandlerError.new(
|
||||
"Custom error",
|
||||
nil,
|
||||
error_code: -32042,
|
||||
error_data: { elicitations: [{ id: "abc" }] },
|
||||
)
|
||||
end
|
||||
|
||||
handle jsonrpc: "2.0", id: 1, method: "test_method"
|
||||
|
||||
assert_rpc_error expected_error: {
|
||||
code: -32042,
|
||||
message: "Custom error",
|
||||
data: { elicitations: [{ id: "abc" }] },
|
||||
}
|
||||
end
|
||||
|
||||
it "falls back to error_type when error_code is nil" do
|
||||
register("test_method") do
|
||||
raise MCP::Server::RequestHandlerError.new(
|
||||
"Invalid params error",
|
||||
nil,
|
||||
error_type: :invalid_params,
|
||||
)
|
||||
end
|
||||
|
||||
handle jsonrpc: "2.0", id: 1, method: "test_method"
|
||||
|
||||
assert_rpc_error expected_error: {
|
||||
code: -32602,
|
||||
message: "Invalid params",
|
||||
data: "Invalid params error",
|
||||
}
|
||||
end
|
||||
|
||||
# 6 Batch
|
||||
#
|
||||
# To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.
|
||||
|
|
|
|||
|
|
@ -81,5 +81,6 @@ module MCP
|
|||
ensure_capability_does_not_raise_for Methods::PING
|
||||
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_PROGRESS
|
||||
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_CANCELLED
|
||||
ensure_capability_does_not_raise_for Methods::NOTIFICATIONS_ELICITATION_COMPLETE
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -83,6 +83,62 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
199
test/mcp/server_elicitation_test.rb
Normal file
199
test/mcp/server_elicitation_test.rb
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# 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)
|
||||
@requests << { method: method, params: params }
|
||||
{ action: "accept" }
|
||||
end
|
||||
|
||||
def send_response(response); end
|
||||
|
||||
def send_notification(method, params = nil)
|
||||
@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 sends request through transport" do
|
||||
result = @session.create_form_elicitation(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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
|
||||
Loading…
Reference in a new issue