|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
| 3 | +require_relative "elicitation_request" |
| 4 | +require_relative "url_elicitation_request" |
| 5 | + |
3 | 6 | module ActionMCP |
4 | 7 | 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) |
6 | 13 | 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, |
17 | 86 | message: message, |
18 | | - requestedSchema: requested_schema |
| 87 | + data: { elicitations: elicitations } |
19 | 88 | } |
20 | 89 |
|
21 | | - send_jsonrpc_request(request_id, method: "elicitation/create", params: params) |
| 90 | + send_jsonrpc_response(request_id, error: error) |
22 | 91 | end |
23 | 92 |
|
24 | 93 | private |
25 | 94 |
|
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] |
32 | 100 |
|
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) |
35 | 102 |
|
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 |
38 | 111 | end |
39 | | - end |
40 | 112 |
|
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" |
54 | 116 | 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 |
55 | 121 | end |
56 | 122 | end |
| 123 | + |
| 124 | + class UnsupportedElicitationError < StandardError; end |
57 | 125 | end |
58 | 126 | end |
0 commit comments