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
@@ -1,114 +1,376 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json_schemer"
4
+
3
5
  module ActionMCP
4
6
  module Server
5
7
  class SamplingRequest
6
- class << self
7
- attr_reader :default_messages, :default_system_prompt, :default_context,
8
- :default_model_hints, :default_intelligence_priority,
9
- :default_max_tokens, :default_temperature
8
+ UNSET = Object.new.freeze
9
+
10
+ TOOL_SCHEMA_OBJECT = {
11
+ "type" => "object",
12
+ "required" => [ "type" ],
13
+ "properties" => {
14
+ "$schema" => { "type" => "string" },
15
+ "properties" => {
16
+ "type" => "object",
17
+ "additionalProperties" => {
18
+ "type" => "object",
19
+ "properties" => {},
20
+ "additionalProperties" => true
21
+ }
22
+ },
23
+ "required" => { "type" => "array", "items" => { "type" => "string" } },
24
+ "type" => { "type" => "string", "const" => "object" }
25
+ }
26
+ }.freeze
27
+
28
+ SAMPLING_DEFINITIONS = Content::Validation::DEFINITIONS.merge(
29
+ "ProgressToken" => { "type" => %w[string integer] },
30
+ "Role" => { "type" => "string", "enum" => %w[assistant user] },
31
+ "ToolSchema" => TOOL_SCHEMA_OBJECT,
32
+ "ModelHint" => {
33
+ "type" => "object",
34
+ "properties" => { "name" => { "type" => "string" } }
35
+ },
36
+ "ModelPreferences" => {
37
+ "type" => "object",
38
+ "properties" => {
39
+ "hints" => {
40
+ "type" => "array",
41
+ "items" => { "$ref" => "#/$defs/ModelHint" }
42
+ },
43
+ "costPriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 },
44
+ "speedPriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 },
45
+ "intelligencePriority" => { "type" => "number", "minimum" => 0, "maximum" => 1 }
46
+ }
47
+ },
48
+ "ToolAnnotations" => {
49
+ "type" => "object",
50
+ "properties" => {
51
+ "destructiveHint" => { "type" => "boolean" },
52
+ "idempotentHint" => { "type" => "boolean" },
53
+ "openWorldHint" => { "type" => "boolean" },
54
+ "readOnlyHint" => { "type" => "boolean" },
55
+ "title" => { "type" => "string" }
56
+ }
57
+ },
58
+ "ToolExecution" => {
59
+ "type" => "object",
60
+ "properties" => {
61
+ "taskSupport" => {
62
+ "type" => "string",
63
+ "enum" => %w[forbidden optional required]
64
+ }
65
+ }
66
+ },
67
+ "Tool" => {
68
+ "type" => "object",
69
+ "required" => %w[inputSchema name],
70
+ "properties" => Content::Validation::META_PROPERTY.merge(
71
+ "annotations" => { "$ref" => "#/$defs/ToolAnnotations" },
72
+ "description" => { "type" => "string" },
73
+ "execution" => { "$ref" => "#/$defs/ToolExecution" },
74
+ "icons" => { "type" => "array", "items" => { "$ref" => "#/$defs/Icon" } },
75
+ "inputSchema" => { "$ref" => "#/$defs/ToolSchema" },
76
+ "name" => { "type" => "string" },
77
+ "outputSchema" => { "$ref" => "#/$defs/ToolSchema" },
78
+ "title" => { "type" => "string" }
79
+ )
80
+ },
81
+ "ToolChoice" => {
82
+ "type" => "object",
83
+ "properties" => {
84
+ "mode" => { "type" => "string", "enum" => %w[auto none required] }
85
+ }
86
+ },
87
+ "TaskMetadata" => {
88
+ "type" => "object",
89
+ "properties" => { "ttl" => { "type" => "integer" } }
90
+ },
91
+ "ToolUseContent" => {
92
+ "type" => "object",
93
+ "required" => %w[id input name type],
94
+ "properties" => Content::Validation::META_PROPERTY.merge(
95
+ "id" => { "type" => "string" },
96
+ "input" => { "type" => "object" },
97
+ "name" => { "type" => "string" },
98
+ "type" => { "type" => "string", "const" => "tool_use" }
99
+ )
100
+ },
101
+ "ToolResultContent" => {
102
+ "type" => "object",
103
+ "required" => %w[content toolUseId type],
104
+ "properties" => Content::Validation::META_PROPERTY.merge(
105
+ "content" => {
106
+ "type" => "array",
107
+ "items" => { "$ref" => "#/$defs/ContentBlock" }
108
+ },
109
+ "isError" => { "type" => "boolean" },
110
+ "structuredContent" => { "type" => "object" },
111
+ "toolUseId" => { "type" => "string" },
112
+ "type" => { "type" => "string", "const" => "tool_result" }
113
+ )
114
+ },
115
+ "SamplingMessageContentBlock" => {
116
+ "anyOf" => %w[TextContent ImageContent AudioContent ToolUseContent ToolResultContent].map do |name|
117
+ { "$ref" => "#/$defs/#{name}" }
118
+ end
119
+ },
120
+ "SamplingMessage" => {
121
+ "type" => "object",
122
+ "required" => %w[content role],
123
+ "properties" => Content::Validation::META_PROPERTY.merge(
124
+ "content" => {
125
+ "anyOf" => [
126
+ { "$ref" => "#/$defs/SamplingMessageContentBlock" },
127
+ {
128
+ "type" => "array",
129
+ "items" => { "$ref" => "#/$defs/SamplingMessageContentBlock" }
130
+ }
131
+ ]
132
+ },
133
+ "role" => { "$ref" => "#/$defs/Role" }
134
+ )
135
+ },
136
+ "CreateMessageRequestParams" => {
137
+ "type" => "object",
138
+ "required" => %w[maxTokens messages],
139
+ "properties" => {
140
+ "_meta" => {
141
+ "type" => "object",
142
+ "properties" => {
143
+ "progressToken" => { "$ref" => "#/$defs/ProgressToken" }
144
+ }
145
+ },
146
+ "includeContext" => {
147
+ "type" => "string",
148
+ "enum" => %w[allServers none thisServer]
149
+ },
150
+ "maxTokens" => { "type" => "integer" },
151
+ "messages" => {
152
+ "type" => "array",
153
+ "items" => { "$ref" => "#/$defs/SamplingMessage" }
154
+ },
155
+ "metadata" => { "type" => "object" },
156
+ "modelPreferences" => { "$ref" => "#/$defs/ModelPreferences" },
157
+ "stopSequences" => { "type" => "array", "items" => { "type" => "string" } },
158
+ "systemPrompt" => { "type" => "string" },
159
+ "task" => { "$ref" => "#/$defs/TaskMetadata" },
160
+ "temperature" => { "type" => "number" },
161
+ "toolChoice" => { "$ref" => "#/$defs/ToolChoice" },
162
+ "tools" => { "type" => "array", "items" => { "$ref" => "#/$defs/Tool" } }
163
+ }
164
+ }
165
+ ).freeze
166
+
167
+ PARAMS_SCHEMA = {
168
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
169
+ "$ref" => "#/$defs/CreateMessageRequestParams",
170
+ "$defs" => SAMPLING_DEFINITIONS
171
+ }.freeze
172
+ PARAMS_SCHEMER = JSONSchemer.schema(
173
+ PARAMS_SCHEMA,
174
+ formats: Content::Validation::FORMAT_VALIDATORS
175
+ )
10
176
 
177
+ class << self
11
178
  def configure
12
179
  yield self
13
180
  end
14
181
 
15
- def messages(messages = nil)
16
- if messages
17
- @default_messages = messages.map do |msg|
18
- mutate_content(msg)
19
- end
20
- end
21
- @messages ||= []
182
+ def messages(value = UNSET)
183
+ @default_messages = value.map { |message| normalize_message(message) } unless value.equal?(UNSET)
184
+ @default_messages ||= []
22
185
  end
23
186
 
24
- def system_prompt(prompt = nil)
25
- @default_system_prompt = prompt if prompt
187
+ def system_prompt(value = UNSET)
188
+ @default_system_prompt = value unless value.equal?(UNSET)
26
189
  @default_system_prompt
27
190
  end
28
191
 
29
- def include_context(context = nil)
30
- @default_context = context if context
192
+ def include_context(value = UNSET)
193
+ @default_context = value unless value.equal?(UNSET)
31
194
  @default_context
32
195
  end
33
196
 
34
- def model_hints(hints = nil)
35
- @default_model_hints = hints if hints
36
- @model_hints ||= []
197
+ def model_hints(value = UNSET)
198
+ @default_model_hints = value unless value.equal?(UNSET)
199
+ @default_model_hints ||= []
37
200
  end
38
201
 
39
- def intelligence_priority(priority = nil)
40
- @default_intelligence_priority = priority if priority
41
- @intelligence_priority ||= 0.9
202
+ def intelligence_priority(value = UNSET)
203
+ @default_intelligence_priority = value unless value.equal?(UNSET)
204
+ @default_intelligence_priority.nil? ? 0.9 : @default_intelligence_priority
42
205
  end
43
206
 
44
- def max_tokens(tokens = nil)
45
- @default_max_tokens = tokens if tokens
46
- @max_tokens ||= 500
207
+ def max_tokens(value = UNSET)
208
+ @default_max_tokens = value unless value.equal?(UNSET)
209
+ @default_max_tokens.nil? ? 500 : @default_max_tokens
47
210
  end
48
211
 
49
- def temperature(temp = nil)
50
- @default_temperature = temp if temp
51
- @temperature ||= 0.7
212
+ def temperature(value = UNSET)
213
+ @default_temperature = value unless value.equal?(UNSET)
214
+ @default_temperature.nil? ? 0.7 : @default_temperature
215
+ end
216
+
217
+ def validate_params!(params)
218
+ raise ArgumentError, "Sampling request params must be an object" unless params.is_a?(Hash)
219
+
220
+ wire_params = params.deep_stringify_keys
221
+ errors = PARAMS_SCHEMER.validate(wire_params).to_a
222
+ unless errors.empty?
223
+ pointers = errors.map { |error| error["data_pointer"].presence || "/" }.uniq
224
+ raise ArgumentError, "Sampling request must match MCP 2025-11-25 at #{pointers.join(', ')}"
225
+ end
226
+
227
+ validate_tool_conversation!(wire_params.fetch("messages"))
228
+ params
52
229
  end
53
230
 
54
231
  private
55
232
 
56
- def mutate_content(msg)
57
- content = msg[:content]
58
- if content.is_a?(ActionMCP::Content) || (content.respond_to?(:to_h) && !content.is_a?(Hash))
59
- { role: msg[:role], content: content.to_h }
60
- else
61
- msg
233
+ def validate_tool_conversation!(messages)
234
+ expected_ids = nil
235
+ seen_tool_use_ids = {}
236
+
237
+ messages.each_with_index do |message, index|
238
+ blocks = message["content"].is_a?(Array) ? message["content"] : [ message["content"] ]
239
+ tool_results = blocks.select { |block| block["type"] == "tool_result" }
240
+
241
+ if expected_ids
242
+ validate_tool_results!(message, blocks, tool_results, expected_ids, index)
243
+ expected_ids = nil
244
+ next
245
+ end
246
+
247
+ if tool_results.any?
248
+ validate_tool_result_message!(message, blocks, index)
249
+ invalid_tool_conversation!(index, "tool results do not match a preceding tool use")
250
+ end
251
+
252
+ tool_uses = blocks.select { |block| block["type"] == "tool_use" }
253
+ next if tool_uses.empty?
254
+
255
+ unless message["role"] == "assistant"
256
+ invalid_tool_conversation!(index, "tool uses must be in an assistant message")
257
+ end
258
+
259
+ ids = tool_uses.map { |tool_use| tool_use.fetch("id") }
260
+ reject_duplicate_ids!(ids, index, "tool use")
261
+
262
+ if (duplicate = ids.find { |id| seen_tool_use_ids.key?(id) })
263
+ invalid_tool_conversation!(index, "tool use ID #{duplicate.inspect} is not unique")
264
+ end
265
+
266
+ ids.each { |id| seen_tool_use_ids[id] = true }
267
+ expected_ids = ids
268
+ end
269
+
270
+ return unless expected_ids
271
+
272
+ invalid_tool_conversation!(messages.length, "assistant tool uses are missing their next user results")
273
+ end
274
+
275
+ def validate_tool_results!(message, blocks, tool_results, expected_ids, index)
276
+ validate_tool_result_message!(message, blocks, index)
277
+
278
+ result_ids = tool_results.map { |tool_result| tool_result.fetch("toolUseId") }
279
+ reject_duplicate_ids!(result_ids, index, "tool result")
280
+ return if result_ids.sort == expected_ids.sort
281
+
282
+ invalid_tool_conversation!(index, "tool result IDs must exactly match the preceding tool use IDs")
283
+ end
284
+
285
+ def validate_tool_result_message!(message, blocks, index)
286
+ valid = message["role"] == "user" && blocks.any? &&
287
+ blocks.all? { |block| block["type"] == "tool_result" }
288
+ return if valid
289
+
290
+ invalid_tool_conversation!(index, "tool result messages must be user messages containing only results")
291
+ end
292
+
293
+ def reject_duplicate_ids!(ids, index, label)
294
+ return if ids.uniq.length == ids.length
295
+
296
+ invalid_tool_conversation!(index, "#{label} IDs must not contain duplicates")
297
+ end
298
+
299
+ def invalid_tool_conversation!(index, reason)
300
+ raise ArgumentError,
301
+ "Sampling request must match MCP 2025-11-25 at /messages/#{index}: #{reason}"
302
+ end
303
+
304
+ def normalize_message(message)
305
+ raise ArgumentError, "Sampling messages must be objects" unless message.is_a?(Hash)
306
+
307
+ normalized = message.symbolize_keys
308
+ content = normalized[:content]
309
+ if content.respond_to?(:to_h) && !content.is_a?(Hash) && !content.is_a?(Array)
310
+ normalized[:content] = content.to_h
62
311
  end
312
+ normalized
63
313
  end
64
314
  end
65
315
 
66
- attr_accessor :system_prompt, :model_hints, :intelligence_priority, :max_tokens, :temperature
316
+ attr_accessor :system_prompt, :model_hints, :cost_priority, :speed_priority,
317
+ :intelligence_priority, :max_tokens, :temperature, :stop_sequences,
318
+ :metadata, :tools, :tool_choice, :task, :request_meta
67
319
  attr_reader :messages, :context
68
320
 
69
321
  def initialize
70
- @messages = self.class.default_messages.dup
71
- @system_prompt = self.class.default_system_prompt
72
- @context = self.class.default_context
73
- @model_hints = self.class.default_model_hints.dup
74
- @intelligence_priority = self.class.default_intelligence_priority
75
- @max_tokens = self.class.default_max_tokens
76
- @temperature = self.class.default_temperature
322
+ @messages = self.class.messages.map(&:deep_dup)
323
+ @system_prompt = self.class.system_prompt
324
+ @context = self.class.include_context
325
+ @model_hints = self.class.model_hints.deep_dup
326
+ @intelligence_priority = self.class.intelligence_priority
327
+ @max_tokens = self.class.max_tokens
328
+ @temperature = self.class.temperature
77
329
 
78
330
  yield self if block_given?
79
331
  end
80
332
 
81
333
  def messages=(value)
82
- @messages = value.map do |msg|
83
- self.class.send(:mutate_content, msg)
84
- end
334
+ @messages = value.map { |message| self.class.send(:normalize_message, message) }
85
335
  end
86
336
 
87
337
  def include_context=(value)
88
338
  @context = value
89
339
  end
90
340
 
91
- def add_message(content, role: "user")
92
- if content.is_a?(Content::Base) || (content.respond_to?(:to_h) && !content.is_a?(Hash))
93
- @messages << { role: role, content: content.to_h }
94
- else
95
- content = Content::Text.new(content, annotations: nil).to_h if content.is_a?(String)
96
- @messages << { role: role, content: content }
341
+ def add_message(content, role: "user", _meta: nil)
342
+ if content.respond_to?(:to_h) && !content.is_a?(Hash) && !content.is_a?(Array)
343
+ content = content.to_h
97
344
  end
345
+ content = Content::Text.new(content, annotations: nil).to_h if content.is_a?(String)
346
+
347
+ message = { role: role, content: content }
348
+ message[:_meta] = _meta if _meta
349
+ @messages << message
98
350
  end
99
351
 
100
352
  def to_h
101
- {
102
- messages: messages.map { |msg| { role: msg[:role], content: msg[:content] } },
353
+ params = {
354
+ messages: messages.map { |message| message.slice(:role, :content, :_meta).compact },
103
355
  systemPrompt: system_prompt,
104
356
  includeContext: context,
105
357
  modelPreferences: {
106
- hints: model_hints.map { |name| { name: name } },
358
+ hints: model_hints.map { |hint| hint.is_a?(Hash) ? hint : { name: hint } },
359
+ costPriority: cost_priority,
360
+ speedPriority: speed_priority,
107
361
  intelligencePriority: intelligence_priority
108
- },
362
+ }.compact,
109
363
  maxTokens: max_tokens,
110
- temperature: temperature
364
+ temperature: temperature,
365
+ stopSequences: stop_sequences,
366
+ metadata: metadata,
367
+ tools: tools,
368
+ toolChoice: tool_choice,
369
+ task: task,
370
+ _meta: request_meta
111
371
  }.compact
372
+
373
+ self.class.validate_params!(params)
112
374
  end
113
375
  end
114
376
  end
@@ -5,7 +5,7 @@ module ActionMCP
5
5
  # Tasks module for MCP 2025-11-25 specification
6
6
  # Provides methods for handling task-related requests:
7
7
  # - tasks/get: Get task status and data
8
- # - tasks/result: Get task result (blocking until terminal or input_required state)
8
+ # - tasks/result: Get task result (blocking until a terminal state)
9
9
  # - tasks/list: List tasks for the session
10
10
  # - tasks/cancel: Cancel a task
11
11
  module Tasks
@@ -19,35 +19,17 @@ module ActionMCP
19
19
  send_jsonrpc_response(request_id, result: task.to_task_data)
20
20
  end
21
21
 
22
- # Get task result, blocking until task reaches terminal or input_required state
22
+ # Get task result, blocking until the task reaches a terminal state.
23
23
  # @param request_id [String, Integer] JSON-RPC request ID
24
24
  # @param task_id [String] Task ID to get result for
25
25
  def send_tasks_result(request_id, task_id)
26
26
  task = find_task(task_id)
27
27
  return unless task
28
28
 
29
- unless task.result_ready?
30
- case ActionMCP.configuration.tasks_result_strategy
31
- when :polling_only
32
- send_jsonrpc_error(
33
- request_id,
34
- :invalid_request,
35
- "Task is not ready. Poll tasks/get over HTTP until the task reaches a terminal or input_required status, then retry tasks/result."
36
- )
37
- return
38
- else
39
- task = wait_for_result_ready_task(task_id)
40
- unless task&.result_ready?
41
- send_jsonrpc_response(
42
- request_id,
43
- error: {
44
- code: -32_000,
45
- message: "Timed out waiting for task '#{task_id}' to reach a terminal or input_required status"
46
- }
47
- )
48
- return
49
- end
50
- end
29
+ task = wait_for_terminal_task(task_id) unless task.terminal?
30
+ unless task
31
+ send_jsonrpc_error(request_id, :invalid_params, "Task '#{task_id}' not found")
32
+ return
51
33
  end
52
34
 
53
35
  if (error = task.to_task_error)
@@ -83,19 +65,28 @@ module ActionMCP
83
65
  task = find_task(task_id)
84
66
  return unless task
85
67
 
86
- if task.terminal?
68
+ terminal_status = nil
69
+ task.with_lock do
70
+ if task.terminal?
71
+ terminal_status = task.status
72
+ next
73
+ end
74
+
75
+ task.status_message = "The task was cancelled by request." if task.status_message.blank?
76
+ task.result_payload ||= {
77
+ code: -32_000,
78
+ message: "Task was cancelled"
79
+ }
80
+ task.save! if task.changed?
81
+ task.cancel!
82
+ end
83
+
84
+ if terminal_status
87
85
  send_jsonrpc_error(request_id, :invalid_params,
88
- "Cannot cancel task in terminal status: #{task.status}")
86
+ "Cannot cancel task in terminal status: #{terminal_status}")
89
87
  return
90
88
  end
91
89
 
92
- task.status_message = "The task was cancelled by request." if task.status_message.blank?
93
- task.result_payload ||= {
94
- code: -32_000,
95
- message: "Task was cancelled"
96
- }
97
- task.save! if task.changed?
98
- task.cancel!
99
90
  send_jsonrpc_response(request_id, result: task.to_task_data)
100
91
  end
101
92
 
@@ -144,17 +135,12 @@ module ActionMCP
144
135
  task
145
136
  end
146
137
 
147
- def wait_for_result_ready_task(task_id)
148
- deadline = monotonic_time + ActionMCP.configuration.tasks_result_timeout.to_f
149
-
138
+ def wait_for_terminal_task(task_id)
150
139
  loop do
151
140
  task = load_task_for_result(task_id)
152
- return task if task.nil? || task.result_ready?
153
-
154
- remaining = deadline - monotonic_time
155
- return task unless remaining.positive?
141
+ return task if task.nil? || task.terminal?
156
142
 
157
- sleep [ ActionMCP.configuration.tasks_result_poll_interval.to_f, remaining ].min
143
+ sleep ActionMCP.configuration.tasks_result_poll_interval.to_f
158
144
  end
159
145
  end
160
146
 
@@ -163,10 +149,6 @@ module ActionMCP
163
149
  session.tasks.find_by(id: task_id)
164
150
  end
165
151
  end
166
-
167
- def monotonic_time
168
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
169
- end
170
152
  end
171
153
  end
172
154
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ module ToolResult
6
+ module_function
7
+
8
+ def normalize(response)
9
+ payload = response.to_h
10
+ code = payload[:code] || payload["code"]
11
+ return response unless response.error? && code == -32_602
12
+
13
+ execution_error(
14
+ payload[:message] || payload["message"],
15
+ data: payload[:data] || payload["data"]
16
+ )
17
+ end
18
+
19
+ def execution_error(message, data: nil)
20
+ details = data.is_a?(Array) ? data.join(", ") : nil
21
+ text = [ message, details ].compact_blank.join(": ")
22
+
23
+ ToolResponse.new.tap { |response| response.report_tool_error(text) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -31,7 +31,7 @@ module ActionMCP
31
31
 
32
32
  unless tool_class
33
33
  Rails.logger.error "Tool not found: #{tool_name}. Registered tools: #{session.registered_tools.map(&:tool_name).join(', ')}"
34
- send_jsonrpc_error(request_id, :method_not_found,
34
+ send_jsonrpc_error(request_id, :invalid_params,
35
35
  "Tool '#{tool_name}' not found or not registered for this session")
36
36
  return
37
37
  end
@@ -51,8 +51,7 @@ module ActionMCP
51
51
  task_requested = !task_params.nil?
52
52
 
53
53
  if task_requested && !tasks_enabled?
54
- send_jsonrpc_error(request_id, :method_not_found,
55
- "Task-augmented execution is not available for this session")
54
+ execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
56
55
  return
57
56
  end
58
57
 
@@ -74,7 +73,7 @@ module ActionMCP
74
73
  return
75
74
  end
76
75
 
77
- if !task_requested && task_support == :required
76
+ if !task_requested && tasks_enabled? && task_support == :required
78
77
  send_jsonrpc_error(request_id, :method_not_found,
79
78
  "Tool '#{tool_name}' requires task-augmented execution")
80
79
  return
@@ -89,7 +88,7 @@ module ActionMCP
89
88
  def execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
90
89
  begin
91
90
  # Create tool and set execution context with request info
92
- tool = tool_class.new(arguments)
91
+ tool = tool_class.from_wire(arguments)
93
92
  tool.with_context({
94
93
  session: session,
95
94
  request: {
@@ -117,6 +116,8 @@ module ActionMCP
117
116
  tool.call
118
117
  end
119
118
 
119
+ result = ToolResult.normalize(result)
120
+
120
121
  if result.is_error
121
122
  # Protocol error
122
123
  send_jsonrpc_response(request_id, error: result.to_h)
@@ -125,11 +126,9 @@ module ActionMCP
125
126
  send_jsonrpc_response(request_id, result: result.to_h)
126
127
  end
127
128
  rescue ArgumentError => e
128
- # Handle parameter validation errors (e.g. alias conflicts)
129
- send_jsonrpc_error(request_id, :invalid_params, e.message)
129
+ send_jsonrpc_response(request_id, result: ToolResult.execution_error(e.message).to_h)
130
130
  rescue ActiveModel::UnknownAttributeError => e
131
- # Undeclared param on a strict tool — message already names the key + tool
132
- send_jsonrpc_error(request_id, :invalid_params, e.message)
131
+ send_jsonrpc_response(request_id, result: ToolResult.execution_error(e.message).to_h)
133
132
  rescue StandardError => e
134
133
  # Log the actual error for debugging
135
134
  Rails.logger.error "Tool execution error: #{e.class} - #{e.message}"
@@ -177,7 +176,10 @@ module ActionMCP
177
176
  end
178
177
 
179
178
  def tasks_enabled?
180
- ActionMCP.configuration.tasks_enabled && session.protocol_version == "2025-11-25"
179
+ return false unless session.protocol_version == "2025-11-25"
180
+
181
+ capabilities = (session.server_capabilities || {}).with_indifferent_access
182
+ capabilities.dig(:tasks, :requests, :tools, :call).is_a?(Hash)
181
183
  end
182
184
 
183
185
  def tool_task_support(tool_class)
@@ -3,6 +3,7 @@
3
3
  require_relative "pagination"
4
4
  require_relative "response_collector"
5
5
  require_relative "base_messaging"
6
+ require_relative "client_notifications"
6
7
 
7
8
  module ActionMCP
8
9
  module Server
@@ -14,10 +15,12 @@ module ActionMCP
14
15
 
15
16
  include Pagination
16
17
  include MessagingService
18
+ include ClientNotifications
17
19
  include Capabilities
18
20
  include Tools
19
21
  include Prompts
20
22
  include Resources
23
+ include Completions
21
24
  include Sampling
22
25
  include Roots
23
26
  include Elicitation