Merge pull request #312 from koic/support_elicitation
Support `elicitation/create` per MCP specification
This commit is contained in:
commit
3b1777adb3
12 changed files with 566 additions and 36 deletions
104
README.md
104
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1 @@
|
|||
server:
|
||||
# TODO: Server-to-client requests (elicitation/create) are not implemented.
|
||||
- tools-call-elicitation
|
||||
- elicitation-sep1034-defaults
|
||||
- elicitation-sep1330-enums
|
||||
server: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ...)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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