Merge pull request #312 from koic/support_elicitation

Support `elicitation/create` per MCP specification
This commit is contained in:
Koichi ITO 2026-04-15 02:22:55 +09:00 • committed by GitHub
commit 3b1777adb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 566 additions and 36 deletions

104
README.md
View file

@ -53,6 +53,7 @@ It implements the Model Context Protocol specification, handling model context r
- `resources/templates/list` - Lists all registered resource templates and their schemas
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
- `elicitation/create` - Requests user input from the client (server-to-client)
### Usage
@ -1137,6 +1138,108 @@ The SDK automatically enforces the 100-item limit per the MCP specification.
The server validates that the referenced prompt, resource, or resource template is registered before calling the handler.
Requests for unknown references return an error.
### Elicitation
The MCP Ruby SDK supports [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation),
which allows servers to request additional information from users through the client during tool execution.
Elicitation is a **server-to-client request**. The server sends a request and blocks until the user responds via the client.
#### Capabilities
Clients must declare the `elicitation` capability during initialization. The server checks this before sending any elicitation request
and raises a `RuntimeError` if the client does not support it.
For URL mode support, the client must also declare `elicitation.url` capability.
#### Using Elicitation in Tools
Tools that accept a `server_context:` parameter can call `create_form_elicitation` on it:
```ruby
server.define_tool(name: "collect_info", description: "Collect user info") do |server_context:|
result = server_context.create_form_elicitation(
message: "Please provide your name",
requested_schema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
)
MCP::Tool::Response.new([{ type: "text", text: "Hello, #{result[:content][:name]}" }])
end
```
#### Form Mode
Form mode collects structured data from the user directly through the MCP client:
```ruby
server.define_tool(name: "collect_contact", description: "Collect contact info") do |server_context:|
result = server_context.create_form_elicitation(
message: "Please provide your contact information",
requested_schema: {
type: "object",
properties: {
name: { type: "string", description: "Your full name" },
email: { type: "string", format: "email", description: "Your email address" },
},
required: ["name", "email"],
},
)
text = case result[:action]
when "accept"
"Hello, #{result[:content][:name]} (#{result[:content][:email]})"
when "decline"
"User declined"
when "cancel"
"User cancelled"
end
MCP::Tool::Response.new([{ type: "text", text: text }])
end
```
#### URL Mode
URL mode directs the user to an external URL for out-of-band interactions such as OAuth flows:
```ruby
server.define_tool(name: "authorize_github", description: "Authorize GitHub") do |server_context:|
elicitation_id = SecureRandom.uuid
result = server_context.create_url_elicitation(
message: "Please authorize access to your GitHub account",
url: "https://example.com/oauth/authorize?elicitation_id=#{elicitation_id}",
elicitation_id: elicitation_id,
)
server_context.notify_elicitation_complete(elicitation_id: elicitation_id)
MCP::Tool::Response.new([{ type: "text", text: "Authorization complete" }])
end
```
#### URLElicitationRequiredError
When a tool cannot proceed until an out-of-band elicitation is completed, raise `MCP::Server::URLElicitationRequiredError`.
This returns a JSON-RPC error with code `-32042` to the client:
```ruby
server.define_tool(name: "access_github", description: "Access GitHub") do |server_context:|
raise MCP::Server::URLElicitationRequiredError.new([
{
mode: "url",
elicitationId: SecureRandom.uuid,
url: "https://example.com/oauth/authorize",
message: "GitHub authorization is required.",
},
])
end
```
### Logging
The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging).
@ -1302,7 +1405,6 @@ end
### Unsupported Features (to be implemented in future versions)
- Resource subscriptions
- Elicitation
## Building an MCP Client

View file

@ -1,5 +1 @@
server:
# TODO: Server-to-client requests (elicitation/create) are not implemented.
- tools-call-elicitation
- elicitation-sep1034-defaults
- elicitation-sep1330-enums
server: []

View file

@ -178,7 +178,6 @@ module Conformance
end
end
# TODO: Implement when `Transport` supports server-to-client requests.
class TestElicitation < MCP::Tool
tool_name "test_elicitation"
description "A tool that requests user input from the client"
@ -188,41 +187,96 @@ module Conformance
)
class << self
def call(message:)
MCP::Tool::Response.new(
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
error: true,
def call(server_context:, message:)
result = server_context.create_form_elicitation(
message: message,
requested_schema: {
type: "object",
properties: {
username: { type: "string", description: "User's response" },
email: { type: "string", description: "User's email address" },
},
required: ["username", "email"],
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("User response: #{result}").to_h])
end
end
end
# TODO: Implement when `Transport` supports server-to-client requests.
class TestElicitationSep1034Defaults < MCP::Tool
tool_name "test_elicitation_sep1034_defaults"
description "A tool that tests elicitation with default values"
class << self
def call(**_args)
MCP::Tool::Response.new(
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
error: true,
def call(server_context:, **_args)
result = server_context.create_form_elicitation(
message: "Please provide your information (with defaults)",
requested_schema: {
type: "object",
properties: {
name: { type: "string", default: "John Doe" },
age: { type: "integer", default: 30 },
score: { type: "number", default: 95.5 },
status: { type: "string", enum: ["active", "inactive", "pending"], default: "active" },
verified: { type: "boolean", default: true },
},
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h])
end
end
end
# TODO: Implement when `Transport` supports server-to-client requests.
class TestElicitationSep1330Enums < MCP::Tool
tool_name "test_elicitation_sep1330_enums"
description "A tool that tests elicitation with enum schemas"
class << self
def call(**_args)
MCP::Tool::Response.new(
[MCP::Content::Text.new("Elicitation not supported in this SDK version").to_h],
error: true,
def call(server_context:, **_args)
result = server_context.create_form_elicitation(
message: "Please select options",
requested_schema: {
type: "object",
properties: {
untitledSingle: {
type: "string",
enum: ["option1", "option2", "option3"],
},
titledSingle: {
type: "string",
oneOf: [
{ const: "value1", title: "First Option" },
{ const: "value2", title: "Second Option" },
{ const: "value3", title: "Third Option" },
],
},
legacyEnum: {
type: "string",
enum: ["opt1", "opt2", "opt3"],
enumNames: ["Option One", "Option Two", "Option Three"],
},
untitledMulti: {
type: "array",
items: {
type: "string",
enum: ["option1", "option2", "option3"],
},
},
titledMulti: {
type: "array",
items: {
anyOf: [
{ const: "value1", title: "First Choice" },
{ const: "value2", title: "Second Choice" },
{ const: "value3", title: "Third Choice" },
],
},
},
},
},
)
MCP::Tool::Response.new([MCP::Content::Text.new("Elicitation result: #{result}").to_h])
end
end
end

View file

@ -117,20 +117,27 @@ module JsonRpcHandler
end
def handle_request_error(error, id, id_validation_pattern)
error_type = error.respond_to?(:error_type) ? error.error_type : nil
if error.respond_to?(:error_code) && error.error_code
code = error.error_code
message = error.message
else
error_type = error.respond_to?(:error_type) ? error.error_type : nil
code, message = case error_type
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
code, message = case error_type
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
end
end
data = error.respond_to?(:error_data) && error.error_data ? error.error_data : error.message
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
code: code,
message: message,
data: error.message,
data: data,
})
end

View file

@ -33,6 +33,7 @@ module MCP
NOTIFICATIONS_MESSAGE = "notifications/message"
NOTIFICATIONS_PROGRESS = "notifications/progress"
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"
class MissingRequiredCapabilityError < StandardError
attr_reader :method
@ -79,8 +80,8 @@ module MCP
require_capability!(method, capabilities, :sampling)
when ELICITATION_CREATE
require_capability!(method, capabilities, :elicitation)
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
# No specific capability required for initialize, ping, progress or cancelled
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
# No specific capability required.
end
end

View file

@ -31,14 +31,27 @@ module MCP
MAX_COMPLETION_VALUES = 100
class RequestHandlerError < StandardError
attr_reader :error_type
attr_reader :original_error
attr_reader :error_type, :original_error, :error_code, :error_data
def initialize(message, request, error_type: :internal_error, original_error: nil)
def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil)
super(message)
@request = request
@error_type = error_type
@original_error = original_error
@error_code = error_code
@error_data = error_data
end
end
class URLElicitationRequiredError < RequestHandlerError
def initialize(elicitations)
super(
"URL elicitation required",
nil,
error_type: :url_elicitation_required,
error_code: -32042,
error_data: { elicitations: elicitations },
)
end
end
@ -114,7 +127,6 @@ module MCP
# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
Methods::ELICITATION_CREATE => ->(_) {},
}
@transport = transport
end

View file

@ -43,6 +43,42 @@ module MCP
end
end
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support elicitation.
def create_form_elicitation(**kwargs)
if @notification_target.respond_to?(:create_form_elicitation)
@notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
elsif @context.respond_to?(:create_form_elicitation)
@context.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}"
end
end
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
def create_url_elicitation(**kwargs)
if @notification_target.respond_to?(:create_url_elicitation)
@notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
elsif @context.respond_to?(:create_url_elicitation)
@context.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}"
end
end
# Delegates to the session so the notification is scoped to the originating client.
def notify_elicitation_complete(**kwargs)
if @notification_target.respond_to?(:notify_elicitation_complete)
@notification_target.notify_elicitation_complete(**kwargs)
elsif @context.respond_to?(:notify_elicitation_complete)
@context.notify_elicitation_complete(**kwargs)
else
raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}"
end
end
def method_missing(name, ...)
if @context.respond_to?(name)
@context.public_send(name, ...)

View file

@ -47,6 +47,35 @@ module MCP
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
end
# Sends an `elicitation/create` request (form mode) scoped to this session.
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
unless client_capabilities&.dig(:elicitation)
raise "Client does not support elicitation. " \
"The client must declare the `elicitation` capability during initialization."
end
params = { mode: "form", message: message, requestedSchema: requested_schema }
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
end
# Sends an `elicitation/create` request (URL mode) scoped to this session.
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
unless client_capabilities&.dig(:elicitation, :url)
raise "Client does not support URL mode elicitation. " \
"The client must declare the `elicitation.url` capability during initialization."
end
params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
end
# Sends an elicitation complete notification scoped to this session.
def notify_elicitation_complete(elicitation_id:)
send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
rescue => e
@server.report_exception(e, notification: "elicitation_complete")
end
# Sends a progress notification to this session only.
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
params = {

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