Skip to content

Commit c8fb0d1

Browse files
authored
feat: implement MCP elicitation with form and URL modes (#210)
1 parent 10e6fba commit c8fb0d1

8 files changed

Lines changed: 1019 additions & 40 deletions

lib/action_mcp/configuration.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ class Configuration
2525
:logging_level,
2626
:active_profile,
2727
:profiles,
28-
:elicitation_enabled,
2928
:verbose_logging,
3029
# --- Authentication Options ---
3130
:authentication_methods,
@@ -58,7 +57,6 @@ def initialize
5857
@list_changed = true
5958
@logging_level = :warning
6059
@resources_subscribe = false
61-
@elicitation_enabled = false
6260
@verbose_logging = false
6361
@active_profile = :primary
6462
@profiles = default_profiles
@@ -280,7 +278,6 @@ def capabilities
280278
capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
281279
end
282280

283-
capabilities[:elicitation] = {} if @elicitation_enabled
284281

285282
# Tasks capability (MCP 2025-11-25)
286283
if @tasks_enabled
Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,126 @@
11
# frozen_string_literal: true
22

3+
require_relative "elicitation_request"
4+
require_relative "url_elicitation_request"
5+
36
module ActionMCP
47
module Server
5-
# Handles elicitation requests from the server to the client
8+
# Handles elicitation requests from the server to the client.
9+
#
10+
# Two modes per MCP 2025-11-25:
11+
# - Form mode: structured data collection with JSON Schema validation
12+
# - URL mode: out-of-band interaction via external URL (sensitive data, OAuth flows)
613
module Elicitation
7-
# Sends an elicitation request to the client to gather additional information
8-
# @param request_id [String, Integer] The JSON-RPC request ID
9-
# @param message [String] The message to present to the user
10-
# @param requested_schema [Hash] The schema for the requested information
11-
# @return [Hash] The elicitation response
12-
def send_elicitation_request(request_id, message:, requested_schema:)
13-
# Validate the requested schema
14-
validate_elicitation_schema!(requested_schema)
15-
16-
params = {
14+
URL_ELICITATION_REQUIRED_CODE = -32_042
15+
16+
# Send a form mode elicitation request to the client.
17+
# @param message [String] Human-readable message explaining why input is needed
18+
# @param requested_schema [Hash] JSON Schema for the expected response (primitive types only)
19+
# @param _meta [Hash] Optional metadata (e.g. related task)
20+
def send_elicitation_create(message:, requested_schema:, _meta: {})
21+
require_client_elicitation_support!(:form)
22+
23+
request = ElicitationRequest.new(
24+
message: message,
25+
requested_schema: requested_schema,
26+
_meta: _meta
27+
)
28+
request.assert_valid!
29+
30+
send_jsonrpc_request("elicitation/create", params: request.to_params)
31+
end
32+
33+
# Send a URL mode elicitation request to the client.
34+
# Used for sensitive data collection (API keys, OAuth, payments) that must not
35+
# pass through the MCP client.
36+
# @param message [String] Human-readable message explaining why navigation is needed
37+
# @param url [String] The URL the user should navigate to
38+
# @param elicitation_id [String] Unique identifier for this elicitation
39+
# @param _meta [Hash] Optional metadata (e.g. related task)
40+
def send_elicitation_create_url(message:, url:, elicitation_id: nil, _meta: {})
41+
require_client_elicitation_support!(:url)
42+
43+
request = UrlElicitationRequest.new(
44+
message: message,
45+
url: url,
46+
elicitation_id: elicitation_id,
47+
_meta: _meta
48+
)
49+
request.assert_valid!
50+
51+
send_jsonrpc_request("elicitation/create", params: request.to_params)
52+
end
53+
54+
# Send a completion notification for a URL mode elicitation.
55+
# Informs the client that the out-of-band interaction has completed.
56+
# @param elicitation_id [String] The elicitation ID from the original request
57+
def send_elicitation_complete_notification(elicitation_id)
58+
require_client_elicitation_support!(:url)
59+
send_jsonrpc_notification(
60+
"notifications/elicitation/complete",
61+
{ elicitationId: elicitation_id }
62+
)
63+
end
64+
65+
# Build a URLElicitationRequiredError response (-32042).
66+
# Used when a request cannot proceed until an elicitation is completed.
67+
# @param request_id [String, Integer] The JSON-RPC request ID to respond to
68+
# @param message [String] Human-readable error message
69+
# @param elicitations [Array<Hash>] Required URL mode elicitations
70+
def send_url_elicitation_required_error(request_id, message:, elicitations:)
71+
require_client_elicitation_support!(:url)
72+
73+
elicitations.each do |e|
74+
raise ArgumentError, "Each elicitation must have mode: 'url'" unless e[:mode] == "url"
75+
raise ArgumentError, "Each elicitation must have an elicitationId" unless e[:elicitationId].present?
76+
77+
UrlElicitationRequest.new(
78+
message: e[:message],
79+
url: e[:url],
80+
elicitation_id: e[:elicitationId]
81+
).assert_valid!
82+
end
83+
84+
error = {
85+
code: URL_ELICITATION_REQUIRED_CODE,
1786
message: message,
18-
requestedSchema: requested_schema
87+
data: { elicitations: elicitations }
1988
}
2089

21-
send_jsonrpc_request(request_id, method: "elicitation/create", params: params)
90+
send_jsonrpc_response(request_id, error: error)
2291
end
2392

2493
private
2594

26-
# Validates that the requested schema follows the elicitation constraints
27-
# Only allows primitive types without nesting
28-
def validate_elicitation_schema!(schema)
29-
unless schema.is_a?(Hash) && schema[:type] == "object"
30-
raise ArgumentError, "Elicitation schema must be an object type"
31-
end
95+
# Check that the client declared support for the given elicitation mode.
96+
# Elicitation is a client capability — servers MUST NOT send modes the client didn't declare.
97+
def require_client_elicitation_support!(mode)
98+
client_caps = session.client_capabilities || {}
99+
elicitation_caps = client_caps["elicitation"] || client_caps[:elicitation]
32100

33-
properties = schema[:properties]
34-
raise ArgumentError, "Elicitation schema must have properties" unless properties.is_a?(Hash)
101+
raise UnsupportedElicitationError, "Client does not support elicitation" unless elicitation_caps.is_a?(Hash)
35102

36-
properties.each do |key, prop_schema|
37-
validate_primitive_schema!(key, prop_schema)
103+
if mode == :form
104+
# Empty hash or explicit form: {} both mean form support (backward compat with 2025-06-18)
105+
# But if client only declared url: {} without form, reject
106+
form_cap = elicitation_caps["form"] || elicitation_caps[:form]
107+
unless elicitation_caps.empty? || form_cap
108+
raise UnsupportedElicitationError, "Client does not support form mode elicitation"
109+
end
110+
return
38111
end
39-
end
40112

41-
# Validates individual property schemas are primitive types
42-
def validate_primitive_schema!(key, schema)
43-
raise ArgumentError, "Property '#{key}' must have a schema definition" unless schema.is_a?(Hash)
44-
45-
type = schema[:type]
46-
case type
47-
when "string"
48-
# Valid string schema, check for enums
49-
raise ArgumentError, "Property '#{key}' enum must be an array" if schema[:enum] && !schema[:enum].is_a?(Array)
50-
when "number", "integer", "boolean"
51-
# Valid primitive types
52-
else
53-
raise ArgumentError, "Property '#{key}' must be a primitive type (string, number, integer, boolean)"
113+
# URL mode requires protocol version 2025-11-25+
114+
unless session.protocol_version == "2025-11-25"
115+
raise UnsupportedElicitationError, "URL mode elicitation requires protocol version 2025-11-25"
54116
end
117+
118+
# Client must explicitly declare url mode support (empty hash = form-only for 2025-06-18 clients)
119+
url_cap = elicitation_caps["url"] || elicitation_caps[:url]
120+
raise UnsupportedElicitationError, "Client does not support URL mode elicitation" unless url_cap
55121
end
56122
end
123+
124+
class UnsupportedElicitationError < StandardError; end
57125
end
58126
end
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# frozen_string_literal: true
2+
3+
module ActionMCP
4+
module Server
5+
# Value object for form-mode elicitation requests.
6+
# Validates that the requested schema follows MCP constraints:
7+
# flat object with primitive properties only.
8+
class ElicitationRequest
9+
include ActiveModel::Model
10+
include ActiveModel::Attributes
11+
12+
attribute :message, :string
13+
attribute :requested_schema # Hash
14+
attribute :_meta # Hash, optional
15+
16+
validates :message, presence: true
17+
validates :requested_schema, presence: true
18+
validate :schema_must_be_object_with_properties, if: -> { requested_schema.present? }
19+
validate :properties_must_be_primitive, if: -> { errors[:requested_schema].empty? && requested_schema.present? }
20+
21+
# Wrap incoming schema in indifferent access so both string and symbol keys work.
22+
def requested_schema=(value)
23+
super(value.is_a?(Hash) ? value.with_indifferent_access : value)
24+
end
25+
26+
# @return [Hash] JSON-RPC params for elicitation/create
27+
def to_params
28+
params = { mode: "form", message: message, requestedSchema: requested_schema.to_hash.deep_symbolize_keys }
29+
params[:_meta] = _meta if _meta.present?
30+
params
31+
end
32+
33+
# Validates and raises ArgumentError on failure (preserving public API).
34+
# Named assert_valid! to avoid shadowing ActiveModel#validate!
35+
def assert_valid!
36+
return if valid?
37+
38+
raise ArgumentError, errors.full_messages.join(", ")
39+
end
40+
41+
private
42+
43+
def schema_must_be_object_with_properties
44+
unless requested_schema.is_a?(Hash) && requested_schema[:type] == "object"
45+
errors.add(:requested_schema, "must be an object type")
46+
return
47+
end
48+
49+
properties = requested_schema[:properties]
50+
errors.add(:requested_schema, "must have properties") unless properties.is_a?(Hash)
51+
end
52+
53+
def properties_must_be_primitive
54+
properties = requested_schema[:properties]
55+
return unless properties.is_a?(Hash)
56+
57+
properties.each do |key, prop_schema|
58+
validate_primitive_property(key, prop_schema)
59+
end
60+
end
61+
62+
def validate_primitive_property(key, schema)
63+
unless schema.is_a?(Hash)
64+
errors.add(:requested_schema, "property '#{key}' must have a schema definition")
65+
return
66+
end
67+
68+
case schema[:type]
69+
when "string"
70+
validate_string_enum(key, schema)
71+
when "number", "integer", "boolean"
72+
# valid primitive types
73+
when "array"
74+
validate_enum_array(key, schema)
75+
else
76+
errors.add(:requested_schema,
77+
"property '#{key}' must be a primitive type (string, number, integer, boolean) or enum array")
78+
end
79+
end
80+
81+
def validate_string_enum(key, schema)
82+
if schema[:enum] && !schema[:enum].is_a?(Array)
83+
errors.add(:requested_schema, "property '#{key}' enum must be an array")
84+
end
85+
end
86+
87+
def validate_enum_array(key, schema)
88+
items = schema[:items]
89+
unless items.is_a?(Hash)
90+
errors.add(:requested_schema, "property '#{key}' array must have items schema")
91+
return
92+
end
93+
94+
unless items[:enum].is_a?(Array) || items[:anyOf].is_a?(Array)
95+
errors.add(:requested_schema, "property '#{key}' array items must be an enum (enum or anyOf)")
96+
end
97+
end
98+
end
99+
end
100+
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
require "uri"
4+
5+
module ActionMCP
6+
module Server
7+
# Value object for URL-mode elicitation requests.
8+
# Used for sensitive data collection (API keys, OAuth, payments)
9+
# that must not pass through the MCP client.
10+
class UrlElicitationRequest
11+
include ActiveModel::Model
12+
include ActiveModel::Attributes
13+
14+
attribute :message, :string
15+
attribute :url, :string
16+
attribute :elicitation_id, :string
17+
attribute :_meta # Hash, optional
18+
19+
validates :message, presence: true
20+
validates :url, presence: true
21+
validate :url_must_be_valid_http, if: -> { url.present? }
22+
23+
def initialize(attributes = {})
24+
super
25+
self.elicitation_id = SecureRandom.uuid_v7 if elicitation_id.blank?
26+
end
27+
28+
# @return [Hash] JSON-RPC params for elicitation/create
29+
def to_params
30+
params = {
31+
mode: "url",
32+
message: message,
33+
url: url,
34+
elicitationId: elicitation_id
35+
}
36+
params[:_meta] = _meta if _meta.present?
37+
params
38+
end
39+
40+
# Validates and raises ArgumentError on failure (preserving public API).
41+
# Named assert_valid! to avoid shadowing ActiveModel#validate!
42+
def assert_valid!
43+
return if valid?
44+
45+
raise ArgumentError, errors.full_messages.join(", ")
46+
end
47+
48+
private
49+
50+
def url_must_be_valid_http
51+
parsed = URI.parse(url)
52+
unless parsed.is_a?(URI::HTTP) && parsed.host.present?
53+
errors.add(:url, "must be an HTTP or HTTPS URL with a host")
54+
end
55+
rescue URI::InvalidURIError
56+
errors.add(:url, "is not a valid URI")
57+
end
58+
end
59+
end
60+
end

0 commit comments

Comments
 (0)