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:
Koichi ITO 2026-04-06 17:14:26 +09:00
commit 985fd03ecb
4 changed files with 566 additions and 36 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View 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