Skip to content

Commit 8d96ccc

Browse files
authored
fix: align tasks/* with MCP 2025-11-25 spec (#219)
1 parent 27a2298 commit 8d96ccc

17 files changed

Lines changed: 617 additions & 116 deletions

File tree

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ class BatchIndexTool < ApplicationMCPTool
315315
end
316316
```
317317

318-
Call it as a task from a client by adding `_meta.task` (creates a Task record and runs the tool via `ToolExecutionJob`):
318+
Call it as a task from a client by adding top-level `task` params (creates a Task record and runs the tool via `ToolExecutionJob`):
319319

320320
```json
321321
{
@@ -325,12 +325,27 @@ Call it as a task from a client by adding `_meta.task` (creates a Task record an
325325
"params": {
326326
"name": "batch_index",
327327
"arguments": { "items": ["a", "b", "c"] },
328-
"_meta": { "task": { "ttl": 120000, "pollInterval": 2000 } }
328+
"task": { "ttl": 120000 }
329329
}
330330
}
331331
```
332332

333-
Poll task status with `tasks/get` or fetch the result when finished with `tasks/result`. Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available — treat cursors as opaque tokens.
333+
Poll task status with `tasks/get` or fetch the result with `tasks/result`.
334+
By default, `tasks/result` uses spec-aligned blocking HTTP: if the task is still working, the request waits until the task reaches `completed`, `failed`, `cancelled`, or `input_required`, then returns one JSON response. Configure the wait bounds for your Rails app:
335+
336+
```ruby
337+
config.action_mcp.tasks_result_strategy = :blocking_http
338+
config.action_mcp.tasks_result_timeout = 30.seconds
339+
config.action_mcp.tasks_result_poll_interval = 0.25.seconds
340+
```
341+
342+
Rails apps that cannot hold request workers open can opt into `:polling_only`, where clients must poll `tasks/get` until terminal or `input_required` before calling `tasks/result`. This is a deliberate MCP spec deviation:
343+
344+
```ruby
345+
config.action_mcp.tasks_result_strategy = :polling_only
346+
```
347+
348+
Use `tasks/cancel` to stop non-terminal tasks. `tasks/list` returns tasks in recent-first order and always paginates (default 50 per page, or `pagination_page_size` if configured). The response includes an opaque `nextCursor` when more results are available; treat cursors as opaque tokens.
334349

335350
### ActionMCP::ResourceTemplate
336351

app/jobs/action_mcp/tool_execution_job.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def validate_session(task)
5353
session = task.session
5454
unless session
5555
task.update(status_message: "Session not found")
56+
task.result_payload = { code: -32_603, message: "Session not found" }
5657
task.mark_failed!
5758
return nil
5859
end
@@ -64,6 +65,10 @@ def prepare_tool(session, tool_name, arguments, task)
6465
tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
6566
unless tool_class
6667
@task.update(status_message: "Tool '#{tool_name}' not found")
68+
@task.result_payload = {
69+
code: -32_601,
70+
message: "Tool '#{tool_name}' not found"
71+
}
6772
@task.mark_failed!
6873
return nil
6974
end
@@ -101,12 +106,13 @@ def execute_with_reloader(tool, session)
101106
def update_task_result(task, result)
102107
return if task.terminal? # Guard against double-complete
103108

104-
if result.is_error
105-
task.result_payload = result.to_h
109+
payload = result.to_h
110+
if result.is_error || payload[:isError] || payload["isError"]
111+
task.result_payload = payload
106112
task.status_message = result.respond_to?(:error_message) ? result.error_message : "Tool returned error"
107113
task.mark_failed!
108114
else
109-
task.result_payload = result.to_h
115+
task.result_payload = payload
110116
task.record_step!(:completed)
111117
task.complete!
112118
end
@@ -123,6 +129,10 @@ def self.handle_job_discard(job, error)
123129

124130
task.update(
125131
status_message: "Job failed: #{error.message}",
132+
result_payload: {
133+
code: -32_603,
134+
message: "Job failed: #{error.message}"
135+
},
126136
continuation_state: {
127137
step: :failed,
128138
error: { class: error.class.name, message: error.message },

app/models/action_mcp/session.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def server_capabilities_payload
114114
payload = {
115115
protocolVersion: protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION,
116116
serverInfo: server_info,
117-
capabilities: server_capabilities
117+
capabilities: capabilities_for_protocol(server_capabilities)
118118
}
119119
# Add instructions at top level if configured
120120
instructions = ActionMCP.configuration.instructions
@@ -130,6 +130,23 @@ def server_capabilities=(value)
130130
super(parsed_json_attribute(value))
131131
end
132132

133+
def capabilities_for_protocol(capabilities)
134+
parsed = parsed_json_attribute(capabilities)
135+
filtered =
136+
if parsed.respond_to?(:deep_dup)
137+
parsed.deep_dup
138+
elsif parsed
139+
parsed.dup
140+
else
141+
{}
142+
end
143+
return filtered if protocol_version == "2025-11-25"
144+
145+
filtered.delete("tasks")
146+
filtered.delete(:tasks)
147+
filtered
148+
end
149+
133150
def initialize!
134151
# update the session initialized to true
135152
return false if initialized?

app/models/action_mcp/session/task.rb

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class Session
5252
# input_required -> completed | failed | cancelled
5353
#
5454
class Task < ApplicationRecord
55+
RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"
56+
5557
self.table_name = "action_mcp_session_tasks"
5658

5759
attribute :id, :string, default: -> { SecureRandom.uuid_v7 }
@@ -139,6 +141,10 @@ def terminal?
139141
status.in?(%w[completed failed cancelled])
140142
end
141143

144+
def result_ready?
145+
terminal? || input_required?
146+
end
147+
142148
# Check if task is in a non-terminal state
143149
def non_terminal?
144150
!terminal?
@@ -148,32 +154,73 @@ def non_terminal?
148154
# @return [Hash] Task data for JSON-RPC responses
149155
def to_task_data
150156
data = {
151-
id: id,
157+
taskId: id,
152158
status: status,
153-
lastUpdatedAt: last_updated_at.iso8601(3)
159+
createdAt: created_at.iso8601(3),
160+
lastUpdatedAt: last_updated_at.iso8601(3),
161+
ttl: ttl
154162
}
155163
data[:statusMessage] = status_message if status_message.present?
156-
157-
# Add progress if available (ActiveJob::Continuable support)
158-
if progress_percent.present? || progress_message.present?
159-
data[:progress] = {}.tap do |progress|
160-
progress[:percent] = progress_percent if progress_percent.present?
161-
progress[:message] = progress_message if progress_message.present?
162-
end
163-
end
164+
data[:pollInterval] = poll_interval if poll_interval.present?
164165

165166
data
166167
end
167168

168-
# Convert to full task result format
169-
# @return [Hash] Complete task with result for tasks/result response
170-
def to_task_result
169+
def to_create_task_result
171170
{
172171
task: to_task_data,
173-
result: result_payload
172+
_meta: related_task_meta
174173
}
175174
end
176175

176+
# Convert to the original request's result payload for tasks/result.
177+
# The result carries related-task metadata because its structure does not
178+
# otherwise include the task identifier.
179+
# @return [Hash] Result payload for tasks/result response
180+
def to_task_result
181+
payload = result_payload.is_a?(Hash) ? result_payload.deep_dup : {}
182+
meta = payload.delete("_meta") || payload.delete(:_meta) || {}
183+
meta = meta.to_h if meta.respond_to?(:to_h)
184+
meta = {} unless meta.is_a?(Hash)
185+
186+
payload[:_meta] = meta.deep_merge(related_task_meta)
187+
payload
188+
end
189+
190+
def to_task_error
191+
return unless result_payload.is_a?(Hash)
192+
193+
code = result_payload["code"] || result_payload[:code]
194+
message = result_payload["message"] || result_payload[:message]
195+
return unless code && message
196+
return if result_payload.key?("content") || result_payload.key?(:content)
197+
return if result_payload.key?("isError") || result_payload.key?(:isError)
198+
199+
error = { code: code, message: message }
200+
data = result_payload["data"] || result_payload[:data]
201+
error[:data] = data unless data.nil?
202+
error
203+
end
204+
205+
def related_task_meta
206+
{
207+
RELATED_TASK_META_KEY => {
208+
"taskId" => id
209+
}
210+
}
211+
end
212+
213+
def request_meta_with_related_task(meta = nil)
214+
existing_meta =
215+
if meta.respond_to?(:to_h)
216+
meta.to_h.deep_dup
217+
else
218+
{}
219+
end
220+
221+
existing_meta.deep_merge(related_task_meta)
222+
end
223+
177224
# Broadcast status change notification to the session
178225
# @param transition [StateMachines::Transition] The state transition that occurred
179226
def broadcast_status_change(transition = nil)

lib/action_mcp/configuration.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ class Configuration
4545
:tasks_enabled,
4646
:tasks_list_enabled,
4747
:tasks_cancel_enabled,
48+
:tasks_result_strategy,
49+
:tasks_result_timeout,
50+
:tasks_result_poll_interval,
4851
# --- Schema Validation Options ---
4952
:validate_structured_content,
5053
# --- Allowed identity keys for gateway ---
@@ -72,6 +75,9 @@ def initialize
7275
@tasks_enabled = false
7376
@tasks_list_enabled = true
7477
@tasks_cancel_enabled = true
78+
@tasks_result_strategy = :blocking_http
79+
@tasks_result_timeout = 30.seconds
80+
@tasks_result_poll_interval = 0.25
7581

7682
# Pagination - nil means off. Set a number to enable with that page size.
7783
# Most MCP clients (including Claude Code) don't follow nextCursor yet.
@@ -152,6 +158,23 @@ def pagination_page_size=(value)
152158
end
153159
end
154160

161+
def tasks_result_strategy=(value)
162+
strategy = value.to_sym
163+
unless %i[blocking_http polling_only].include?(strategy)
164+
raise ArgumentError, "tasks_result_strategy must be :blocking_http or :polling_only, got: #{value.inspect}"
165+
end
166+
167+
@tasks_result_strategy = strategy
168+
end
169+
170+
def tasks_result_timeout=(value)
171+
@tasks_result_timeout = normalize_positive_duration(value, "tasks_result_timeout")
172+
end
173+
174+
def tasks_result_poll_interval=(value)
175+
@tasks_result_poll_interval = normalize_positive_duration(value, "tasks_result_poll_interval")
176+
end
177+
155178
def gateway_class
156179
# Resolve gateway class lazily to account for Zeitwerk autoloading
157180
# This allows ApplicationGateway to be loaded from app/mcp even if the
@@ -408,6 +431,12 @@ def extract_top_level_settings(app_config)
408431
self.pagination_page_size = config["pagination_page_size"]
409432
end
410433

434+
self.tasks_result_strategy = config["tasks_result_strategy"] if config.key?("tasks_result_strategy")
435+
self.tasks_result_timeout = config["tasks_result_timeout"] if config.key?("tasks_result_timeout")
436+
if config.key?("tasks_result_poll_interval")
437+
self.tasks_result_poll_interval = config["tasks_result_poll_interval"]
438+
end
439+
411440
# Extract allowed origins for DNS rebinding protection
412441
if config["allowed_origins"]
413442
@allowed_origins = Array(config["allowed_origins"])
@@ -434,6 +463,13 @@ def parse_instructions(instructions)
434463
Array(instructions).map(&:to_s)
435464
end
436465

466+
def normalize_positive_duration(value, setting_name)
467+
duration = value.respond_to?(:to_f) ? value.to_f : value.to_s.to_f
468+
raise ArgumentError, "#{setting_name} must be positive, got: #{value.inspect}" unless duration.positive?
469+
470+
duration
471+
end
472+
437473
def ensure_mcp_components_loaded
438474
# Only load if we haven't loaded yet - but in development, always reload
439475
return if @mcp_components_loaded && !Rails.env.development?

lib/action_mcp/json_rpc_handler_base.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ module Methods
4040
TASKS_RESULT = "tasks/result"
4141
TASKS_LIST = "tasks/list"
4242
TASKS_CANCEL = "tasks/cancel"
43-
TASKS_RESUME = "tasks/resume"
4443

4544
# Task notifications
4645
NOTIFICATIONS_TASKS_STATUS = "notifications/tasks/status"

lib/action_mcp/server/base_session.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def server_capabilities_payload
118118
payload = {
119119
protocolVersion: ActionMCP::LATEST_VERSION,
120120
serverInfo: server_info,
121-
capabilities: server_capabilities
121+
capabilities: capabilities_for_protocol(server_capabilities)
122122
}
123123
# Add instructions at top level if configured
124124
instructions = ActionMCP.configuration.instructions
@@ -280,6 +280,22 @@ def revoke_consent(key)
280280

281281
private
282282

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"
293+
294+
filtered.delete("tasks")
295+
filtered.delete(:tasks)
296+
filtered
297+
end
298+
283299
def normalize_name(class_or_name, type)
284300
case class_or_name
285301
when String

lib/action_mcp/server/handlers/task_handler.rb

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ def process_tasks(rpc_method, id, params)
1212
params ||= {}
1313

1414
with_error_handling(id) do
15+
unless transport.session.protocol_version == "2025-11-25"
16+
raise JSON_RPC::JsonRpcError.new(:method_not_found,
17+
message: "Tasks are only available in MCP 2025-11-25")
18+
end
19+
1520
handler = task_method_handlers[rpc_method]
1621
if handler
1722
send(handler, id, params)
@@ -29,8 +34,7 @@ def task_method_handlers
2934
JsonRpcHandlerBase::Methods::TASKS_GET => :handle_tasks_get,
3035
JsonRpcHandlerBase::Methods::TASKS_RESULT => :handle_tasks_result,
3136
JsonRpcHandlerBase::Methods::TASKS_LIST => :handle_tasks_list,
32-
JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel,
33-
JsonRpcHandlerBase::Methods::TASKS_RESUME => :handle_tasks_resume
37+
JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel
3438
}
3539
end
3640

@@ -63,15 +67,6 @@ def handle_tasks_cancel(id, params)
6367
transport.send_tasks_cancel(id, task_id)
6468
end
6569

66-
def handle_tasks_resume(id, params)
67-
task_id = validate_required_param(params, "taskId", "Task ID is required")
68-
input = params["input"]
69-
task = find_task_or_error(id, task_id)
70-
return unless task
71-
72-
transport.send_tasks_resume(id, task_id, input)
73-
end
74-
7570
def find_task_or_error(id, task_id)
7671
task = transport.session.tasks.find_by(id: task_id)
7772
unless task

lib/action_mcp/server/handlers/tool_handler.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def handle_tools_call(id, params)
3737
name = validate_required_param(params, "name", "Tool name is required")
3838
arguments = extract_arguments(params)
3939
_meta = params["_meta"] || params[:_meta] || {}
40-
transport.send_tools_call(id, name, arguments, _meta)
40+
task_params = params.key?("task") ? params["task"] : params[:task]
41+
transport.send_tools_call(id, name, arguments, _meta, task_params)
4142
end
4243

4344
def extract_arguments(params)

0 commit comments

Comments
 (0)