actionmcp 0.83.4 → 0.90.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07c0477f4e98cef701c55861a54b06530246aa72aa7e4cb573fbf2d523c0ae54
4
- data.tar.gz: 9bdff393572212ee32d0430ba2887292f35b0489a0753399a27a5f7a16aa3fac
3
+ metadata.gz: f4e55c16fbbaf08a18d98c152d72d152a97170ac81e9f6126b24663f7f53ed14
4
+ data.tar.gz: 11e2f3cd710ee43e7ef868197bed0221d50da3ea513764a05856afb3334db8fb
5
5
  SHA512:
6
- metadata.gz: d84067880b719178839887ec9aed55236486a1b8ed60859d1885303ac71b83525c3a52ee4c9267cf1b84044f467668b2a05ec06485d31666d0057838ab789b3a
7
- data.tar.gz: b4f687ea0485632c75670c64c9d8953cc340fc45ed774993a0ce2c661c30e05e5429047fb30146c6450e66e1b72ea2be6aae8977127e9cdbedb50e5b578186fd
6
+ metadata.gz: 4eeca2ab09bb4ee053d575d610fd6f1a236ee1038c008fad9f90b9aa0f78c339e21ceaecdf499d13c885e006e89ea224a74fc326e5d9b3cc091434c17733d50f
7
+ data.tar.gz: e1c15290f9a862dfb3d37cd8d077b9c3312370dd2e6e12f7bf57c661d5de20a682d1ca7c59e6149bdc94d543a2151cfcc576ede94afab07accdd8fb8ce82bb59
@@ -271,9 +271,9 @@ module ActionMCP
271
271
  header_version = request.headers["MCP-Protocol-Version"] || request.headers["mcp-protocol-version"]
272
272
  session = mcp_session
273
273
 
274
- # If header is missing, assume 2025-03-26 for backward compatibility as per spec
274
+ # If header is missing, assume 2025-06-18 for backward compatibility as per spec
275
275
  if header_version.nil?
276
- ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-03-26 for backward compatibility"
276
+ ActionMCP.logger.debug "MCP-Protocol-Version header missing, assuming 2025-06-18 for backward compatibility"
277
277
  return true
278
278
  end
279
279
 
@@ -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
@@ -79,6 +79,12 @@ module ActionMCP
79
79
  dependent: :delete_all,
80
80
  inverse_of: :session
81
81
 
82
+ has_many :tasks,
83
+ class_name: "ActionMCP::Session::Task",
84
+ foreign_key: "session_id",
85
+ dependent: :delete_all,
86
+ inverse_of: :session
87
+
82
88
  scope :pre_initialize, -> { where(status: "pre_initialize") }
83
89
  scope :closed, -> { where(status: "closed") }
84
90
  scope :without_messages, -> { includes(:messages).where(action_mcp_session_messages: { id: nil }) }
@@ -2,6 +2,8 @@
2
2
 
3
3
  class AddConsentsToActionMCPSess < ActiveRecord::Migration[8.0]
4
4
  def change
5
+ return if column_exists?(:action_mcp_sessions, :consents)
6
+
5
7
  add_column :action_mcp_sessions, :consents, :json, default: {}, null: false
6
8
  end
7
9
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActionMCPSessionTasks < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :action_mcp_session_tasks, id: :string do |t|
6
+ t.references :session, null: false,
7
+ foreign_key: { to_table: :action_mcp_sessions,
8
+ on_delete: :cascade,
9
+ on_update: :cascade,
10
+ name: 'fk_action_mcp_session_tasks_session_id' },
11
+ type: :string
12
+ t.string :status, null: false, default: 'working'
13
+ t.string :status_message
14
+ t.string :request_method, comment: 'e.g., tools/call, prompts/get'
15
+ t.string :request_name, comment: 'e.g., tool name, prompt name'
16
+ t.json :request_params, comment: 'Original request params'
17
+ t.json :result_payload, comment: 'Final result data'
18
+ t.integer :ttl, comment: 'Time to live in milliseconds'
19
+ t.integer :poll_interval, comment: 'Suggested polling interval in milliseconds'
20
+ t.datetime :last_updated_at, null: false
21
+
22
+ t.timestamps
23
+
24
+ t.index :status
25
+ t.index %i[session_id status]
26
+ t.index :created_at
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddContinuationStateToActionMCPSessionTasks < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :action_mcp_session_tasks, :continuation_state, :json, default: {}
6
+ add_column :action_mcp_session_tasks, :progress_percent, :integer
7
+ add_column :action_mcp_session_tasks, :progress_message, :string
8
+ add_column :action_mcp_session_tasks, :last_step_at, :datetime
9
+ end
10
+ end
data/exe/actionmcp_cli CHANGED
@@ -62,7 +62,7 @@ end
62
62
 
63
63
  # Function to generate a unique request ID
64
64
  def generate_request_id
65
- SecureRandom.uuid
65
+ SecureRandom.uuid_v7
66
66
  end
67
67
 
68
68
  # Function to parse command shortcuts and return a Request object
@@ -11,6 +11,7 @@ module ActionMCP
11
11
  include Renderable
12
12
 
13
13
  class_attribute :_capability_name, instance_accessor: false
14
+ class_attribute :_registered_name, instance_accessor: false
14
15
  class_attribute :_description, instance_accessor: false, default: ""
15
16
 
16
17
  attr_reader :execution_context
@@ -187,7 +187,7 @@ module ActionMCP
187
187
  params[:sessionId] = @session_id if @session_id
188
188
 
189
189
  # Use a unique request ID (not session ID since we don't have one yet)
190
- request_id = SecureRandom.uuid
190
+ request_id = SecureRandom.uuid_v7
191
191
  send_jsonrpc_request("initialize", params: params, id: request_id)
192
192
  end
193
193
 
@@ -48,7 +48,11 @@ module ActionMCP
48
48
  :max_threads,
49
49
  :max_queue,
50
50
  :polling_interval,
51
- :connects_to
51
+ :connects_to,
52
+ # --- Tasks Options (MCP 2025-11-25) ---
53
+ :tasks_enabled,
54
+ :tasks_list_enabled,
55
+ :tasks_cancel_enabled
52
56
 
53
57
  def initialize
54
58
  @logging_enabled = false
@@ -65,7 +69,12 @@ module ActionMCP
65
69
 
66
70
  @sse_heartbeat_interval = 30
67
71
  @post_response_preference = :json
68
- @protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
72
+ @protocol_version = "2025-06-18" # Default to stable version for backwards compatibility
73
+
74
+ # Tasks defaults (MCP 2025-11-25)
75
+ @tasks_enabled = false
76
+ @tasks_list_enabled = true
77
+ @tasks_cancel_enabled = true
69
78
 
70
79
  # Resumability defaults
71
80
  @sse_event_retention_period = 15.minutes
@@ -224,6 +233,18 @@ module ActionMCP
224
233
 
225
234
  capabilities[:elicitation] = {} if @elicitation_enabled
226
235
 
236
+ # Tasks capability (MCP 2025-11-25)
237
+ if @tasks_enabled
238
+ tasks_cap = {
239
+ requests: {
240
+ tools: { call: {} }
241
+ }
242
+ }
243
+ tasks_cap[:list] = {} if @tasks_list_enabled
244
+ tasks_cap[:cancel] = {} if @tasks_cancel_enabled
245
+ capabilities[:tasks] = tasks_cap
246
+ end
247
+
227
248
  capabilities
228
249
  end
229
250
 
@@ -61,6 +61,14 @@ module ActionMCP
61
61
 
62
62
  # Configure autoloading for the mcp/tools directory and identifiers
63
63
  initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
64
+ # Ensure ActionMCP base constants exist before Zeitwerk indexes app/mcp
65
+ # This prevents NameError when dependent gems have app/mcp
66
+ # directories with classes inheriting from ActionMCP::Tool, etc.
67
+ require "action_mcp/tool"
68
+ require "action_mcp/prompt"
69
+ require "action_mcp/resource_template"
70
+ require "action_mcp/gateway"
71
+
64
72
  mcp_path = app.root.join("app/mcp")
65
73
  identifiers_path = app.root.join("app/identifiers")
66
74
 
@@ -34,6 +34,16 @@ module ActionMCP
34
34
 
35
35
  # Elicitation methods
36
36
  ELICITATION_CREATE = "elicitation/create"
37
+
38
+ # Task methods (MCP 2025-11-25)
39
+ TASKS_GET = "tasks/get"
40
+ TASKS_RESULT = "tasks/result"
41
+ TASKS_LIST = "tasks/list"
42
+ TASKS_CANCEL = "tasks/cancel"
43
+ TASKS_RESUME = "tasks/resume"
44
+
45
+ # Task notifications
46
+ NOTIFICATIONS_TASKS_STATUS = "notifications/tasks/status"
37
47
  end
38
48
 
39
49
  delegate :initialize!, :initialized?, to: :transport
@@ -18,11 +18,27 @@ module ActionMCP
18
18
  def self.prompt_name(name = nil)
19
19
  if name
20
20
  self._capability_name = name
21
+ re_register_if_needed
22
+ name
21
23
  else
22
24
  _capability_name || default_prompt_name
23
25
  end
24
26
  end
25
27
 
28
+ # Re-registers the prompt if it was already registered under a different name
29
+ # @return [void]
30
+ def self.re_register_if_needed
31
+ return if abstract?
32
+ return unless _registered_name # Not yet registered
33
+
34
+ new_name = capability_name
35
+ return if _registered_name == new_name # No change
36
+
37
+ ActionMCP::PromptsRegistry.re_register(self, _registered_name)
38
+ end
39
+
40
+ private_class_method :re_register_if_needed
41
+
26
42
  # Returns the default prompt name based on the class name.
27
43
  #
28
44
  # @return [String] The default prompt name.
@@ -21,7 +21,29 @@ module ActionMCP
21
21
  def register(klass)
22
22
  return if klass.abstract?
23
23
 
24
- items[klass.capability_name] = klass
24
+ name = klass.capability_name
25
+ items[name] = klass
26
+ klass._registered_name = name if klass.respond_to?(:_registered_name=)
27
+ end
28
+
29
+ # Re-register an item under its current capability_name
30
+ # Removes old entry if name changed
31
+ #
32
+ # @param klass [Class] The class to re-register
33
+ # @param old_name [String] The previous registered name
34
+ # @return [void]
35
+ def re_register(klass, old_name)
36
+ return if klass.abstract?
37
+
38
+ new_name = klass.capability_name
39
+
40
+ # Remove old entry if it exists and points to this class
41
+ if old_name && items[old_name] == klass
42
+ items.delete(old_name)
43
+ end
44
+
45
+ items[new_name] = klass
46
+ klass._registered_name = new_name if klass.respond_to?(:_registered_name=)
25
47
  end
26
48
 
27
49
  # Unregister an item
@@ -18,6 +18,8 @@ module ActionMCP
18
18
  @handler.process_resources(rpc_method, id, params)
19
19
  when %r{^tools/}
20
20
  @handler.process_tools(rpc_method, id, params)
21
+ when %r{^tasks/}
22
+ @handler.process_tasks(rpc_method, id, params)
21
23
  when "completion/complete"
22
24
  @handler.process_completion_complete(id, params)
23
25
  else
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ module Handlers
6
+ # Handler for MCP 2025-11-25 Tasks feature
7
+ # Tasks provide durable state machines for tracking async request execution
8
+ module TaskHandler
9
+ include ErrorAware
10
+
11
+ def process_tasks(rpc_method, id, params)
12
+ params ||= {}
13
+
14
+ with_error_handling(id) do
15
+ handler = task_method_handlers[rpc_method]
16
+ if handler
17
+ send(handler, id, params)
18
+ else
19
+ Rails.logger.warn("Unknown tasks method: #{rpc_method}")
20
+ raise JSON_RPC::JsonRpcError.new(:method_not_found, message: "Unknown tasks method: #{rpc_method}")
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def task_method_handlers
28
+ {
29
+ JsonRpcHandlerBase::Methods::TASKS_GET => :handle_tasks_get,
30
+ JsonRpcHandlerBase::Methods::TASKS_RESULT => :handle_tasks_result,
31
+ JsonRpcHandlerBase::Methods::TASKS_LIST => :handle_tasks_list,
32
+ JsonRpcHandlerBase::Methods::TASKS_CANCEL => :handle_tasks_cancel,
33
+ JsonRpcHandlerBase::Methods::TASKS_RESUME => :handle_tasks_resume
34
+ }
35
+ end
36
+
37
+ def handle_tasks_get(id, params)
38
+ task_id = validate_required_param(params, "taskId", "Task ID is required")
39
+ task = find_task_or_error(id, task_id)
40
+ return unless task
41
+
42
+ transport.send_tasks_get(id, task_id)
43
+ end
44
+
45
+ def handle_tasks_result(id, params)
46
+ task_id = validate_required_param(params, "taskId", "Task ID is required")
47
+ task = find_task_or_error(id, task_id)
48
+ return unless task
49
+
50
+ transport.send_tasks_result(id, task_id)
51
+ end
52
+
53
+ def handle_tasks_list(id, params)
54
+ cursor = params["cursor"]
55
+ transport.send_tasks_list(id, cursor: cursor)
56
+ end
57
+
58
+ def handle_tasks_cancel(id, params)
59
+ task_id = validate_required_param(params, "taskId", "Task ID is required")
60
+ task = find_task_or_error(id, task_id)
61
+ return unless task
62
+
63
+ transport.send_tasks_cancel(id, task_id)
64
+ end
65
+
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
+
75
+ def find_task_or_error(id, task_id)
76
+ task = transport.session.tasks.find_by(id: task_id)
77
+ unless task
78
+ transport.send_jsonrpc_error(id, :invalid_params, "Task '#{task_id}' not found")
79
+ return nil
80
+ end
81
+ task
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -7,6 +7,7 @@ module ActionMCP
7
7
  include Handlers::ToolHandler
8
8
  include Handlers::PromptHandler
9
9
  include Handlers::LoggingHandler
10
+ include Handlers::TaskHandler
10
11
  include ErrorHandling
11
12
  include ErrorAware
12
13
 
@@ -54,6 +55,8 @@ module ActionMCP
54
55
  process_resources(rpc_method, id, params)
55
56
  when %r{^tools/}
56
57
  process_tools(rpc_method, id, params)
58
+ when %r{^tasks/}
59
+ process_tasks(rpc_method, id, params)
57
60
  when Methods::COMPLETION_COMPLETE
58
61
  process_completion_complete(id, params)
59
62
  when Methods::LOGGING_SET_LEVEL
@@ -36,7 +36,7 @@ module ActionMCP
36
36
  # @param success_callback [Proc] Callback for successful subscription
37
37
  # @return [String] Subscription ID
38
38
  def subscribe(channel, message_callback, success_callback = nil)
39
- subscription_id = SecureRandom.uuid
39
+ subscription_id = SecureRandom.uuid_v7
40
40
 
41
41
  @subscriptions[subscription_id] = {
42
42
  channel: channel,
@@ -21,7 +21,7 @@ module ActionMCP
21
21
  # @param success_callback [Proc] Callback for successful subscription
22
22
  # @return [String] Subscription ID
23
23
  def subscribe(channel, message_callback, success_callback = nil)
24
- subscription_id = SecureRandom.uuid
24
+ subscription_id = SecureRandom.uuid_v7
25
25
  session_id = extract_session_id(channel)
26
26
 
27
27
  @subscriptions[subscription_id] = {
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Tasks module for MCP 2025-11-25 specification
6
+ # Provides methods for handling task-related requests:
7
+ # - tasks/get: Get task status and data
8
+ # - tasks/result: Get task result (blocking until terminal state)
9
+ # - tasks/list: List tasks for the session
10
+ # - tasks/cancel: Cancel a task
11
+ module Tasks
12
+ # Get task status and metadata
13
+ # @param request_id [String, Integer] JSON-RPC request ID
14
+ # @param task_id [String] Task ID to retrieve
15
+ def send_tasks_get(request_id, task_id)
16
+ task = find_task(task_id)
17
+ return unless task
18
+
19
+ send_jsonrpc_response(request_id, result: { task: task.to_task_data })
20
+ end
21
+
22
+ # Get task result, blocking until task reaches terminal state
23
+ # @param request_id [String, Integer] JSON-RPC request ID
24
+ # @param task_id [String] Task ID to get result for
25
+ def send_tasks_result(request_id, task_id)
26
+ task = find_task(task_id)
27
+ return unless task
28
+
29
+ # If task is not in terminal state, wait for it
30
+ # In async execution, client should poll or use SSE for notifications
31
+ unless task.terminal?
32
+ send_jsonrpc_error(request_id, :invalid_request,
33
+ "Task is not yet complete. Current status: #{task.status}")
34
+ return
35
+ end
36
+
37
+ send_jsonrpc_response(request_id, result: task.to_task_result)
38
+ end
39
+
40
+ # List tasks for the session with optional pagination
41
+ # @param request_id [String, Integer] JSON-RPC request ID
42
+ # @param cursor [String, nil] Pagination cursor
43
+ def send_tasks_list(request_id, cursor: nil)
44
+ # Parse cursor if provided
45
+ offset = cursor.to_i if cursor.present?
46
+ offset ||= 0
47
+ limit = 50
48
+
49
+ tasks = session.tasks.recent.offset(offset).limit(limit + 1)
50
+ has_more = tasks.length > limit
51
+ tasks = tasks.first(limit)
52
+
53
+ result = {
54
+ tasks: tasks.map(&:to_task_data)
55
+ }
56
+ result[:nextCursor] = (offset + limit).to_s if has_more
57
+
58
+ send_jsonrpc_response(request_id, result: result)
59
+ end
60
+
61
+ # Cancel a task
62
+ # @param request_id [String, Integer] JSON-RPC request ID
63
+ # @param task_id [String] Task ID to cancel
64
+ def send_tasks_cancel(request_id, task_id)
65
+ task = find_task(task_id)
66
+ return unless task
67
+
68
+ if task.terminal?
69
+ send_jsonrpc_error(request_id, :invalid_params,
70
+ "Cannot cancel task in terminal status: #{task.status}")
71
+ return
72
+ end
73
+
74
+ task.cancel!
75
+ send_jsonrpc_response(request_id, result: { task: task.to_task_data })
76
+ end
77
+
78
+ # Resume a task from input_required state
79
+ # @param request_id [String, Integer] JSON-RPC request ID
80
+ # @param task_id [String] Task ID to resume
81
+ # @param input [Object] Input data for the task
82
+ def send_tasks_resume(request_id, task_id, input)
83
+ task = find_task(task_id)
84
+ return unless task
85
+
86
+ unless task.input_required?
87
+ send_jsonrpc_error(request_id, :invalid_params,
88
+ "Task is not awaiting input. Current status: #{task.status}")
89
+ return
90
+ end
91
+
92
+ # Store input in continuation state
93
+ continuation = task.continuation_state || {}
94
+ continuation[:input] = input
95
+ task.update!(continuation_state: continuation)
96
+
97
+ # Resume task and re-enqueue job
98
+ task.resume_from_continuation!
99
+
100
+ send_jsonrpc_response(request_id, result: { task: task.to_task_data })
101
+ end
102
+
103
+ # Send task status notification
104
+ # @param task [ActionMCP::Session::Task] Task to notify about
105
+ def send_task_status_notification(task)
106
+ send_jsonrpc_notification(
107
+ JsonRpcHandlerBase::Methods::NOTIFICATIONS_TASKS_STATUS,
108
+ { task: task.to_task_data }
109
+ )
110
+ end
111
+
112
+ private
113
+
114
+ def find_task(task_id)
115
+ task = session.tasks.find_by(id: task_id)
116
+ unless task
117
+ Rails.logger.warn "Task not found: #{task_id}"
118
+ # Note: we need the request_id to send error, but this is called from handler
119
+ # The handler should handle the nil return
120
+ end
121
+ task
122
+ end
123
+ end
124
+ end
125
+ end
@@ -58,6 +58,20 @@ module ActionMCP
58
58
  return
59
59
  end
60
60
 
61
+ # Check for task-augmented execution (MCP 2025-11-25)
62
+ task_params = _meta["task"] || _meta[:task]
63
+ if task_params && tasks_enabled?
64
+ handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
65
+ return
66
+ end
67
+
68
+ # Standard synchronous execution
69
+ execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
70
+ end
71
+
72
+ private
73
+
74
+ def execute_tool_synchronously(request_id, tool_class, tool_name, arguments, _meta)
61
75
  begin
62
76
  # Create tool and set execution context with request info
63
77
  tool = tool_class.new(arguments)
@@ -106,7 +120,39 @@ module ActionMCP
106
120
  end
107
121
  end
108
122
 
109
- private
123
+ # Handle task-augmented tool calls per MCP 2025-11-25 specification
124
+ # Creates a Task record and executes the tool asynchronously
125
+ def handle_task_augmented_tool_call(request_id, tool_name, arguments, _meta, task_params)
126
+ # Extract task configuration
127
+ ttl = task_params["ttl"] || task_params[:ttl] || 60_000
128
+ poll_interval = task_params["pollInterval"] || task_params[:pollInterval] || 5_000
129
+
130
+ # Create task record
131
+ task = session.tasks.create!(
132
+ request_method: "tools/call",
133
+ request_name: tool_name,
134
+ request_params: {
135
+ name: tool_name,
136
+ arguments: arguments,
137
+ _meta: _meta
138
+ },
139
+ ttl: ttl,
140
+ poll_interval: poll_interval
141
+ )
142
+
143
+ # Return CreateTaskResult immediately
144
+ send_jsonrpc_response(request_id, result: { task: task.to_task_data })
145
+
146
+ # Execute tool asynchronously via ActiveJob
147
+ ToolExecutionJob.perform_later(task.id, tool_name, arguments, _meta)
148
+ rescue StandardError => e
149
+ Rails.logger.error "Failed to create task: #{e.class} - #{e.message}"
150
+ send_jsonrpc_error(request_id, :internal_error, "Failed to create task")
151
+ end
152
+
153
+ def tasks_enabled?
154
+ ActionMCP.configuration.tasks_enabled
155
+ end
110
156
 
111
157
  def format_registry_items(registry, protocol_version = nil)
112
158
  registry.map { |item| item.klass.to_h(protocol_version: protocol_version) }
@@ -19,6 +19,7 @@ module ActionMCP
19
19
  include Sampling
20
20
  include Roots
21
21
  include Elicitation
22
+ include Tasks
22
23
  include ResponseCollector # Must be included last to override write_message
23
24
 
24
25
  # @param [ActionMCP::Session] session
@@ -29,6 +29,8 @@ module ActionMCP
29
29
  class_attribute :_output_schema_builder, instance_accessor: false, default: nil
30
30
  class_attribute :_additional_properties, instance_accessor: false, default: nil
31
31
  class_attribute :_cached_schema_property_keys, instance_accessor: false, default: nil
32
+ class_attribute :_task_support, instance_accessor: false, default: :forbidden
33
+ class_attribute :_resumable_steps_block, instance_accessor: false, default: nil
32
34
 
33
35
  # --------------------------------------------------------------------------
34
36
  # Tool Name and Description DSL
@@ -40,11 +42,27 @@ module ActionMCP
40
42
  def self.tool_name(name = nil)
41
43
  if name
42
44
  self._capability_name = name
45
+ re_register_if_needed
46
+ name
43
47
  else
44
48
  _capability_name || default_tool_name
45
49
  end
46
50
  end
47
51
 
52
+ # Re-registers the tool if it was already registered under a different name
53
+ # @return [void]
54
+ def self.re_register_if_needed
55
+ return if abstract?
56
+ return unless _registered_name # Not yet registered
57
+
58
+ new_name = capability_name
59
+ return if _registered_name == new_name # No change
60
+
61
+ ActionMCP::ToolsRegistry.re_register(self, _registered_name)
62
+ end
63
+
64
+ private_class_method :re_register_if_needed
65
+
48
66
  # Returns a default tool name based on the class name.
49
67
  #
50
68
  # @return [String] The default tool name.
@@ -178,6 +196,60 @@ module ActionMCP
178
196
  _requires_consent
179
197
  end
180
198
 
199
+ # --------------------------------------------------------------------------
200
+ # Task Support DSL (MCP 2025-11-25)
201
+ # --------------------------------------------------------------------------
202
+ # Sets or retrieves the task support mode for this tool
203
+ # @param mode [Symbol, nil] :required, :optional, or :forbidden (default)
204
+ # @return [Symbol] The current task support mode
205
+ def task_support(mode = nil)
206
+ if mode
207
+ unless %i[required optional forbidden].include?(mode)
208
+ raise ArgumentError, "task_support must be :required, :optional, or :forbidden"
209
+ end
210
+
211
+ self._task_support = mode
212
+ else
213
+ _task_support
214
+ end
215
+ end
216
+
217
+ # Convenience methods for task support
218
+ def task_required!
219
+ self._task_support = :required
220
+ end
221
+
222
+ def task_optional!
223
+ self._task_support = :optional
224
+ end
225
+
226
+ def task_forbidden!
227
+ self._task_support = :forbidden
228
+ end
229
+
230
+ # Returns the execution metadata including task support
231
+ def execution_metadata
232
+ {
233
+ taskSupport: _task_support.to_s
234
+ }
235
+ end
236
+
237
+ # --------------------------------------------------------------------------
238
+ # Resumable Steps DSL (ActiveJob::Continuable support)
239
+ # --------------------------------------------------------------------------
240
+ # Defines resumable execution steps for long-running tools
241
+ # @param block [Proc] Block containing step definitions
242
+ # @return [void]
243
+ def resumable_steps(&block)
244
+ self._resumable_steps_block = block
245
+ end
246
+
247
+ # Checks if tool has resumable steps defined
248
+ # @return [Boolean]
249
+ def resumable_steps_defined?
250
+ _resumable_steps_block.present?
251
+ end
252
+
181
253
  # Sets or retrieves the additionalProperties setting for the input schema
182
254
  # @param enabled [Boolean, Hash] true to allow any additional properties,
183
255
  # false to disallow them, or a Hash for typed additional properties
@@ -322,6 +394,12 @@ module ActionMCP
322
394
  annotations = annotations_for_protocol(protocol_version)
323
395
  result[:annotations] = annotations if annotations.any?
324
396
 
397
+ # Add execution metadata (MCP 2025-11-25)
398
+ # Only include if not default (forbidden) to minimize payload
399
+ if _task_support && _task_support != :forbidden
400
+ result[:execution] = execution_metadata
401
+ end
402
+
325
403
  # Add _meta if present
326
404
  result[:_meta] = _meta if _meta.any?
327
405
 
@@ -438,6 +516,28 @@ module ActionMCP
438
516
  raise NotImplementedError, "Subclasses must implement the perform method"
439
517
  end
440
518
 
519
+ # Request user/client input and pause execution
520
+ # @param prompt [String] The prompt/question for the user
521
+ # @param context [Hash] Additional context about the input request
522
+ # @return [void]
523
+ def request_input!(prompt:, context: {})
524
+ @_input_required = { prompt: prompt, context: context }
525
+ throw :halt_execution # Use throw to exit perform block
526
+ end
527
+
528
+ # Report progress for long-running tool operations
529
+ # Only available when tool is running in task-augmented mode
530
+ # @param percent [Integer] Progress percentage (0-100)
531
+ # @param message [String] Optional progress message
532
+ # @param cursor [Integer, String] Optional cursor for iteration state
533
+ # @return [void]
534
+ def report_progress!(percent:, message: nil, cursor: nil)
535
+ return unless @_task # Only available in task-augmented mode
536
+
537
+ @_task.update_progress!(percent: percent, message: message)
538
+ @_task.record_step!(:in_progress, cursor: cursor) if cursor
539
+ end
540
+
441
541
  private
442
542
 
443
543
  # Helper method for tools to manually report errors
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.83.4"
5
+ VERSION = "0.90.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -34,12 +34,12 @@ module ActionMCP
34
34
 
35
35
  # Protocol version constants
36
36
  SUPPORTED_VERSIONS = [
37
- "2025-06-18", # Dr. Identity McBouncer - elicitation, structured output, resource links
38
- "2025-03-26" # The Persistent Negotiator - StreamableHTTP, resumability, audio support
37
+ "2025-11-25", # The Task Master - Tasks, icons, tool naming, polling SSE
38
+ "2025-06-18" # Dr. Identity McBouncer - elicitation, structured output, resource links
39
39
  ].freeze
40
40
 
41
41
  LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
42
- DEFAULT_PROTOCOL_VERSION = "2025-03-26" # Default to initial stable version for backwards compatibility
42
+ DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
43
43
  class << self
44
44
  # Returns a Rack-compatible application for serving MCP requests
45
45
  # This makes ActionMCP.server work similar to ActionCable.server
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.83.4
4
+ version: 0.90.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -9,20 +9,34 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: activerecord
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - ">="
17
31
  - !ruby/object:Gem::Version
18
- version: 8.0.4
32
+ version: 8.1.0
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
37
  - - ">="
24
38
  - !ruby/object:Gem::Version
25
- version: 8.0.4
39
+ version: 8.1.0
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: concurrent-ruby
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +85,14 @@ dependencies:
71
85
  requirements:
72
86
  - - ">="
73
87
  - !ruby/object:Gem::Version
74
- version: 8.0.4
88
+ version: 8.1.0
75
89
  type: :runtime
76
90
  prerelease: false
77
91
  version_requirements: !ruby/object:Gem::Requirement
78
92
  requirements:
79
93
  - - ">="
80
94
  - !ruby/object:Gem::Version
81
- version: 8.0.4
95
+ version: 8.1.0
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: zeitwerk
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +107,20 @@ dependencies:
93
107
  - - "~>"
94
108
  - !ruby/object:Gem::Version
95
109
  version: '2.6'
110
+ - !ruby/object:Gem::Dependency
111
+ name: state_machines-activerecord
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 0.100.0
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 0.100.0
96
124
  - !ruby/object:Gem::Dependency
97
125
  name: json_schemer
98
126
  requirement: !ruby/object:Gem::Requirement
@@ -121,6 +149,7 @@ files:
121
149
  - README.md
122
150
  - Rakefile
123
151
  - app/controllers/action_mcp/application_controller.rb
152
+ - app/jobs/action_mcp/tool_execution_job.rb
124
153
  - app/models/action_mcp.rb
125
154
  - app/models/action_mcp/application_record.rb
126
155
  - app/models/action_mcp/session.rb
@@ -128,12 +157,15 @@ files:
128
157
  - app/models/action_mcp/session/resource.rb
129
158
  - app/models/action_mcp/session/sse_event.rb
130
159
  - app/models/action_mcp/session/subscription.rb
160
+ - app/models/action_mcp/session/task.rb
131
161
  - app/models/concerns/action_mcp/mcp_console_helpers.rb
132
162
  - app/models/concerns/action_mcp/mcp_message_inspect.rb
133
163
  - config/routes.rb
134
164
  - db/migrate/20250512154359_consolidated_migration.rb
135
165
  - db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb
136
166
  - db/migrate/20250727000001_remove_oauth_support.rb
167
+ - db/migrate/20251125000001_create_action_mcp_session_tasks.rb
168
+ - db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb
137
169
  - exe/actionmcp_cli
138
170
  - lib/action_mcp.rb
139
171
  - lib/action_mcp/base_response.rb
@@ -223,6 +255,7 @@ files:
223
255
  - lib/action_mcp/server/handlers/prompt_handler.rb
224
256
  - lib/action_mcp/server/handlers/resource_handler.rb
225
257
  - lib/action_mcp/server/handlers/router.rb
258
+ - lib/action_mcp/server/handlers/task_handler.rb
226
259
  - lib/action_mcp/server/handlers/tool_handler.rb
227
260
  - lib/action_mcp/server/json_rpc_handler.rb
228
261
  - lib/action_mcp/server/messaging_service.rb
@@ -237,6 +270,7 @@ files:
237
270
  - lib/action_mcp/server/session_store_factory.rb
238
271
  - lib/action_mcp/server/simple_pub_sub.rb
239
272
  - lib/action_mcp/server/solid_mcp_adapter.rb
273
+ - lib/action_mcp/server/tasks.rb
240
274
  - lib/action_mcp/server/test_session_store.rb
241
275
  - lib/action_mcp/server/tools.rb
242
276
  - lib/action_mcp/server/transport_handler.rb