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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/action_mcp/application_controller.rb +12 -222
  3. data/app/jobs/action_mcp/tool_execution_job.rb +133 -0
  4. data/app/models/action_mcp/session/task.rb +204 -0
  5. data/app/models/action_mcp/session.rb +2 -65
  6. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +2 -0
  7. data/db/migrate/20251125000001_create_action_mcp_session_tasks.rb +29 -0
  8. data/db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb +10 -0
  9. data/db/migrate/20251203000001_remove_sse_support.rb +31 -0
  10. data/db/migrate/20251204000001_add_progress_to_session_tasks.rb +12 -0
  11. data/db/test.sqlite3 +0 -0
  12. data/exe/actionmcp_cli +1 -1
  13. data/lib/action_mcp/capability.rb +1 -0
  14. data/lib/action_mcp/client/base.rb +1 -1
  15. data/lib/action_mcp/configuration.rb +22 -15
  16. data/lib/action_mcp/engine.rb +8 -1
  17. data/lib/action_mcp/filtered_logger.rb +0 -3
  18. data/lib/action_mcp/json_rpc_handler_base.rb +10 -0
  19. data/lib/action_mcp/prompt.rb +16 -0
  20. data/lib/action_mcp/registry_base.rb +23 -1
  21. data/lib/action_mcp/server/base_session.rb +1 -71
  22. data/lib/action_mcp/server/handlers/router.rb +2 -0
  23. data/lib/action_mcp/server/handlers/task_handler.rb +86 -0
  24. data/lib/action_mcp/server/json_rpc_handler.rb +3 -0
  25. data/lib/action_mcp/server/simple_pub_sub.rb +1 -1
  26. data/lib/action_mcp/server/solid_mcp_adapter.rb +1 -1
  27. data/lib/action_mcp/server/tasks.rb +125 -0
  28. data/lib/action_mcp/server/tools.rb +47 -1
  29. data/lib/action_mcp/server/transport_handler.rb +1 -0
  30. data/lib/action_mcp/tool.rb +100 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/action_mcp.rb +3 -4
  33. data/lib/tasks/action_mcp_tasks.rake +1 -35
  34. metadata +42 -7
  35. data/app/models/action_mcp/session/sse_event.rb +0 -62
  36. data/lib/action_mcp/sse_listener.rb +0 -81
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07c0477f4e98cef701c55861a54b06530246aa72aa7e4cb573fbf2d523c0ae54
4
- data.tar.gz: 9bdff393572212ee32d0430ba2887292f35b0489a0753399a27a5f7a16aa3fac
3
+ metadata.gz: 9b01884f2ba4ded4a59d6c235583edbd00dae36da78215da26a1fbf83039838f
4
+ data.tar.gz: c97745755f33ece7ce05a4a2a904cde23e3fe67c3f1ecf9ee0cc962e6bdc9177
5
5
  SHA512:
6
- metadata.gz: d84067880b719178839887ec9aed55236486a1b8ed60859d1885303ac71b83525c3a52ee4c9267cf1b84044f467668b2a05ec06485d31666d0057838ab789b3a
7
- data.tar.gz: b4f687ea0485632c75670c64c9d8953cc340fc45ed774993a0ce2c661c30e05e5429047fb30146c6450e66e1b72ea2be6aae8977127e9cdbedb50e5b578186fd
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 for establishing server-initiated SSE streams (2025-03-26 spec).
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
- unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
36
- return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
37
- end
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-03-26 for backward compatibility as per spec
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-03-26 for backward compatibility"
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 client's Accept header includes the required types.
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
- if ActionMCP.configuration.post_response_preference == :sse
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
- if ActionMCP.configuration.post_response_preference == :sse
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(s) as a direct JSON HTTP 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