actionmcp 0.111.1 → 0.200.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -18
  3. data/Rakefile +3 -0
  4. data/app/controllers/action_mcp/application_controller.rb +166 -109
  5. data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
  6. data/app/helpers/action_mcp/apps_helper.rb +48 -0
  7. data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
  8. data/app/models/action_mcp/session/message.rb +8 -3
  9. data/app/models/action_mcp/session/task.rb +15 -5
  10. data/app/models/action_mcp/session.rb +93 -19
  11. data/config/routes.rb +1 -1
  12. data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
  13. data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
  14. data/lib/action_mcp/apps/view_manifest.rb +170 -0
  15. data/lib/action_mcp/apps.rb +17 -2
  16. data/lib/action_mcp/configuration.rb +14 -30
  17. data/lib/action_mcp/content/audio.rb +4 -1
  18. data/lib/action_mcp/content/base.rb +3 -1
  19. data/lib/action_mcp/content/image.rb +4 -1
  20. data/lib/action_mcp/content/resource.rb +15 -14
  21. data/lib/action_mcp/content/resource_link.rb +15 -5
  22. data/lib/action_mcp/content/text.rb +2 -1
  23. data/lib/action_mcp/content/validation.rb +253 -0
  24. data/lib/action_mcp/content.rb +3 -1
  25. data/lib/action_mcp/dev/runner.rb +203 -0
  26. data/lib/action_mcp/engine.rb +24 -2
  27. data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
  28. data/lib/action_mcp/logging/logger.rb +16 -11
  29. data/lib/action_mcp/logging.rb +34 -1
  30. data/lib/action_mcp/middleware/origin_validation.rb +9 -13
  31. data/lib/action_mcp/output_schema_builder.rb +2 -0
  32. data/lib/action_mcp/prompt_response.rb +12 -2
  33. data/lib/action_mcp/protocol_validator.rb +513 -0
  34. data/lib/action_mcp/renderable.rb +4 -4
  35. data/lib/action_mcp/resource.rb +15 -11
  36. data/lib/action_mcp/resource_template.rb +105 -90
  37. data/lib/action_mcp/resource_templates_registry.rb +31 -97
  38. data/lib/action_mcp/schema_validator.rb +43 -0
  39. data/lib/action_mcp/server/base_session.rb +134 -28
  40. data/lib/action_mcp/server/base_session_store.rb +1 -1
  41. data/lib/action_mcp/server/capabilities.rb +10 -30
  42. data/lib/action_mcp/server/client_notifications.rb +63 -0
  43. data/lib/action_mcp/server/completions.rb +68 -0
  44. data/lib/action_mcp/server/elicitation.rb +47 -25
  45. data/lib/action_mcp/server/elicitation_request.rb +136 -51
  46. data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
  47. data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
  48. data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
  49. data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
  50. data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
  51. data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
  52. data/lib/action_mcp/server/messaging_service.rb +3 -1
  53. data/lib/action_mcp/server/prompts.rb +5 -3
  54. data/lib/action_mcp/server/resources.rb +35 -2
  55. data/lib/action_mcp/server/response_collector.rb +5 -1
  56. data/lib/action_mcp/server/roots.rb +15 -2
  57. data/lib/action_mcp/server/sampling.rb +36 -1
  58. data/lib/action_mcp/server/sampling_request.rb +317 -55
  59. data/lib/action_mcp/server/tasks.rb +27 -45
  60. data/lib/action_mcp/server/tool_result.rb +27 -0
  61. data/lib/action_mcp/server/tools.rb +12 -10
  62. data/lib/action_mcp/server/transport_handler.rb +3 -0
  63. data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
  64. data/lib/action_mcp/tool.rb +133 -199
  65. data/lib/action_mcp/tool_response.rb +35 -4
  66. data/lib/action_mcp/tools_registry.rb +1 -1
  67. data/lib/action_mcp/version.rb +1 -1
  68. data/lib/action_mcp.rb +2 -5
  69. data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
  70. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
  71. data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
  72. data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
  73. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
  74. data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
  75. data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
  76. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
  77. data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
  78. data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
  79. data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
  80. data/lib/generators/action_mcp/view/view_generator.rb +57 -0
  81. data/lib/tasks/action_mcp_apps.rake +17 -0
  82. data/lib/tasks/action_mcp_tasks.rake +135 -74
  83. metadata +47 -17
@@ -70,8 +70,15 @@ module ActionMCP
70
70
  initialized
71
71
  end
72
72
 
73
+ def begin_initialization!
74
+ return false unless status == "pre_initialize" && !initialized?
75
+
76
+ self.status = "initializing"
77
+ save
78
+ end
79
+
73
80
  def initialize!
74
- return false if initialized?
81
+ return false unless status == "initializing" && !initialized?
75
82
 
76
83
  self.initialized = true
77
84
  self.status = "initialized"
@@ -86,23 +93,11 @@ module ActionMCP
86
93
 
87
94
  # Message management
88
95
  def write(data)
89
- @messages << {
90
- data: data,
91
- direction: role == "server" ? "client" : "server",
92
- created_at: Time.current
93
- }
94
- @message_counter.increment
95
- self.messages_count = @message_counter.value
96
+ record_message(data, writer_role)
96
97
  end
97
98
 
98
99
  def read(data)
99
- @messages << {
100
- data: data,
101
- direction: role,
102
- created_at: Time.current
103
- }
104
- @message_counter.increment
105
- self.messages_count = @message_counter.value
100
+ record_message(data, role)
106
101
  end
107
102
 
108
103
  def messages
@@ -113,6 +108,42 @@ module ActionMCP
113
108
  SubscriptionCollection.new(@subscriptions)
114
109
  end
115
110
 
111
+ def cancel_in_flight_request(request_id)
112
+ return if request_id.nil?
113
+
114
+ request = received_requests_with_id(request_id).find do |message|
115
+ !message[:request_acknowledged] && !message[:request_cancelled]
116
+ end
117
+ return if request && message_payload(request)[:method] == JsonRpcHandlerBase::Methods::INITIALIZE
118
+
119
+ request&.tap { |message| message[:request_cancelled] = true }
120
+ end
121
+
122
+ def client_request_for_progress(progress_token)
123
+ return if progress_token.nil?
124
+
125
+ issued_client_requests.find do |request|
126
+ message_payload(request).dig(:params, :_meta, :progressToken) == progress_token &&
127
+ client_request_accepts_progress?(request)
128
+ end
129
+ end
130
+
131
+ def client_request_for_task(task_id)
132
+ return unless task_id.is_a?(String) && task_id.present?
133
+
134
+ response = @messages.reverse_each.find do |message|
135
+ message[:direction] == role &&
136
+ message_payload(message).dig(:result, :task, :taskId) == task_id
137
+ end
138
+ return unless response
139
+
140
+ response_id = message_payload(response)[:id]
141
+ issued_client_requests.find do |request|
142
+ payload = message_payload(request)
143
+ payload[:id] == response_id && payload.dig(:params, :task).is_a?(Hash)
144
+ end
145
+ end
146
+
116
147
  # Capability methods
117
148
  def server_capabilities_payload
118
149
  payload = {
@@ -150,6 +181,10 @@ module ActionMCP
150
181
  @subscriptions.delete_if { |s| s[:uri] == uri }
151
182
  end
152
183
 
184
+ def resource_subscribed?(uri)
185
+ @subscriptions.any? { |subscription| subscription[:uri] == uri }
186
+ end
187
+
153
188
  # Progress notification
154
189
  def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
155
190
  handler = ActionMCP::Server::TransportHandler.new(self)
@@ -280,20 +315,91 @@ module ActionMCP
280
315
 
281
316
  private
282
317
 
283
- def capabilities_for_protocol(capabilities)
284
- filtered =
285
- if capabilities.respond_to?(:deep_dup)
286
- capabilities.deep_dup
287
- elsif capabilities
288
- capabilities.dup
289
- else
290
- {}
291
- end
292
- return filtered if protocol_version == "2025-11-25"
318
+ CLIENT_REQUEST_METHODS = %w[sampling/createMessage elicitation/create].freeze
319
+ TERMINAL_CLIENT_TASK_STATUSES = %w[cancelled completed failed].freeze
320
+
321
+ def record_message(data, direction)
322
+ payload = data.respond_to?(:to_h) ? data.to_h : data
323
+ entry = {
324
+ data: data,
325
+ direction: direction,
326
+ request_acknowledged: false,
327
+ request_cancelled: false,
328
+ is_ping: payload.is_a?(Hash) && (payload[:method] || payload["method"]) == "ping",
329
+ created_at: Time.current
330
+ }
331
+ @messages << entry
332
+ acknowledge_request(entry)
333
+ @message_counter.increment
334
+ self.messages_count = @message_counter.value
335
+ entry
336
+ end
337
+
338
+ def acknowledge_request(response)
339
+ payload = message_payload(response)
340
+ return unless payload.key?(:id) && (payload.key?(:result) || payload.key?(:error))
341
+
342
+ request = @messages.reverse_each.find do |message|
343
+ next if message.equal?(response) || message[:direction] == response[:direction]
344
+
345
+ request_payload = message_payload(message)
346
+ request_payload.key?(:method) && request_payload[:id] == payload[:id]
347
+ end
348
+ return unless request
349
+
350
+ request[:request_acknowledged] = true
351
+ response[:is_ping] = request[:is_ping]
352
+ end
353
+
354
+ def received_requests_with_id(request_id)
355
+ @messages.reverse_each.select do |message|
356
+ payload = message_payload(message)
357
+ message[:direction] == role && payload.key?(:method) && payload[:id] == request_id
358
+ end
359
+ end
360
+
361
+ def issued_client_requests
362
+ @messages.reverse_each.select do |message|
363
+ payload = message_payload(message)
364
+ message[:direction] == writer_role && CLIENT_REQUEST_METHODS.include?(payload[:method])
365
+ end
366
+ end
367
+
368
+ def client_request_accepts_progress?(request)
369
+ return true unless request[:request_acknowledged]
370
+
371
+ request_id = message_payload(request)[:id]
372
+ response = @messages.reverse_each.find do |message|
373
+ payload = message_payload(message)
374
+ message[:direction] == role && payload[:id] == request_id && payload.key?(:result)
375
+ end
376
+ task = message_payload(response).dig(:result, :task) if response
377
+ return false unless task.is_a?(Hash) && task[:taskId].is_a?(String)
378
+
379
+ latest_status = @messages.reverse_each.filter_map do |message|
380
+ payload = message_payload(message)
381
+ next unless message[:direction] == role
382
+ next unless payload[:method] == JsonRpcHandlerBase::Methods::NOTIFICATIONS_TASKS_STATUS
383
+ next unless payload.dig(:params, :taskId) == task[:taskId]
293
384
 
294
- filtered.delete("tasks")
295
- filtered.delete(:tasks)
296
- filtered
385
+ payload.dig(:params, :status)
386
+ end.first
387
+
388
+ !TERMINAL_CLIENT_TASK_STATUSES.include?(latest_status || task[:status])
389
+ end
390
+
391
+ def message_payload(message)
392
+ data = message[:data]
393
+ data = data.to_h if data.respond_to?(:to_h)
394
+ data.is_a?(Hash) ? data.with_indifferent_access : {}.with_indifferent_access
395
+ end
396
+
397
+ def writer_role
398
+ role == "server" ? "client" : "server"
399
+ end
400
+
401
+ def capabilities_for_protocol(capabilities)
402
+ capabilities ? capabilities.deep_dup : {}
297
403
  end
298
404
 
299
405
  def normalize_name(class_or_name, type)
@@ -10,7 +10,7 @@ module ActionMCP
10
10
  end
11
11
 
12
12
  def create_session(session_id = nil, attributes = {})
13
- session_id ||= SecureRandom.hex(6)
13
+ session_id ||= SecureRandom.hex(16)
14
14
 
15
15
  session_data = {
16
16
  id: session_id,
@@ -13,53 +13,33 @@ module ActionMCP
13
13
  client_protocol_version = params["protocolVersion"]
14
14
  client_info = params["clientInfo"]
15
15
  client_capabilities = params["capabilities"]
16
- session_id = params["sessionId"]
17
-
18
- unless client_protocol_version.is_a?(String) && client_protocol_version.present?
16
+ unless client_protocol_version.is_a?(String)
19
17
  return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
20
18
  end
21
19
 
22
- unless ActionMCP::SUPPORTED_VERSIONS.include?(client_protocol_version)
23
- error_message = "Unsupported protocol version. Client requested '#{client_protocol_version}' but server supports #{ActionMCP::SUPPORTED_VERSIONS.join(', ')}"
24
- error_data = {
25
- supported: ActionMCP::SUPPORTED_VERSIONS,
26
- requested: client_protocol_version
27
- }
28
- return send_jsonrpc_error(request_id, :invalid_params, error_message, error_data)
29
- end
30
-
31
- unless client_info.is_a?(Hash)
20
+ unless client_info.is_a?(Hash) && client_info["name"].is_a?(String) && client_info["version"].is_a?(String)
32
21
  return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'clientInfo'")
33
22
  end
34
23
  unless client_capabilities.is_a?(Hash)
35
24
  return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
36
25
  end
37
26
 
38
- # Handle session resumption if sessionId provided
39
- if session_id
40
- existing_session = ActionMCP::Session.find_by(id: session_id)
41
- if existing_session&.initialized?
42
- # Resume existing session - update transport reference
43
- transport.instance_variable_set(:@session, existing_session)
44
-
45
- # Return existing session info
46
- capabilities_payload = existing_session.server_capabilities_payload
47
- capabilities_payload[:protocolVersion] = client_protocol_version
48
- return send_jsonrpc_response(request_id, result: capabilities_payload)
49
- end
27
+ negotiated_version = if ActionMCP::SUPPORTED_VERSIONS.include?(client_protocol_version)
28
+ client_protocol_version
29
+ else
30
+ ActionMCP::LATEST_VERSION
50
31
  end
51
32
 
52
- # Create new session if not resuming
53
33
  session.store_client_info(client_info)
54
34
  session.store_client_capabilities(client_capabilities)
55
- session.set_protocol_version(client_protocol_version)
35
+ session.set_protocol_version(negotiated_version)
56
36
 
57
- unless session.initialize!
58
- return send_jsonrpc_error(request_id, :internal_error, "Failed to initialize session")
37
+ unless session.begin_initialization!
38
+ return send_jsonrpc_error(request_id, :invalid_request, "Session has already started initialization")
59
39
  end
60
40
 
61
41
  capabilities_payload = session.server_capabilities_payload
62
- capabilities_payload[:protocolVersion] = client_protocol_version
42
+ capabilities_payload[:protocolVersion] = negotiated_version
63
43
 
64
44
  send_jsonrpc_response(request_id, result: capabilities_payload)
65
45
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Correlates client notifications with server session state. Applications
6
+ # can consume the resulting Rails notifications without ActionMCP keeping
7
+ # callback objects in a persistent session.
8
+ module ClientNotifications
9
+ def receive_cancelled_notification(params)
10
+ params = normalize_client_notification_params(params)
11
+ request = if session.respond_to?(:cancel_in_flight_request)
12
+ session.cancel_in_flight_request(params[:requestId])
13
+ end
14
+ return log_unmatched_client_notification("cancellation", params[:requestId]) unless request
15
+
16
+ instrument_client_notification("request_cancelled", request, params)
17
+ request
18
+ end
19
+
20
+ def receive_progress_notification(params)
21
+ params = normalize_client_notification_params(params)
22
+ request = if session.respond_to?(:client_request_for_progress)
23
+ session.client_request_for_progress(params[:progressToken])
24
+ end
25
+ return log_unmatched_client_notification("progress token", params[:progressToken]) unless request
26
+
27
+ instrument_client_notification("request_progress", request, params)
28
+ request
29
+ end
30
+
31
+ def receive_task_status_notification(params)
32
+ params = normalize_client_notification_params(params)
33
+ request = if session.respond_to?(:client_request_for_task)
34
+ session.client_request_for_task(params[:taskId])
35
+ end
36
+ return log_unmatched_client_notification("task", params[:taskId]) unless request
37
+
38
+ instrument_client_notification("task_status", request, params)
39
+ request
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_client_notification_params(params)
45
+ params.respond_to?(:to_h) ? params.to_h.with_indifferent_access : {}.with_indifferent_access
46
+ end
47
+
48
+ def instrument_client_notification(event, request, params)
49
+ ActiveSupport::Notifications.instrument(
50
+ "#{event}.action_mcp",
51
+ session: session,
52
+ request: request,
53
+ params: params
54
+ )
55
+ end
56
+
57
+ def log_unmatched_client_notification(kind, identifier)
58
+ Rails.logger.debug("Ignoring MCP notification for unknown #{kind}: #{identifier.inspect}")
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ module Completions
6
+ MAX_COMPLETION_VALUES = 100
7
+
8
+ def send_completion_complete(request_id, params)
9
+ unless completion_capability?
10
+ send_jsonrpc_error(request_id, :method_not_found, "Completions are not available for this session")
11
+ return
12
+ end
13
+
14
+ if (validation_error = ProtocolValidator.request_params_validation_error("completion/complete", params))
15
+ send_jsonrpc_error(request_id, validation_error.code, validation_error.message)
16
+ return
17
+ end
18
+
19
+ params = params.with_indifferent_access
20
+ definition = completion_definition(params[:ref], params.dig(:argument, :name))
21
+ unless definition
22
+ send_jsonrpc_error(request_id, :invalid_params, "Unknown completion reference or argument")
23
+ return
24
+ end
25
+
26
+ values = matching_completion_values(definition, params.dig(:argument, :value))
27
+ result = {
28
+ completion: {
29
+ values: values.first(MAX_COMPLETION_VALUES),
30
+ total: values.length,
31
+ hasMore: values.length > MAX_COMPLETION_VALUES
32
+ }
33
+ }
34
+ send_jsonrpc_response(request_id, result: result)
35
+ end
36
+
37
+ private
38
+
39
+ def completion_capability?
40
+ capabilities = (session.server_capabilities || {}).with_indifferent_access
41
+ capabilities.key?(:completions)
42
+ end
43
+
44
+ def completion_definition(reference, argument_name)
45
+ case reference[:type]
46
+ when "ref/prompt"
47
+ prompt = session.registered_prompts.find { |candidate| candidate.prompt_name == reference[:name] }
48
+ prompt&.arguments&.find do |argument|
49
+ (argument[:name] || argument["name"]).to_s == argument_name
50
+ end
51
+ when "ref/resource"
52
+ template = session.registered_resource_templates.find do |candidate|
53
+ candidate.uri_template == reference[:uri]
54
+ end
55
+ template&.parameters&.find { |name, _definition| name.to_s == argument_name }&.last
56
+ end
57
+ end
58
+
59
+ def matching_completion_values(definition, current_value)
60
+ enum = definition[:enum] || definition["enum"]
61
+ return [] unless enum.is_a?(Array)
62
+
63
+ prefix = current_value.downcase
64
+ enum.map(&:to_s).select { |value| value.downcase.start_with?(prefix) }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -17,13 +17,15 @@ module ActionMCP
17
17
  # @param message [String] Human-readable message explaining why input is needed
18
18
  # @param requested_schema [Hash] JSON Schema for the expected response (primitive types only)
19
19
  # @param _meta [Hash] Optional metadata (e.g. related task)
20
- def send_elicitation_create(message:, requested_schema:, _meta: {})
20
+ def send_elicitation_create(message:, requested_schema:, _meta: {}, task: nil)
21
21
  require_client_elicitation_support!(:form)
22
+ require_client_elicitation_task_support! if task
22
23
 
23
24
  request = ElicitationRequest.new(
24
25
  message: message,
25
26
  requested_schema: requested_schema,
26
- _meta: _meta
27
+ _meta: _meta,
28
+ task: task
27
29
  )
28
30
  request.assert_valid!
29
31
 
@@ -37,14 +39,16 @@ module ActionMCP
37
39
  # @param url [String] The URL the user should navigate to
38
40
  # @param elicitation_id [String] Unique identifier for this elicitation
39
41
  # @param _meta [Hash] Optional metadata (e.g. related task)
40
- def send_elicitation_create_url(message:, url:, elicitation_id: nil, _meta: {})
42
+ def send_elicitation_create_url(message:, url:, elicitation_id: nil, _meta: {}, task: nil)
41
43
  require_client_elicitation_support!(:url)
44
+ require_client_elicitation_task_support! if task
42
45
 
43
46
  request = UrlElicitationRequest.new(
44
47
  message: message,
45
48
  url: url,
46
49
  elicitation_id: elicitation_id,
47
- _meta: _meta
50
+ _meta: _meta,
51
+ task: task
48
52
  )
49
53
  request.assert_valid!
50
54
 
@@ -56,6 +60,10 @@ module ActionMCP
56
60
  # @param elicitation_id [String] The elicitation ID from the original request
57
61
  def send_elicitation_complete_notification(elicitation_id)
58
62
  require_client_elicitation_support!(:url)
63
+ unless elicitation_id.is_a?(String) && elicitation_id.present?
64
+ raise ArgumentError, "elicitation_id must be a non-empty string"
65
+ end
66
+
59
67
  send_jsonrpc_notification(
60
68
  "notifications/elicitation/complete",
61
69
  { elicitationId: elicitation_id }
@@ -69,22 +77,35 @@ module ActionMCP
69
77
  # @param elicitations [Array<Hash>] Required URL mode elicitations
70
78
  def send_url_elicitation_required_error(request_id, message:, elicitations:)
71
79
  require_client_elicitation_support!(:url)
80
+ raise ArgumentError, "message must be a string" unless message.is_a?(String)
81
+ raise ArgumentError, "elicitations must be an array" unless elicitations.is_a?(Array)
72
82
 
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?
83
+ normalized_elicitations = elicitations.map do |elicitation|
84
+ unless elicitation.respond_to?(:to_h) && elicitation.to_h.is_a?(Hash)
85
+ raise ArgumentError, "Each elicitation must be an object"
86
+ end
76
87
 
77
- UrlElicitationRequest.new(
78
- message: e[:message],
79
- url: e[:url],
80
- elicitation_id: e[:elicitationId]
81
- ).assert_valid!
88
+ attributes = elicitation.to_h.with_indifferent_access
89
+ raise ArgumentError, "Each elicitation must have mode: 'url'" unless attributes[:mode] == "url"
90
+ raise ArgumentError, "Each elicitation must have an elicitationId" unless attributes[:elicitationId].present?
91
+
92
+ request = UrlElicitationRequest.new(
93
+ message: attributes[:message],
94
+ url: attributes[:url],
95
+ elicitation_id: attributes[:elicitationId],
96
+ _meta: attributes[:_meta],
97
+ task: attributes[:task]
98
+ )
99
+ request.assert_valid!
100
+
101
+ extensions = attributes.except(:mode, :message, :url, :elicitationId, :_meta, :task)
102
+ request.to_params.merge(extensions)
82
103
  end
83
104
 
84
105
  error = {
85
106
  code: URL_ELICITATION_REQUIRED_CODE,
86
107
  message: message,
87
- data: { elicitations: elicitations }
108
+ data: { elicitations: normalized_elicitations }
88
109
  }
89
110
 
90
111
  send_jsonrpc_response(request_id, error: error)
@@ -101,23 +122,24 @@ module ActionMCP
101
122
  raise UnsupportedElicitationError, "Client does not support elicitation" unless elicitation_caps.is_a?(Hash)
102
123
 
103
124
  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
125
  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
126
+ raise UnsupportedElicitationError, "Client does not support form mode elicitation" unless form_cap.is_a?(Hash)
110
127
  return
111
128
  end
112
129
 
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"
116
- end
117
-
118
- # Client must explicitly declare url mode support (empty hash = form-only for 2025-06-18 clients)
119
130
  url_cap = elicitation_caps["url"] || elicitation_caps[:url]
120
- raise UnsupportedElicitationError, "Client does not support URL mode elicitation" unless url_cap
131
+ raise UnsupportedElicitationError, "Client does not support URL mode elicitation" unless url_cap.is_a?(Hash)
132
+ end
133
+
134
+ def require_client_elicitation_task_support!
135
+ client_capabilities = session.client_capabilities || {}
136
+ tasks = client_capabilities["tasks"] || client_capabilities[:tasks]
137
+ requests = tasks.is_a?(Hash) && (tasks["requests"] || tasks[:requests])
138
+ elicitation = requests.is_a?(Hash) && (requests["elicitation"] || requests[:elicitation])
139
+ create = elicitation.is_a?(Hash) && (elicitation["create"] || elicitation[:create])
140
+ return if create.is_a?(Hash)
141
+
142
+ raise UnsupportedElicitationError, "Client does not support task-augmented elicitation"
121
143
  end
122
144
  end
123
145