actionmcp 0.83.4 → 0.100.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/app/controllers/action_mcp/application_controller.rb +12 -222
- data/app/jobs/action_mcp/tool_execution_job.rb +133 -0
- data/app/models/action_mcp/session/task.rb +204 -0
- data/app/models/action_mcp/session.rb +2 -65
- data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +2 -0
- data/db/migrate/20251125000001_create_action_mcp_session_tasks.rb +29 -0
- data/db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb +10 -0
- data/db/migrate/20251203000001_remove_sse_support.rb +31 -0
- data/db/migrate/20251204000001_add_progress_to_session_tasks.rb +12 -0
- data/db/test.sqlite3 +0 -0
- data/exe/actionmcp_cli +1 -1
- data/lib/action_mcp/capability.rb +1 -0
- data/lib/action_mcp/client/base.rb +1 -1
- data/lib/action_mcp/configuration.rb +22 -15
- data/lib/action_mcp/engine.rb +8 -1
- data/lib/action_mcp/filtered_logger.rb +0 -3
- data/lib/action_mcp/json_rpc_handler_base.rb +10 -0
- data/lib/action_mcp/prompt.rb +16 -0
- data/lib/action_mcp/registry_base.rb +23 -1
- data/lib/action_mcp/server/base_session.rb +1 -71
- data/lib/action_mcp/server/handlers/router.rb +2 -0
- data/lib/action_mcp/server/handlers/task_handler.rb +86 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +3 -0
- data/lib/action_mcp/server/simple_pub_sub.rb +1 -1
- data/lib/action_mcp/server/solid_mcp_adapter.rb +1 -1
- data/lib/action_mcp/server/tasks.rb +125 -0
- data/lib/action_mcp/server/tools.rb +47 -1
- data/lib/action_mcp/server/transport_handler.rb +1 -0
- data/lib/action_mcp/tool.rb +100 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +3 -4
- data/lib/tasks/action_mcp_tasks.rake +1 -35
- metadata +42 -7
- data/app/models/action_mcp/session/sse_event.rb +0 -62
- data/lib/action_mcp/sse_listener.rb +0 -81
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b01884f2ba4ded4a59d6c235583edbd00dae36da78215da26a1fbf83039838f
|
|
4
|
+
data.tar.gz: c97745755f33ece7ce05a4a2a904cde23e3fe67c3f1ecf9ee0cc962e6bdc9177
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 545540e721a557860d9263873c841fd703afd1ea9bed05ea489b738c5a81594cc6f560fdf3959d6d9ff992b3e4b247eb8efead8f5f234946429903b00ce06a96
|
|
7
|
+
data.tar.gz: 8c4a3d2abe05b4f65b26e6dfdd751350973e029341c8ed8bb1e3f880a046477afd59ce236a71d6e607dd82ab3220eaac1c3ee462c7c8861c1ec79a814eaa42ca
|
|
@@ -9,7 +9,6 @@ module ActionMCP
|
|
|
9
9
|
|
|
10
10
|
include Engine.routes.url_helpers
|
|
11
11
|
include JSONRPC_Rails::ControllerHelpers
|
|
12
|
-
include ActionController::Live
|
|
13
12
|
include ActionController::Instrumentation
|
|
14
13
|
|
|
15
14
|
# Provides the ActionMCP::Session for the current request.
|
|
@@ -27,136 +26,15 @@ module ActionMCP
|
|
|
27
26
|
@session_key ||= "action_mcp-sessions-#{mcp_session.id}"
|
|
28
27
|
end
|
|
29
28
|
|
|
30
|
-
# Handles GET requests
|
|
29
|
+
# Handles GET requests - returns 405 Method Not Allowed as per MCP spec.
|
|
30
|
+
# SSE streaming is not supported. Clients should use Tasks for async operations.
|
|
31
31
|
# <rails-lens:routes:begin>
|
|
32
32
|
# ROUTE: /, name: mcp_get, via: GET
|
|
33
33
|
# <rails-lens:routes:end>
|
|
34
34
|
def show
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
session_id_from_header = extract_session_id
|
|
40
|
-
return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
|
|
41
|
-
|
|
42
|
-
session = mcp_session
|
|
43
|
-
if session.nil? || session.new_record?
|
|
44
|
-
return render_not_found("Session not found.")
|
|
45
|
-
elsif !session.initialized?
|
|
46
|
-
return render_bad_request("Session is not fully initialized.")
|
|
47
|
-
elsif session.status == "closed"
|
|
48
|
-
return render_not_found("Session has been terminated.")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Authenticate the request via gateway
|
|
52
|
-
authenticate_gateway!
|
|
53
|
-
return if performed?
|
|
54
|
-
|
|
55
|
-
last_event_id = request.headers["Last-Event-ID"].presence
|
|
56
|
-
if last_event_id && ActionMCP.configuration.verbose_logging
|
|
57
|
-
Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
response.headers["Content-Type"] = "text/event-stream"
|
|
61
|
-
response.headers["X-Accel-Buffering"] = "no"
|
|
62
|
-
response.headers["Cache-Control"] = "no-cache"
|
|
63
|
-
response.headers["Connection"] = "keep-alive"
|
|
64
|
-
# Add MCP-Protocol-Version header for established sessions
|
|
65
|
-
response.headers["MCP-Protocol-Version"] = session.protocol_version
|
|
66
|
-
|
|
67
|
-
if ActionMCP.configuration.verbose_logging
|
|
68
|
-
Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
sse = SSE.new(response.stream)
|
|
72
|
-
listener = SSEListener.new(session)
|
|
73
|
-
connection_active = Concurrent::AtomicBoolean.new
|
|
74
|
-
connection_active.make_true
|
|
75
|
-
heartbeat_active = Concurrent::AtomicBoolean.new
|
|
76
|
-
heartbeat_active.make_true
|
|
77
|
-
heartbeat_task = nil
|
|
78
|
-
|
|
79
|
-
listener_started = listener.start do |message|
|
|
80
|
-
write_sse_event(sse, session, message)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
unless listener_started
|
|
84
|
-
Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
|
|
85
|
-
connection_active.make_false
|
|
86
|
-
return
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
if last_event_id.present? && last_event_id.to_i.positive?
|
|
90
|
-
begin
|
|
91
|
-
missed_events = session.get_sse_events_after(last_event_id.to_i)
|
|
92
|
-
if missed_events.any?
|
|
93
|
-
if ActionMCP.configuration.verbose_logging
|
|
94
|
-
Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
|
|
95
|
-
end
|
|
96
|
-
missed_events.each do |event|
|
|
97
|
-
sse.write(event.to_sse)
|
|
98
|
-
end
|
|
99
|
-
elsif ActionMCP.configuration.verbose_logging
|
|
100
|
-
if ActionMCP.configuration.verbose_logging
|
|
101
|
-
Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
rescue StandardError => e
|
|
105
|
-
Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
|
|
110
|
-
heartbeat_sender = lambda do
|
|
111
|
-
if connection_active.true? && !response.stream.closed?
|
|
112
|
-
begin
|
|
113
|
-
# Send a proper JSON-RPC notification for heartbeat
|
|
114
|
-
ping_notification = {
|
|
115
|
-
jsonrpc: "2.0",
|
|
116
|
-
method: "notifications/ping",
|
|
117
|
-
params: {}
|
|
118
|
-
}
|
|
119
|
-
future = Concurrent::Promises.future { write_sse_event(sse, session, ping_notification) }
|
|
120
|
-
future.value!(5)
|
|
121
|
-
if heartbeat_active.true?
|
|
122
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
|
123
|
-
end
|
|
124
|
-
rescue Concurrent::TimeoutError
|
|
125
|
-
Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
|
|
126
|
-
connection_active.make_false
|
|
127
|
-
rescue StandardError => e
|
|
128
|
-
if ActionMCP.configuration.verbose_logging
|
|
129
|
-
Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}"
|
|
130
|
-
end
|
|
131
|
-
connection_active.make_false
|
|
132
|
-
end
|
|
133
|
-
else
|
|
134
|
-
heartbeat_active.make_false
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
|
139
|
-
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
|
140
|
-
rescue ActionController::Live::ClientDisconnected, IOError => e
|
|
141
|
-
if ActionMCP.configuration.verbose_logging
|
|
142
|
-
Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
|
|
143
|
-
end
|
|
144
|
-
rescue StandardError => e
|
|
145
|
-
Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
|
146
|
-
ensure
|
|
147
|
-
if ActionMCP.configuration.verbose_logging
|
|
148
|
-
Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
|
|
149
|
-
end
|
|
150
|
-
heartbeat_active&.make_false
|
|
151
|
-
heartbeat_task&.cancel
|
|
152
|
-
listener&.stop
|
|
153
|
-
cleanup_old_sse_events(session) if session
|
|
154
|
-
sse&.close
|
|
155
|
-
begin
|
|
156
|
-
response.stream&.close
|
|
157
|
-
rescue StandardError
|
|
158
|
-
nil
|
|
159
|
-
end
|
|
35
|
+
# MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
|
|
36
|
+
# ActionMCP uses Tasks for async operations instead of SSE streaming.
|
|
37
|
+
head :method_not_allowed
|
|
160
38
|
end
|
|
161
39
|
|
|
162
40
|
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
|
@@ -211,15 +89,6 @@ module ActionMCP
|
|
|
211
89
|
|
|
212
90
|
result = json_rpc_handler.call(jsonrpc_params)
|
|
213
91
|
process_handler_results(result, session, session_initially_missing, is_initialize_request)
|
|
214
|
-
rescue ActionController::Live::ClientDisconnected, IOError => e
|
|
215
|
-
if ActionMCP.configuration.verbose_logging
|
|
216
|
-
Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
|
|
217
|
-
end
|
|
218
|
-
begin
|
|
219
|
-
response.stream&.close
|
|
220
|
-
rescue StandardError
|
|
221
|
-
nil
|
|
222
|
-
end
|
|
223
92
|
rescue StandardError => e
|
|
224
93
|
Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
|
225
94
|
id = begin
|
|
@@ -271,9 +140,9 @@ module ActionMCP
|
|
|
271
140
|
header_version = request.headers["MCP-Protocol-Version"] || request.headers["mcp-protocol-version"]
|
|
272
141
|
session = mcp_session
|
|
273
142
|
|
|
274
|
-
# If header is missing, assume 2025-
|
|
143
|
+
# If header is missing, assume 2025-06-18 for backward compatibility as per spec
|
|
275
144
|
if header_version.nil?
|
|
276
|
-
ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-
|
|
145
|
+
ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-06-18 for backward compatibility"
|
|
277
146
|
return true
|
|
278
147
|
end
|
|
279
148
|
|
|
@@ -298,7 +167,6 @@ module ActionMCP
|
|
|
298
167
|
end
|
|
299
168
|
end
|
|
300
169
|
|
|
301
|
-
ActionMCP.logger.debug "MCP-Protocol-Version header validation passed: #{header_version}"
|
|
302
170
|
true
|
|
303
171
|
end
|
|
304
172
|
|
|
@@ -320,28 +188,14 @@ module ActionMCP
|
|
|
320
188
|
request.headers[MCP_SESSION_ID_HEADER].presence
|
|
321
189
|
end
|
|
322
190
|
|
|
323
|
-
# Checks if the
|
|
324
|
-
def accepts_valid_content_types?
|
|
325
|
-
request.accepts.any? { |type| type.to_s == "application/json" } &&
|
|
326
|
-
request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
# Checks if the Accept headers for POST are valid according to server preference.
|
|
191
|
+
# Checks if the Accept headers for POST are valid.
|
|
330
192
|
def post_accept_headers_valid?
|
|
331
|
-
|
|
332
|
-
accepts_valid_content_types?
|
|
333
|
-
else
|
|
334
|
-
request.accepts.any? { |type| type.to_s == "application/json" }
|
|
335
|
-
end
|
|
193
|
+
request.accepts.any? { |type| type.to_s == "application/json" }
|
|
336
194
|
end
|
|
337
195
|
|
|
338
196
|
# Returns the appropriate error message for POST Accept header validation.
|
|
339
197
|
def post_accept_headers_error_message
|
|
340
|
-
|
|
341
|
-
"Client must accept 'application/json' and 'text/event-stream'"
|
|
342
|
-
else
|
|
343
|
-
"Client must accept 'application/json'"
|
|
344
|
-
end
|
|
198
|
+
"Client must accept 'application/json'"
|
|
345
199
|
end
|
|
346
200
|
|
|
347
201
|
# Checks if the parsed body represents an 'initialize' request.
|
|
@@ -372,19 +226,11 @@ module ActionMCP
|
|
|
372
226
|
result
|
|
373
227
|
end
|
|
374
228
|
|
|
375
|
-
# Determine response format
|
|
376
|
-
server_preference = ActionMCP.configuration.post_response_preference
|
|
377
|
-
use_sse = (server_preference == :sse)
|
|
378
229
|
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
|
379
|
-
|
|
380
|
-
if use_sse
|
|
381
|
-
render_sse_response(payload, session, add_session_header)
|
|
382
|
-
else
|
|
383
|
-
render_json_response(payload, session, add_session_header)
|
|
384
|
-
end
|
|
230
|
+
render_json_response(payload, session, add_session_header)
|
|
385
231
|
end
|
|
386
232
|
|
|
387
|
-
# Renders the JSON-RPC response
|
|
233
|
+
# Renders the JSON-RPC response as a JSON HTTP response.
|
|
388
234
|
def render_json_response(payload, session, add_session_header)
|
|
389
235
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
|
390
236
|
# Add MCP-Protocol-Version header if session has been initialized
|
|
@@ -393,62 +239,6 @@ module ActionMCP
|
|
|
393
239
|
render json: payload, status: :ok
|
|
394
240
|
end
|
|
395
241
|
|
|
396
|
-
# Renders the JSON-RPC response(s) as an SSE stream.
|
|
397
|
-
def render_sse_response(payload, session, add_session_header)
|
|
398
|
-
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
|
399
|
-
# Add MCP-Protocol-Version header if session has been initialized
|
|
400
|
-
response.headers["MCP-Protocol-Version"] = session.protocol_version if session&.initialized?
|
|
401
|
-
response.headers["Content-Type"] = "text/event-stream"
|
|
402
|
-
response.headers["X-Accel-Buffering"] = "no"
|
|
403
|
-
response.headers["Cache-Control"] = "no-cache"
|
|
404
|
-
response.headers["Connection"] = "keep-alive"
|
|
405
|
-
sse = SSE.new(response.stream)
|
|
406
|
-
write_sse_event(sse, session, payload)
|
|
407
|
-
ensure
|
|
408
|
-
sse&.close
|
|
409
|
-
begin
|
|
410
|
-
response.stream&.close
|
|
411
|
-
rescue StandardError
|
|
412
|
-
nil
|
|
413
|
-
end
|
|
414
|
-
Rails.logger.debug "Unified SSE (POST): Response stream closed." if ActionMCP.configuration.verbose_logging
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
# Helper to write a JSON payload as an SSE event with a unique ID.
|
|
418
|
-
# Also stores the event for potential resumability.
|
|
419
|
-
def write_sse_event(sse, session, payload)
|
|
420
|
-
event_id = session.increment_sse_counter!
|
|
421
|
-
# Ensure we're always writing valid JSON strings
|
|
422
|
-
data = case payload
|
|
423
|
-
when String
|
|
424
|
-
payload
|
|
425
|
-
when Hash
|
|
426
|
-
MultiJson.dump(payload)
|
|
427
|
-
else
|
|
428
|
-
MultiJson.dump(payload.to_h)
|
|
429
|
-
end
|
|
430
|
-
# Use the SSE class's write method with proper options
|
|
431
|
-
# According to MCP spec, we need to send with event type "message"
|
|
432
|
-
sse.write(data, event: "message", id: event_id)
|
|
433
|
-
|
|
434
|
-
begin
|
|
435
|
-
session.store_sse_event(event_id, payload, session.max_stored_sse_events)
|
|
436
|
-
rescue StandardError => e
|
|
437
|
-
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
# Helper to clean up old SSE events for a session
|
|
442
|
-
def cleanup_old_sse_events(session)
|
|
443
|
-
retention_period = session.sse_event_retention_period
|
|
444
|
-
count = session.cleanup_old_sse_events(retention_period)
|
|
445
|
-
if count.positive? && ActionMCP.configuration.verbose_logging
|
|
446
|
-
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}"
|
|
447
|
-
end
|
|
448
|
-
rescue StandardError => e
|
|
449
|
-
Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
|
|
450
|
-
end
|
|
451
|
-
|
|
452
242
|
def format_tools_list(tools, session)
|
|
453
243
|
protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
|
|
454
244
|
tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMCP
|
|
4
|
+
# ActiveJob for executing tools asynchronously in task-augmented mode
|
|
5
|
+
# Part of MCP 2025-11-25 Tasks specification with ActiveJob::Continuable support
|
|
6
|
+
class ToolExecutionJob < ActiveJob::Base
|
|
7
|
+
include ActiveJob::Continuable
|
|
8
|
+
|
|
9
|
+
queue_as :mcp_tasks
|
|
10
|
+
|
|
11
|
+
# Retry configuration for transient failures
|
|
12
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
13
|
+
|
|
14
|
+
# Ensure tasks reach terminal state on permanent failure
|
|
15
|
+
discard_on StandardError do |job, error|
|
|
16
|
+
handle_job_discard(job, error)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param task_id [String] Task ID
|
|
20
|
+
# @param tool_name [String] Name of the tool to execute
|
|
21
|
+
# @param arguments [Hash] Tool arguments
|
|
22
|
+
# @param meta [Hash] Request metadata
|
|
23
|
+
def perform(task_id, tool_name, arguments, meta = {})
|
|
24
|
+
@task = step(:load_task, task_id)
|
|
25
|
+
return if @task.nil? || @task.terminal?
|
|
26
|
+
|
|
27
|
+
@session = step(:validate_session, @task)
|
|
28
|
+
return unless @session
|
|
29
|
+
|
|
30
|
+
@tool = step(:prepare_tool, @session, tool_name, arguments)
|
|
31
|
+
return unless @tool
|
|
32
|
+
|
|
33
|
+
step(:execute_tool) do
|
|
34
|
+
result = execute_with_reloader(@tool, @session)
|
|
35
|
+
update_task_result(@task, result)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def load_task(task_id)
|
|
42
|
+
task = Session::Task.find_by(id: task_id)
|
|
43
|
+
unless task
|
|
44
|
+
Rails.logger.error "[ToolExecutionJob] Task not found: #{task_id}"
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
task.record_step!(:job_started)
|
|
49
|
+
task
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_session(task)
|
|
53
|
+
session = task.session
|
|
54
|
+
unless session
|
|
55
|
+
task.update(status_message: "Session not found")
|
|
56
|
+
task.mark_failed!
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
session
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def prepare_tool(session, tool_name, arguments)
|
|
64
|
+
tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
|
|
65
|
+
unless tool_class
|
|
66
|
+
@task.update(status_message: "Tool '#{tool_name}' not found")
|
|
67
|
+
@task.mark_failed!
|
|
68
|
+
return nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create and configure tool instance
|
|
72
|
+
tool = tool_class.new(arguments)
|
|
73
|
+
tool.with_context({
|
|
74
|
+
session: session,
|
|
75
|
+
request: {
|
|
76
|
+
params: @task.request_params
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
tool
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def execute_with_reloader(tool, session)
|
|
84
|
+
if Rails.env.development?
|
|
85
|
+
# Preserve Current attributes across reloader boundary
|
|
86
|
+
current_user = ActionMCP::Current.user
|
|
87
|
+
current_gateway = ActionMCP::Current.gateway
|
|
88
|
+
|
|
89
|
+
Rails.application.reloader.wrap do
|
|
90
|
+
ActionMCP::Current.user = current_user
|
|
91
|
+
ActionMCP::Current.gateway = current_gateway
|
|
92
|
+
tool.call
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
tool.call
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def update_task_result(task, result)
|
|
100
|
+
return if task.terminal? # Guard against double-complete
|
|
101
|
+
|
|
102
|
+
if result.is_error
|
|
103
|
+
task.result_payload = result.to_h
|
|
104
|
+
task.status_message = result.respond_to?(:error_message) ? result.error_message : "Tool returned error"
|
|
105
|
+
task.mark_failed!
|
|
106
|
+
else
|
|
107
|
+
task.result_payload = result.to_h
|
|
108
|
+
task.record_step!(:completed)
|
|
109
|
+
task.complete!
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.handle_job_discard(job, error)
|
|
114
|
+
task_id = job.arguments.first
|
|
115
|
+
task = Session::Task.find_by(id: task_id)
|
|
116
|
+
return unless task&.persisted?
|
|
117
|
+
return if task.terminal?
|
|
118
|
+
|
|
119
|
+
Rails.logger.error "[ToolExecutionJob] Discarding job for task #{task_id}: #{error.class} - #{error.message}"
|
|
120
|
+
Rails.logger.error error.backtrace&.first(10)&.join("\n")
|
|
121
|
+
|
|
122
|
+
task.update(
|
|
123
|
+
status_message: "Job failed: #{error.message}",
|
|
124
|
+
continuation_state: {
|
|
125
|
+
step: :failed,
|
|
126
|
+
error: { class: error.class.name, message: error.message },
|
|
127
|
+
timestamp: Time.current.iso8601
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
task.mark_failed!
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "state_machines-activerecord"
|
|
4
|
+
|
|
5
|
+
module ActionMCP
|
|
6
|
+
class Session
|
|
7
|
+
# Represents a Task in an MCP session as per MCP 2025-11-25 specification.
|
|
8
|
+
# Tasks provide durable state machines for tracking async request execution.
|
|
9
|
+
#
|
|
10
|
+
# State Machine:
|
|
11
|
+
# working -> input_required -> working (via resume)
|
|
12
|
+
# working -> completed | failed | cancelled
|
|
13
|
+
# input_required -> completed | failed | cancelled
|
|
14
|
+
#
|
|
15
|
+
class Task < ApplicationRecord
|
|
16
|
+
self.table_name = "action_mcp_session_tasks"
|
|
17
|
+
|
|
18
|
+
attribute :id, :string, default: -> { SecureRandom.uuid_v7 }
|
|
19
|
+
|
|
20
|
+
belongs_to :session, class_name: "ActionMCP::Session", inverse_of: :tasks
|
|
21
|
+
|
|
22
|
+
# JSON columns are handled natively by Rails 8.1+
|
|
23
|
+
# No serialize needed for json column types
|
|
24
|
+
|
|
25
|
+
# Validations
|
|
26
|
+
validates :status, presence: true
|
|
27
|
+
validates :last_updated_at, presence: true
|
|
28
|
+
|
|
29
|
+
# Scopes - state_machines >= 0.100.0 auto-generates .with_status(:state) scopes
|
|
30
|
+
scope :terminal, -> { with_status(:completed, :failed, :cancelled) }
|
|
31
|
+
scope :non_terminal, -> { with_status(:working, :input_required) }
|
|
32
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
33
|
+
|
|
34
|
+
# State machine definition per MCP spec
|
|
35
|
+
state_machine :status, initial: :working do
|
|
36
|
+
# Terminal states
|
|
37
|
+
state :completed
|
|
38
|
+
state :failed
|
|
39
|
+
state :cancelled
|
|
40
|
+
|
|
41
|
+
# Non-terminal states
|
|
42
|
+
state :working
|
|
43
|
+
state :input_required
|
|
44
|
+
|
|
45
|
+
# Transition to input_required when awaiting user/client input
|
|
46
|
+
event :require_input do
|
|
47
|
+
transition working: :input_required
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resume from input_required back to working
|
|
51
|
+
event :resume do
|
|
52
|
+
transition input_required: :working
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Complete the task successfully
|
|
56
|
+
event :complete do
|
|
57
|
+
transition %i[working input_required] => :completed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Mark the task as failed due to an error
|
|
61
|
+
# Note: Using 'mark_failed' instead of 'fail' to avoid conflict with Object#fail
|
|
62
|
+
event :mark_failed do
|
|
63
|
+
transition %i[working input_required] => :failed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cancel the task
|
|
67
|
+
event :cancel do
|
|
68
|
+
transition %i[working input_required] => :cancelled
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# After any transition, update timestamp and broadcast
|
|
72
|
+
after_transition do |task, transition|
|
|
73
|
+
task.update_column(:last_updated_at, Time.current)
|
|
74
|
+
task.broadcast_status_change(transition)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Callbacks
|
|
79
|
+
before_validation :set_last_updated_at, on: :create
|
|
80
|
+
|
|
81
|
+
# TTL management
|
|
82
|
+
# @return [Boolean] true if task has exceeded its TTL
|
|
83
|
+
def expired?
|
|
84
|
+
return false if ttl.nil?
|
|
85
|
+
|
|
86
|
+
# TTL is stored in milliseconds (MCP spec)
|
|
87
|
+
created_at + (ttl / 1000.0).seconds < Time.current
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if task is in a terminal state
|
|
91
|
+
def terminal?
|
|
92
|
+
status.in?(%w[completed failed cancelled])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if task is in a non-terminal state
|
|
96
|
+
def non_terminal?
|
|
97
|
+
!terminal?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Convert to task data format per MCP spec
|
|
101
|
+
# @return [Hash] Task data for JSON-RPC responses
|
|
102
|
+
def to_task_data
|
|
103
|
+
data = {
|
|
104
|
+
id: id,
|
|
105
|
+
status: status,
|
|
106
|
+
lastUpdatedAt: last_updated_at.iso8601(3)
|
|
107
|
+
}
|
|
108
|
+
data[:statusMessage] = status_message if status_message.present?
|
|
109
|
+
|
|
110
|
+
# Add progress if available (ActiveJob::Continuable support)
|
|
111
|
+
if progress_percent.present? || progress_message.present?
|
|
112
|
+
data[:progress] = {}.tap do |progress|
|
|
113
|
+
progress[:percent] = progress_percent if progress_percent.present?
|
|
114
|
+
progress[:message] = progress_message if progress_message.present?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
data
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Convert to full task result format
|
|
122
|
+
# @return [Hash] Complete task with result for tasks/result response
|
|
123
|
+
def to_task_result
|
|
124
|
+
{
|
|
125
|
+
task: to_task_data,
|
|
126
|
+
result: result_payload
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Broadcast status change notification to the session
|
|
131
|
+
# @param transition [StateMachines::Transition] The state transition that occurred
|
|
132
|
+
def broadcast_status_change(transition = nil)
|
|
133
|
+
return unless session
|
|
134
|
+
|
|
135
|
+
handler = ActionMCP::Server::TransportHandler.new(session)
|
|
136
|
+
handler.send_task_status_notification(self)
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
Rails.logger.warn "Failed to broadcast task status change: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Continuation State Management (for ActiveJob::Continuable support)
|
|
142
|
+
|
|
143
|
+
# Record step execution state for job resumption
|
|
144
|
+
# @param step_name [Symbol] Name of the step
|
|
145
|
+
# @param cursor [Integer, String] Optional cursor for resuming iteration
|
|
146
|
+
# @param data [Hash] Additional step data to persist
|
|
147
|
+
def record_step!(step_name, cursor: nil, data: {})
|
|
148
|
+
update!(
|
|
149
|
+
continuation_state: {
|
|
150
|
+
step: step_name,
|
|
151
|
+
cursor: cursor,
|
|
152
|
+
data: data,
|
|
153
|
+
timestamp: Time.current.iso8601
|
|
154
|
+
},
|
|
155
|
+
last_step_at: Time.current
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Store partial result fragment (for streaming/incremental results)
|
|
160
|
+
# @param result_fragment [Hash] Partial result to append
|
|
161
|
+
def store_partial_result!(result_fragment)
|
|
162
|
+
payload = result_payload || {}
|
|
163
|
+
payload[:partial] ||= []
|
|
164
|
+
payload[:partial] << result_fragment
|
|
165
|
+
update!(result_payload: payload)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Update progress indicators for long-running tasks
|
|
169
|
+
# @param percent [Integer] Progress percentage (0-100)
|
|
170
|
+
# @param message [String] Optional progress message
|
|
171
|
+
def update_progress!(percent:, message: nil)
|
|
172
|
+
update!(
|
|
173
|
+
progress_percent: percent.clamp(0, 100),
|
|
174
|
+
progress_message: message,
|
|
175
|
+
last_step_at: Time.current
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Transition to input_required state and store pending input prompt
|
|
180
|
+
# @param prompt [String] The prompt/question for the user
|
|
181
|
+
# @param context [Hash] Additional context about the input request
|
|
182
|
+
def await_input!(prompt:, context: {})
|
|
183
|
+
record_step!(:awaiting_input, data: { prompt: prompt, context: context })
|
|
184
|
+
require_input!
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Resume task from input_required state and re-enqueue job
|
|
188
|
+
# @return [void]
|
|
189
|
+
def resume_from_continuation!
|
|
190
|
+
return unless input_required?
|
|
191
|
+
|
|
192
|
+
resume!
|
|
193
|
+
# Re-enqueue the job to continue execution
|
|
194
|
+
ActionMCP::ToolExecutionJob.perform_later(id, request_name, request_params, {})
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def set_last_updated_at
|
|
200
|
+
self.last_updated_at ||= Time.current
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|