actionmcp 0.83.4 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/action_mcp/application_controller.rb +12 -222
  3. data/app/jobs/action_mcp/tool_execution_job.rb +133 -0
  4. data/app/models/action_mcp/session/task.rb +204 -0
  5. data/app/models/action_mcp/session.rb +2 -65
  6. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +2 -0
  7. data/db/migrate/20251125000001_create_action_mcp_session_tasks.rb +29 -0
  8. data/db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb +10 -0
  9. data/db/migrate/20251203000001_remove_sse_support.rb +31 -0
  10. data/db/migrate/20251204000001_add_progress_to_session_tasks.rb +12 -0
  11. data/db/test.sqlite3 +0 -0
  12. data/exe/actionmcp_cli +1 -1
  13. data/lib/action_mcp/capability.rb +1 -0
  14. data/lib/action_mcp/client/base.rb +1 -1
  15. data/lib/action_mcp/configuration.rb +22 -15
  16. data/lib/action_mcp/engine.rb +8 -1
  17. data/lib/action_mcp/filtered_logger.rb +0 -3
  18. data/lib/action_mcp/json_rpc_handler_base.rb +10 -0
  19. data/lib/action_mcp/prompt.rb +16 -0
  20. data/lib/action_mcp/registry_base.rb +23 -1
  21. data/lib/action_mcp/server/base_session.rb +1 -71
  22. data/lib/action_mcp/server/handlers/router.rb +2 -0
  23. data/lib/action_mcp/server/handlers/task_handler.rb +86 -0
  24. data/lib/action_mcp/server/json_rpc_handler.rb +3 -0
  25. data/lib/action_mcp/server/simple_pub_sub.rb +1 -1
  26. data/lib/action_mcp/server/solid_mcp_adapter.rb +1 -1
  27. data/lib/action_mcp/server/tasks.rb +125 -0
  28. data/lib/action_mcp/server/tools.rb +47 -1
  29. data/lib/action_mcp/server/transport_handler.rb +1 -0
  30. data/lib/action_mcp/tool.rb +100 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/action_mcp.rb +3 -4
  33. data/lib/tasks/action_mcp_tasks.rake +1 -35
  34. metadata +42 -7
  35. data/app/models/action_mcp/session/sse_event.rb +0 -62
  36. data/lib/action_mcp/sse_listener.rb +0 -81
@@ -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.100.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -25,7 +25,6 @@ Zeitwerk::Loader.for_gem.tap do |loader|
25
25
  )
26
26
 
27
27
  loader.inflector.inflect("action_mcp" => "ActionMCP")
28
- loader.inflector.inflect("sse_listener" => "SSEListener")
29
28
  end.setup
30
29
 
31
30
  module ActionMCP
@@ -34,12 +33,12 @@ module ActionMCP
34
33
 
35
34
  # Protocol version constants
36
35
  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
36
+ "2025-11-25", # The Task Master - Tasks, icons, tool naming, polling SSE
37
+ "2025-06-18" # Dr. Identity McBouncer - elicitation, structured output, resource links
39
38
  ].freeze
40
39
 
41
40
  LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
42
- DEFAULT_PROTOCOL_VERSION = "2025-03-26" # Default to initial stable version for backwards compatibility
41
+ DEFAULT_PROTOCOL_VERSION = "2025-06-18" # Default to previous stable version for backwards compatibility
43
42
  class << self
44
43
  # Returns a Rack-compatible application for serving MCP requests
45
44
  # This makes ActionMCP.server work similar to ActionCable.server
@@ -179,10 +179,7 @@ namespace :action_mcp do
179
179
 
180
180
  # Transport Configuration
181
181
  puts "\n\e[36mTransport Configuration:\e[0m"
182
- puts " SSE Heartbeat Interval: #{config.sse_heartbeat_interval}s"
183
- puts " Post Response Preference: #{config.post_response_preference}"
184
- puts " SSE Event Retention Period: #{config.sse_event_retention_period}"
185
- puts " Max Stored SSE Events: #{config.max_stored_sse_events}"
182
+ puts " Protocol Version: #{config.protocol_version}"
186
183
 
187
184
  # Pub/Sub Adapter
188
185
  puts "\n\e[36mPub/Sub Adapter:\e[0m"
@@ -332,37 +329,6 @@ namespace :action_mcp do
332
329
  puts " Error accessing message data: #{e.message}"
333
330
  end
334
331
 
335
- # SSE Event Statistics (if table exists)
336
- puts "\n\e[36mSSE Event Statistics:\e[0m"
337
-
338
- begin
339
- if ActionMCP::ApplicationRecord.connection.table_exists?("action_mcp_sse_events")
340
- total_events = ActionMCP::Session::SSEEvent.count
341
- puts " Total SSE Events: #{total_events}"
342
-
343
- if total_events.positive?
344
- # Recent events
345
- recent_events = ActionMCP::Session::SSEEvent.where("created_at > ?", 1.hour.ago).count
346
- puts " SSE Events (Last Hour): #{recent_events}"
347
-
348
- # Events by session
349
- events_by_session = ActionMCP::Session::SSEEvent.joins(:session)
350
- .group("action_mcp_sessions.id")
351
- .count
352
- .sort_by { |_session_id, count| -count }
353
- .first(5)
354
- puts " Top Sessions by SSE Events:"
355
- events_by_session.each do |session_id, count|
356
- puts " #{session_id}: #{count} events"
357
- end
358
- end
359
- else
360
- puts " SSE Events table not found"
361
- end
362
- rescue StandardError => e
363
- puts " Error accessing SSE event data: #{e.message}"
364
- end
365
-
366
332
  # Storage Information
367
333
  puts "\n\e[36mStorage Information:\e[0m"
368
334
  puts " Session Store Type: #{ActionMCP.configuration.session_store_type}"
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.100.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,19 +149,25 @@ 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
127
156
  - app/models/action_mcp/session/message.rb
128
157
  - app/models/action_mcp/session/resource.rb
129
- - app/models/action_mcp/session/sse_event.rb
130
158
  - app/models/action_mcp/session/subscription.rb
159
+ - app/models/action_mcp/session/task.rb
131
160
  - app/models/concerns/action_mcp/mcp_console_helpers.rb
132
161
  - app/models/concerns/action_mcp/mcp_message_inspect.rb
133
162
  - config/routes.rb
134
163
  - db/migrate/20250512154359_consolidated_migration.rb
135
164
  - db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb
136
165
  - db/migrate/20250727000001_remove_oauth_support.rb
166
+ - db/migrate/20251125000001_create_action_mcp_session_tasks.rb
167
+ - db/migrate/20251126000001_add_continuation_state_to_action_mcp_session_tasks.rb
168
+ - db/migrate/20251203000001_remove_sse_support.rb
169
+ - db/migrate/20251204000001_add_progress_to_session_tasks.rb
170
+ - db/test.sqlite3
137
171
  - exe/actionmcp_cli
138
172
  - lib/action_mcp.rb
139
173
  - lib/action_mcp/base_response.rb
@@ -223,6 +257,7 @@ files:
223
257
  - lib/action_mcp/server/handlers/prompt_handler.rb
224
258
  - lib/action_mcp/server/handlers/resource_handler.rb
225
259
  - lib/action_mcp/server/handlers/router.rb
260
+ - lib/action_mcp/server/handlers/task_handler.rb
226
261
  - lib/action_mcp/server/handlers/tool_handler.rb
227
262
  - lib/action_mcp/server/json_rpc_handler.rb
228
263
  - lib/action_mcp/server/messaging_service.rb
@@ -237,11 +272,11 @@ files:
237
272
  - lib/action_mcp/server/session_store_factory.rb
238
273
  - lib/action_mcp/server/simple_pub_sub.rb
239
274
  - lib/action_mcp/server/solid_mcp_adapter.rb
275
+ - lib/action_mcp/server/tasks.rb
240
276
  - lib/action_mcp/server/test_session_store.rb
241
277
  - lib/action_mcp/server/tools.rb
242
278
  - lib/action_mcp/server/transport_handler.rb
243
279
  - lib/action_mcp/server/volatile_session_store.rb
244
- - lib/action_mcp/sse_listener.rb
245
280
  - lib/action_mcp/string_array.rb
246
281
  - lib/action_mcp/tagged_stream_logging.rb
247
282
  - lib/action_mcp/test_helper.rb
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # <rails-lens:schema:begin>
4
- # table = "action_mcp_sse_events"
5
- # database_dialect = "SQLite"
6
- #
7
- # columns = [
8
- # { name = "id", type = "integer", primary_key = true, nullable = false },
9
- # { name = "session_id", type = "string", nullable = false },
10
- # { name = "event_id", type = "integer", nullable = false },
11
- # { name = "data", type = "text", nullable = false },
12
- # { name = "created_at", type = "datetime", nullable = false },
13
- # { name = "updated_at", type = "datetime", nullable = false }
14
- # ]
15
- #
16
- # indexes = [
17
- # { name = "index_action_mcp_sse_events_on_created_at", columns = ["created_at"] },
18
- # { name = "index_action_mcp_sse_events_on_session_id_and_event_id", columns = ["session_id", "event_id"], unique = true },
19
- # { name = "index_action_mcp_sse_events_on_session_id", columns = ["session_id"] }
20
- # ]
21
- #
22
- # foreign_keys = [
23
- # { column = "session_id", references_table = "action_mcp_sessions", references_column = "id" }
24
- # ]
25
- #
26
- # == Notes
27
- # - Association 'session' should specify inverse_of
28
- # - String column 'session_id' has no length limit - consider adding one
29
- # <rails-lens:schema:end>
30
- module ActionMCP
31
- class Session
32
- # Represents a Server-Sent Event (SSE) in an MCP session
33
- # These events are stored for potential resumption when a client reconnects
34
- class SSEEvent < ApplicationRecord
35
- self.table_name = "action_mcp_sse_events"
36
-
37
- belongs_to :session, class_name: "ActionMCP::Session"
38
-
39
- # Validations
40
- validates :event_id, presence: true, numericality: { only_integer: true, greater_than: 0 }
41
- validates :data, presence: true
42
-
43
- # Scopes
44
- scope :recent, -> { order(event_id: :desc) }
45
- scope :after_id, ->(id) { where("event_id > ?", id) }
46
- scope :before, ->(time) { where("created_at < ?", time) }
47
-
48
- # Serializes the data as JSON if it's not already a string
49
- def data_for_stream
50
- return data if data.is_a?(String)
51
-
52
- data.is_a?(Hash) ? data.to_json : data.to_s
53
- end
54
-
55
- # Generates the SSE formatted event string
56
- # @return [String] The formatted SSE event
57
- def to_sse
58
- "id: #{event_id}\ndata: #{data_for_stream}\n\n"
59
- end
60
- end
61
- end
62
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "concurrent/atomic/atomic_boolean"
4
- require "concurrent/promise"
5
-
6
- module ActionMCP
7
- # Listener class to subscribe to session messages via PubSub adapter.
8
- class SSEListener
9
- delegate :session_key, :adapter, to: :@session
10
-
11
- # @param session [ActionMCP::Session]
12
- def initialize(session)
13
- @session = session
14
- @stopped = Concurrent::AtomicBoolean.new
15
- @subscription_active = Concurrent::AtomicBoolean.new
16
- end
17
-
18
- # Start listening using PubSub adapter
19
- # @yield [Hash] Yields parsed message received from the pub/sub channel
20
- # @return [Boolean] True if subscription was successful within timeout, false otherwise.
21
- def start(&callback)
22
- success_callback = lambda {
23
- @subscription_active.make_true
24
- }
25
-
26
- message_callback = lambda { |raw_message|
27
- process_message(raw_message, callback)
28
- }
29
-
30
- # Subscribe using the PubSub adapter
31
- adapter.subscribe(session_key, message_callback, success_callback)
32
-
33
- wait_for_subscription
34
- end
35
-
36
- # Stops the listener
37
- def stop
38
- return if @stopped.true?
39
-
40
- @stopped.make_true
41
- end
42
-
43
- private
44
-
45
- def process_message(raw_message, callback)
46
- return if @stopped.true?
47
-
48
- begin
49
- # Check if the message is a valid JSON string or has a message attribute
50
- if raw_message.is_a?(String) && valid_json_format?(raw_message)
51
- message = MultiJson.load(raw_message)
52
- callback&.call(message)
53
- end
54
- rescue StandardError => e
55
- Rails.logger.error "SSEListener: Error processing message: #{e.message}"
56
- Rails.logger.error "SSEListener: Backtrace: #{e.backtrace.join("\n")}"
57
- end
58
- end
59
-
60
- def valid_json_format?(string)
61
- return false if string.blank?
62
-
63
- string = string.strip
64
- (string.start_with?("{") && string.end_with?("}")) ||
65
- (string.start_with?("[") && string.end_with?("]"))
66
- end
67
-
68
- def wait_for_subscription
69
- subscription_future = Concurrent::Promises.future do
70
- sleep 0.1 while !@subscription_active.true? && !@stopped.true?
71
- @subscription_active.true?
72
- end
73
-
74
- begin
75
- subscription_future.value(5) || @subscription_active.true?
76
- rescue Concurrent::TimeoutError
77
- false
78
- end
79
- end
80
- end
81
- end