Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1085,6 +1086,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).
Expand Down Expand Up @@ -1250,7 +1353,6 @@ end
### Unsupported Features (to be implemented in future versions)

- Resource subscriptions
- Elicitation

## Building an MCP Client

Expand Down
6 changes: 1 addition & 5 deletions conformance/expected_failures.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
server:
# TODO: Server-to-client requests (elicitation/create) are not implemented.
- tools-call-elicitation
- elicitation-sep1034-defaults
- elicitation-sep1330-enums
server: []
84 changes: 69 additions & 15 deletions conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ def call(prompt:, server_context:)
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"
Expand All @@ -188,41 +187,96 @@ class TestElicitation < MCP::Tool
)

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
Expand Down
25 changes: 16 additions & 9 deletions lib/json_rpc_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,27 @@ def process_request(request, id_validation_pattern:, &method_finder)
end

def handle_request_error(error, id, id_validation_pattern)
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"]
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"]
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

Expand Down
5 changes: 3 additions & 2 deletions lib/mcp/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module Methods
NOTIFICATIONS_MESSAGE = "notifications/message"
NOTIFICATIONS_PROGRESS = "notifications/progress"
NOTIFICATIONS_CANCELLED = "notifications/cancelled"
NOTIFICATIONS_ELICITATION_COMPLETE = "notifications/elicitation/complete"

class MissingRequiredCapabilityError < StandardError
attr_reader :method
Expand Down Expand Up @@ -79,8 +80,8 @@ def ensure_capability!(method, capabilities)
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

Expand Down
20 changes: 16 additions & 4 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,27 @@ class Server
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

Expand Down Expand Up @@ -114,7 +127,6 @@ def initialize(
# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
Methods::ELICITATION_CREATE => ->(_) {},
}
@transport = transport
end
Expand Down
36 changes: 36 additions & 0 deletions lib/mcp/server_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ def create_sampling_message(**kwargs)
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, ...)
Expand Down
Loading