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 +4 -4
- data/app/controllers/action_mcp/application_controller.rb +2 -2
- 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 +6 -0
- 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/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 +23 -2
- data/lib/action_mcp/engine.rb +8 -0
- 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/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 -3
- metadata +39 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4e55c16fbbaf08a18d98c152d72d152a97170ac81e9f6126b24663f7f53ed14
|
|
4
|
+
data.tar.gz: 11e2f3cd710ee43e7ef868197bed0221d50da3ea513764a05856afb3334db8fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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-
|
|
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 }) }
|
|
@@ -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
|
@@ -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.
|
|
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-
|
|
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
|
|
data/lib/action_mcp/engine.rb
CHANGED
|
@@ -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
|
data/lib/action_mcp/prompt.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) }
|
data/lib/action_mcp/tool.rb
CHANGED
|
@@ -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
|
data/lib/action_mcp/version.rb
CHANGED
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-
|
|
38
|
-
"2025-
|
|
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-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|