actionmcp 0.83.4 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/action_mcp/application_controller.rb +12 -222
- data/app/jobs/action_mcp/tool_execution_job.rb +133 -0
- data/app/models/action_mcp/session/task.rb +204 -0
- data/app/models/action_mcp/session.rb +2 -65
- data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +2 -0
- data/db/migrate/20251125000001_create_action_mcp_session_tasks.rb +29 -0
- data/db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb +10 -0
- data/db/migrate/20251203000001_remove_sse_support.rb +31 -0
- data/db/migrate/20251204000001_add_progress_to_session_tasks.rb +12 -0
- data/db/test.sqlite3 +0 -0
- data/exe/actionmcp_cli +1 -1
- data/lib/action_mcp/capability.rb +1 -0
- data/lib/action_mcp/client/base.rb +1 -1
- data/lib/action_mcp/configuration.rb +22 -15
- data/lib/action_mcp/engine.rb +8 -1
- data/lib/action_mcp/filtered_logger.rb +0 -3
- data/lib/action_mcp/json_rpc_handler_base.rb +10 -0
- data/lib/action_mcp/prompt.rb +16 -0
- data/lib/action_mcp/registry_base.rb +23 -1
- data/lib/action_mcp/server/base_session.rb +1 -71
- data/lib/action_mcp/server/handlers/router.rb +2 -0
- data/lib/action_mcp/server/handlers/task_handler.rb +86 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +3 -0
- data/lib/action_mcp/server/simple_pub_sub.rb +1 -1
- data/lib/action_mcp/server/solid_mcp_adapter.rb +1 -1
- data/lib/action_mcp/server/tasks.rb +125 -0
- data/lib/action_mcp/server/tools.rb +47 -1
- data/lib/action_mcp/server/transport_handler.rb +1 -0
- data/lib/action_mcp/tool.rb +100 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +3 -4
- data/lib/tasks/action_mcp_tasks.rake +1 -35
- metadata +42 -7
- data/app/models/action_mcp/session/sse_event.rb +0 -62
- data/lib/action_mcp/sse_listener.rb +0 -81
|
@@ -73,8 +73,8 @@ module ActionMCP
|
|
|
73
73
|
dependent: :delete_all,
|
|
74
74
|
inverse_of: :session
|
|
75
75
|
|
|
76
|
-
has_many :
|
|
77
|
-
class_name: "ActionMCP::Session::
|
|
76
|
+
has_many :tasks,
|
|
77
|
+
class_name: "ActionMCP::Session::Task",
|
|
78
78
|
foreign_key: "session_id",
|
|
79
79
|
dependent: :delete_all,
|
|
80
80
|
inverse_of: :session
|
|
@@ -178,57 +178,6 @@ module ActionMCP
|
|
|
178
178
|
subscriptions.find_by(uri: uri)&.destroy
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
# Atomically increments the SSE event counter and returns the new value.
|
|
182
|
-
# This ensures unique, sequential IDs for SSE events within the session.
|
|
183
|
-
# @return [Integer] The new value of the counter.
|
|
184
|
-
def increment_sse_counter!
|
|
185
|
-
# Use update_counters for an atomic increment operation
|
|
186
|
-
self.class.update_counters(id, sse_event_counter: 1)
|
|
187
|
-
# Reload to get the updated value (update_counters doesn't update the instance)
|
|
188
|
-
reload.sse_event_counter
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Stores an SSE event for potential resumption
|
|
192
|
-
# @param event_id [Integer] The event ID
|
|
193
|
-
# @param data [Hash, String] The event data
|
|
194
|
-
# @param max_events [Integer] Maximum number of events to store (oldest events are removed when exceeded)
|
|
195
|
-
# @return [ActionMCP::Session::SSEEvent] The created event
|
|
196
|
-
def store_sse_event(event_id, data, max_events = 100)
|
|
197
|
-
# Create the SSE event record
|
|
198
|
-
event = sse_events.create!(
|
|
199
|
-
event_id: event_id,
|
|
200
|
-
data: data
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
# Maintain cache limit by removing oldest events if needed
|
|
204
|
-
count = sse_events.count
|
|
205
|
-
excess = count - max_events
|
|
206
|
-
sse_events.order(event_id: :asc).limit(excess).delete_all if excess.positive?
|
|
207
|
-
|
|
208
|
-
event
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Retrieves SSE events after a given ID
|
|
212
|
-
# @param last_event_id [Integer] The ID to retrieve events after
|
|
213
|
-
# @param limit [Integer] Maximum number of events to return
|
|
214
|
-
# @return [Array<ActionMCP::Session::SSEEvent>] The events
|
|
215
|
-
def get_sse_events_after(last_event_id, limit = 50)
|
|
216
|
-
sse_events.where("event_id > ?", last_event_id)
|
|
217
|
-
.order(event_id: :asc)
|
|
218
|
-
.limit(limit)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Cleans up old SSE events
|
|
222
|
-
# @param max_age [ActiveSupport::Duration] Maximum age of events to keep
|
|
223
|
-
# @return [Integer] Number of events removed
|
|
224
|
-
def cleanup_old_sse_events(max_age = 15.minutes)
|
|
225
|
-
cutoff_time = Time.current - max_age
|
|
226
|
-
events_to_delete = sse_events.where("created_at < ?", cutoff_time)
|
|
227
|
-
count = events_to_delete.count
|
|
228
|
-
events_to_delete.destroy_all
|
|
229
|
-
count
|
|
230
|
-
end
|
|
231
|
-
|
|
232
181
|
def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
|
|
233
182
|
# Create a transport handler to send the notification
|
|
234
183
|
handler = ActionMCP::Server::TransportHandler.new(self)
|
|
@@ -240,18 +189,6 @@ module ActionMCP
|
|
|
240
189
|
)
|
|
241
190
|
end
|
|
242
191
|
|
|
243
|
-
# Calculates the retention period for SSE events based on configuration
|
|
244
|
-
# @return [ActiveSupport::Duration] The retention period
|
|
245
|
-
def sse_event_retention_period
|
|
246
|
-
ActionMCP.configuration.sse_event_retention_period || 15.minutes
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Calculates the maximum number of SSE events to store based on configuration
|
|
250
|
-
# @return [Integer] The maximum number of events
|
|
251
|
-
def max_stored_sse_events
|
|
252
|
-
ActionMCP.configuration.max_stored_sse_events || 100
|
|
253
|
-
end
|
|
254
|
-
|
|
255
192
|
def send_progress_notification_legacy(token:, value:, message: nil)
|
|
256
193
|
send_progress_notification(progressToken: token, progress: value, message: message)
|
|
257
194
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RemoveSseSupport < ActiveRecord::Migration[8.1]
|
|
4
|
+
def up
|
|
5
|
+
# Drop SSE events table
|
|
6
|
+
drop_table :action_mcp_sse_events, if_exists: true
|
|
7
|
+
|
|
8
|
+
# Remove SSE counter from sessions
|
|
9
|
+
remove_column :action_mcp_sessions, :sse_event_counter, if_exists: true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
# Re-add sse_event_counter to sessions
|
|
14
|
+
unless column_exists?(:action_mcp_sessions, :sse_event_counter)
|
|
15
|
+
add_column :action_mcp_sessions, :sse_event_counter, :integer, default: 0, null: false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Re-create SSE events table
|
|
19
|
+
unless table_exists?(:action_mcp_sse_events)
|
|
20
|
+
create_table :action_mcp_sse_events do |t|
|
|
21
|
+
t.references :session, null: false, type: :string, foreign_key: { to_table: :action_mcp_sessions }
|
|
22
|
+
t.integer :event_id, null: false
|
|
23
|
+
t.text :data, null: false
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :action_mcp_sse_events, :created_at
|
|
28
|
+
add_index :action_mcp_sse_events, [ :session_id, :event_id ], unique: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddProgressToSessionTasks < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
unless column_exists?(:action_mcp_session_tasks, :progress_percent)
|
|
6
|
+
add_column :action_mcp_session_tasks, :progress_percent, :integer, comment: "Task progress as percentage 0-100"
|
|
7
|
+
end
|
|
8
|
+
unless column_exists?(:action_mcp_session_tasks, :progress_message)
|
|
9
|
+
add_column :action_mcp_session_tasks, :progress_message, :string, comment: "Human-readable progress message"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/db/test.sqlite3
ADDED
|
File without changes
|
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
|
|
|
@@ -30,12 +30,7 @@ module ActionMCP
|
|
|
30
30
|
# --- Authentication Options ---
|
|
31
31
|
:authentication_methods,
|
|
32
32
|
# --- Transport Options ---
|
|
33
|
-
:sse_heartbeat_interval,
|
|
34
|
-
:post_response_preference, # :json or :sse
|
|
35
33
|
:protocol_version,
|
|
36
|
-
# --- SSE Resumability Options ---
|
|
37
|
-
:sse_event_retention_period,
|
|
38
|
-
:max_stored_sse_events,
|
|
39
34
|
# --- Gateway Options ---
|
|
40
35
|
:gateway_class,
|
|
41
36
|
# --- Session Store Options ---
|
|
@@ -48,7 +43,11 @@ module ActionMCP
|
|
|
48
43
|
:max_threads,
|
|
49
44
|
:max_queue,
|
|
50
45
|
:polling_interval,
|
|
51
|
-
:connects_to
|
|
46
|
+
:connects_to,
|
|
47
|
+
# --- Tasks Options (MCP 2025-11-25) ---
|
|
48
|
+
:tasks_enabled,
|
|
49
|
+
:tasks_list_enabled,
|
|
50
|
+
:tasks_cancel_enabled
|
|
52
51
|
|
|
53
52
|
def initialize
|
|
54
53
|
@logging_enabled = false
|
|
@@ -63,13 +62,12 @@ module ActionMCP
|
|
|
63
62
|
# Authentication defaults - empty means all configured identifiers will be tried
|
|
64
63
|
@authentication_methods = []
|
|
65
64
|
|
|
66
|
-
@
|
|
67
|
-
@post_response_preference = :json
|
|
68
|
-
@protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
|
|
65
|
+
@protocol_version = "2025-06-18" # Default to stable version for backwards compatibility
|
|
69
66
|
|
|
70
|
-
#
|
|
71
|
-
@
|
|
72
|
-
@
|
|
67
|
+
# Tasks defaults (MCP 2025-11-25)
|
|
68
|
+
@tasks_enabled = false
|
|
69
|
+
@tasks_list_enabled = true
|
|
70
|
+
@tasks_cancel_enabled = true
|
|
73
71
|
|
|
74
72
|
# Gateway - resolved lazily to account for Zeitwerk autoloading
|
|
75
73
|
@gateway_class_name = nil
|
|
@@ -205,9 +203,6 @@ module ActionMCP
|
|
|
205
203
|
capabilities = {}
|
|
206
204
|
profile = @profiles[active_profile]
|
|
207
205
|
|
|
208
|
-
Rails.logger.debug "[ActionMCP] Generating capabilities for profile: #{active_profile}"
|
|
209
|
-
Rails.logger.debug "[ActionMCP] Profile config: #{profile.inspect}"
|
|
210
|
-
|
|
211
206
|
# Check profile configuration instead of registry contents
|
|
212
207
|
# If profile includes tools (either "all" or specific tools), advertise tools capability
|
|
213
208
|
capabilities[:tools] = { listChanged: @list_changed } if profile && profile[:tools]&.any?
|
|
@@ -224,6 +219,18 @@ module ActionMCP
|
|
|
224
219
|
|
|
225
220
|
capabilities[:elicitation] = {} if @elicitation_enabled
|
|
226
221
|
|
|
222
|
+
# Tasks capability (MCP 2025-11-25)
|
|
223
|
+
if @tasks_enabled
|
|
224
|
+
tasks_cap = {
|
|
225
|
+
requests: {
|
|
226
|
+
tools: { call: {} }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
tasks_cap[:list] = {} if @tasks_list_enabled
|
|
230
|
+
tasks_cap[:cancel] = {} if @tasks_cancel_enabled
|
|
231
|
+
capabilities[:tasks] = tasks_cap
|
|
232
|
+
end
|
|
233
|
+
|
|
227
234
|
capabilities
|
|
228
235
|
end
|
|
229
236
|
|
data/lib/action_mcp/engine.rb
CHANGED
|
@@ -10,7 +10,6 @@ module ActionMCP
|
|
|
10
10
|
isolate_namespace ActionMCP
|
|
11
11
|
|
|
12
12
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
13
|
-
inflect.acronym "SSE"
|
|
14
13
|
inflect.acronym "MCP"
|
|
15
14
|
end
|
|
16
15
|
|
|
@@ -61,6 +60,14 @@ module ActionMCP
|
|
|
61
60
|
|
|
62
61
|
# Configure autoloading for the mcp/tools directory and identifiers
|
|
63
62
|
initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
|
|
63
|
+
# Ensure ActionMCP base constants exist before Zeitwerk indexes app/mcp
|
|
64
|
+
# This prevents NameError when dependent gems have app/mcp
|
|
65
|
+
# directories with classes inheriting from ActionMCP::Tool, etc.
|
|
66
|
+
require "action_mcp/tool"
|
|
67
|
+
require "action_mcp/prompt"
|
|
68
|
+
require "action_mcp/resource_template"
|
|
69
|
+
require "action_mcp/gateway"
|
|
70
|
+
|
|
64
71
|
mcp_path = app.root.join("app/mcp")
|
|
65
72
|
identifiers_path = app.root.join("app/identifiers")
|
|
66
73
|
|
|
@@ -17,9 +17,6 @@ module ActionMCP
|
|
|
17
17
|
|
|
18
18
|
# Filter out repetitive MCP notifications
|
|
19
19
|
return if FILTERED_METHODS.any? { |method| message.include?(method) }
|
|
20
|
-
|
|
21
|
-
# Filter out MCP protocol version debug messages
|
|
22
|
-
return if message.include?("MCP-Protocol-Version header validation passed")
|
|
23
20
|
end
|
|
24
21
|
|
|
25
22
|
super
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@ module ActionMCP
|
|
|
5
5
|
# Base session object that mimics ActiveRecord Session with common functionality
|
|
6
6
|
class BaseSession
|
|
7
7
|
attr_accessor :id, :status, :initialized, :role, :messages_count,
|
|
8
|
-
:
|
|
8
|
+
:protocol_version, :client_info,
|
|
9
9
|
:client_capabilities, :server_info, :server_capabilities,
|
|
10
10
|
:tool_registry, :prompt_registry, :resource_registry,
|
|
11
11
|
:created_at, :updated_at, :ended_at, :last_event_id,
|
|
@@ -16,8 +16,6 @@ module ActionMCP
|
|
|
16
16
|
@messages = Concurrent::Array.new
|
|
17
17
|
@subscriptions = Concurrent::Array.new
|
|
18
18
|
@resources = Concurrent::Array.new
|
|
19
|
-
@sse_events = Concurrent::Array.new
|
|
20
|
-
@sse_counter = Concurrent::AtomicFixnum.new(0)
|
|
21
19
|
@message_counter = Concurrent::AtomicFixnum.new(0)
|
|
22
20
|
@new_record = true
|
|
23
21
|
|
|
@@ -119,47 +117,6 @@ module ActionMCP
|
|
|
119
117
|
ResourceCollection.new(@resources)
|
|
120
118
|
end
|
|
121
119
|
|
|
122
|
-
def sse_events
|
|
123
|
-
SSEEventCollection.new(@sse_events)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# SSE event management
|
|
127
|
-
def increment_sse_counter!
|
|
128
|
-
new_value = @sse_counter.increment
|
|
129
|
-
self.sse_event_counter = new_value
|
|
130
|
-
save
|
|
131
|
-
new_value
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def store_sse_event(event_id, data, max_events = nil)
|
|
135
|
-
max_events ||= max_stored_sse_events
|
|
136
|
-
event = { event_id: event_id, data: data, created_at: Time.current }
|
|
137
|
-
@sse_events << event
|
|
138
|
-
|
|
139
|
-
@sse_events.shift while @sse_events.size > max_events
|
|
140
|
-
|
|
141
|
-
event
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def get_sse_events_after(last_event_id, limit = 50)
|
|
145
|
-
@sse_events.select { |e| e[:event_id] > last_event_id }.first(limit)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def cleanup_old_sse_events(max_age = 15.minutes)
|
|
149
|
-
cutoff_time = Time.current - max_age
|
|
150
|
-
original_size = @sse_events.size
|
|
151
|
-
@sse_events.delete_if { |e| e[:created_at] < cutoff_time }
|
|
152
|
-
original_size - @sse_events.size
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def max_stored_sse_events
|
|
156
|
-
ActionMCP.configuration.max_stored_sse_events || 100
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def sse_event_retention_period
|
|
160
|
-
ActionMCP.configuration.sse_event_retention_period || 15.minutes
|
|
161
|
-
end
|
|
162
|
-
|
|
163
120
|
# Adapter methods
|
|
164
121
|
def adapter
|
|
165
122
|
ActionMCP::Server.server.pubsub
|
|
@@ -418,33 +375,6 @@ module ActionMCP
|
|
|
418
375
|
|
|
419
376
|
class ResourceCollection < Array
|
|
420
377
|
end
|
|
421
|
-
|
|
422
|
-
class SSEEventCollection < Array
|
|
423
|
-
def create!(attributes)
|
|
424
|
-
self << attributes
|
|
425
|
-
attributes
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
def count
|
|
429
|
-
size
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def where(_condition, value)
|
|
433
|
-
select { |e| e[:event_id] > value }
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def order(field)
|
|
437
|
-
sort_by { |e| e[field.is_a?(Hash) ? field.keys.first : field] }
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
def limit(n)
|
|
441
|
-
first(n)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
def delete_all
|
|
445
|
-
clear
|
|
446
|
-
end
|
|
447
|
-
end
|
|
448
378
|
end
|
|
449
379
|
end
|
|
450
380
|
end
|
|
@@ -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] = {
|