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.
- checksums.yaml +4 -4
- data/README.md +26 -18
- data/Rakefile +3 -0
- data/app/controllers/action_mcp/application_controller.rb +166 -109
- data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
- data/app/helpers/action_mcp/apps_helper.rb +48 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
- data/app/models/action_mcp/session/message.rb +8 -3
- data/app/models/action_mcp/session/task.rb +15 -5
- data/app/models/action_mcp/session.rb +93 -19
- data/config/routes.rb +1 -1
- data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
- data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
- data/lib/action_mcp/apps/view_manifest.rb +170 -0
- data/lib/action_mcp/apps.rb +17 -2
- data/lib/action_mcp/configuration.rb +14 -30
- data/lib/action_mcp/content/audio.rb +4 -1
- data/lib/action_mcp/content/base.rb +3 -1
- data/lib/action_mcp/content/image.rb +4 -1
- data/lib/action_mcp/content/resource.rb +15 -14
- data/lib/action_mcp/content/resource_link.rb +15 -5
- data/lib/action_mcp/content/text.rb +2 -1
- data/lib/action_mcp/content/validation.rb +253 -0
- data/lib/action_mcp/content.rb +3 -1
- data/lib/action_mcp/dev/runner.rb +203 -0
- data/lib/action_mcp/engine.rb +24 -2
- data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
- data/lib/action_mcp/logging/logger.rb +16 -11
- data/lib/action_mcp/logging.rb +34 -1
- data/lib/action_mcp/middleware/origin_validation.rb +9 -13
- data/lib/action_mcp/output_schema_builder.rb +2 -0
- data/lib/action_mcp/prompt_response.rb +12 -2
- data/lib/action_mcp/protocol_validator.rb +513 -0
- data/lib/action_mcp/renderable.rb +4 -4
- data/lib/action_mcp/resource.rb +15 -11
- data/lib/action_mcp/resource_template.rb +105 -90
- data/lib/action_mcp/resource_templates_registry.rb +31 -97
- data/lib/action_mcp/schema_validator.rb +43 -0
- data/lib/action_mcp/server/base_session.rb +134 -28
- data/lib/action_mcp/server/base_session_store.rb +1 -1
- data/lib/action_mcp/server/capabilities.rb +10 -30
- data/lib/action_mcp/server/client_notifications.rb +63 -0
- data/lib/action_mcp/server/completions.rb +68 -0
- data/lib/action_mcp/server/elicitation.rb +47 -25
- data/lib/action_mcp/server/elicitation_request.rb +136 -51
- data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
- data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
- data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
- data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
- data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
- data/lib/action_mcp/server/messaging_service.rb +3 -1
- data/lib/action_mcp/server/prompts.rb +5 -3
- data/lib/action_mcp/server/resources.rb +35 -2
- data/lib/action_mcp/server/response_collector.rb +5 -1
- data/lib/action_mcp/server/roots.rb +15 -2
- data/lib/action_mcp/server/sampling.rb +36 -1
- data/lib/action_mcp/server/sampling_request.rb +317 -55
- data/lib/action_mcp/server/tasks.rb +27 -45
- data/lib/action_mcp/server/tool_result.rb +27 -0
- data/lib/action_mcp/server/tools.rb +12 -10
- data/lib/action_mcp/server/transport_handler.rb +3 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
- data/lib/action_mcp/tool.rb +133 -199
- data/lib/action_mcp/tool_response.rb +35 -4
- data/lib/action_mcp/tools_registry.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -5
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
- data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
- data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
- data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
- data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
- data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
- data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
- data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
- data/lib/generators/action_mcp/view/view_generator.rb +57 -0
- data/lib/tasks/action_mcp_apps.rake +17 -0
- data/lib/tasks/action_mcp_tasks.rake +135 -74
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
35
|
+
session.set_protocol_version(negotiated_version)
|
|
56
36
|
|
|
57
|
-
unless session.
|
|
58
|
-
return send_jsonrpc_error(request_id, :
|
|
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] =
|
|
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.
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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
|
|
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
|
|