actionmcp 0.90.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4e55c16fbbaf08a18d98c152d72d152a97170ac81e9f6126b24663f7f53ed14
4
- data.tar.gz: 11e2f3cd710ee43e7ef868197bed0221d50da3ea513764a05856afb3334db8fb
3
+ metadata.gz: 9b01884f2ba4ded4a59d6c235583edbd00dae36da78215da26a1fbf83039838f
4
+ data.tar.gz: c97745755f33ece7ce05a4a2a904cde23e3fe67c3f1ecf9ee0cc962e6bdc9177
5
5
  SHA512:
6
- metadata.gz: 4eeca2ab09bb4ee053d575d610fd6f1a236ee1038c008fad9f90b9aa0f78c339e21ceaecdf499d13c885e006e89ea224a74fc326e5d9b3cc091434c17733d50f
7
- data.tar.gz: e1c15290f9a862dfb3d37cd8d077b9c3312370dd2e6e12f7bf57c661d5de20a682d1ca7c59e6149bdc94d543a2151cfcc576ede94afab07accdd8fb8ce82bb59
6
+ metadata.gz: 545540e721a557860d9263873c841fd703afd1ea9bed05ea489b738c5a81594cc6f560fdf3959d6d9ff992b3e4b247eb8efead8f5f234946429903b00ce06a96
7
+ data.tar.gz: 8c4a3d2abe05b4f65b26e6dfdd751350973e029341c8ed8bb1e3f880a046477afd59ce236a71d6e607dd82ab3220eaac1c3ee462c7c8861c1ec79a814eaa42ca
@@ -9,7 +9,6 @@ module ActionMCP
9
9
 
10
10
  include Engine.routes.url_helpers
11
11
  include JSONRPC_Rails::ControllerHelpers
12
- include ActionController::Live
13
12
  include ActionController::Instrumentation
14
13
 
15
14
  # Provides the ActionMCP::Session for the current request.
@@ -27,136 +26,15 @@ module ActionMCP
27
26
  @session_key ||= "action_mcp-sessions-#{mcp_session.id}"
28
27
  end
29
28
 
30
- # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
29
+ # Handles GET requests - returns 405 Method Not Allowed as per MCP spec.
30
+ # SSE streaming is not supported. Clients should use Tasks for async operations.
31
31
  # <rails-lens:routes:begin>
32
32
  # ROUTE: /, name: mcp_get, via: GET
33
33
  # <rails-lens:routes:end>
34
34
  def show
35
- unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
36
- return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
37
- end
38
-
39
- session_id_from_header = extract_session_id
40
- return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
41
-
42
- session = mcp_session
43
- if session.nil? || session.new_record?
44
- return render_not_found("Session not found.")
45
- elsif !session.initialized?
46
- return render_bad_request("Session is not fully initialized.")
47
- elsif session.status == "closed"
48
- return render_not_found("Session has been terminated.")
49
- end
50
-
51
- # Authenticate the request via gateway
52
- authenticate_gateway!
53
- return if performed?
54
-
55
- last_event_id = request.headers["Last-Event-ID"].presence
56
- if last_event_id && ActionMCP.configuration.verbose_logging
57
- Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}"
58
- end
59
-
60
- response.headers["Content-Type"] = "text/event-stream"
61
- response.headers["X-Accel-Buffering"] = "no"
62
- response.headers["Cache-Control"] = "no-cache"
63
- response.headers["Connection"] = "keep-alive"
64
- # Add MCP-Protocol-Version header for established sessions
65
- response.headers["MCP-Protocol-Version"] = session.protocol_version
66
-
67
- if ActionMCP.configuration.verbose_logging
68
- Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
69
- end
70
-
71
- sse = SSE.new(response.stream)
72
- listener = SSEListener.new(session)
73
- connection_active = Concurrent::AtomicBoolean.new
74
- connection_active.make_true
75
- heartbeat_active = Concurrent::AtomicBoolean.new
76
- heartbeat_active.make_true
77
- heartbeat_task = nil
78
-
79
- listener_started = listener.start do |message|
80
- write_sse_event(sse, session, message)
81
- end
82
-
83
- unless listener_started
84
- Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
85
- connection_active.make_false
86
- return
87
- end
88
-
89
- if last_event_id.present? && last_event_id.to_i.positive?
90
- begin
91
- missed_events = session.get_sse_events_after(last_event_id.to_i)
92
- if missed_events.any?
93
- if ActionMCP.configuration.verbose_logging
94
- Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
95
- end
96
- missed_events.each do |event|
97
- sse.write(event.to_sse)
98
- end
99
- elsif ActionMCP.configuration.verbose_logging
100
- if ActionMCP.configuration.verbose_logging
101
- Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
102
- end
103
- end
104
- rescue StandardError => e
105
- Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
106
- end
107
- end
108
-
109
- heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
110
- heartbeat_sender = lambda do
111
- if connection_active.true? && !response.stream.closed?
112
- begin
113
- # Send a proper JSON-RPC notification for heartbeat
114
- ping_notification = {
115
- jsonrpc: "2.0",
116
- method: "notifications/ping",
117
- params: {}
118
- }
119
- future = Concurrent::Promises.future { write_sse_event(sse, session, ping_notification) }
120
- future.value!(5)
121
- if heartbeat_active.true?
122
- heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
123
- end
124
- rescue Concurrent::TimeoutError
125
- Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
126
- connection_active.make_false
127
- rescue StandardError => e
128
- if ActionMCP.configuration.verbose_logging
129
- Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}"
130
- end
131
- connection_active.make_false
132
- end
133
- else
134
- heartbeat_active.make_false
135
- end
136
- end
137
-
138
- heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
139
- sleep 0.1 while connection_active.true? && !response.stream.closed?
140
- rescue ActionController::Live::ClientDisconnected, IOError => e
141
- if ActionMCP.configuration.verbose_logging
142
- Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
143
- end
144
- rescue StandardError => e
145
- Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
146
- ensure
147
- if ActionMCP.configuration.verbose_logging
148
- Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
149
- end
150
- heartbeat_active&.make_false
151
- heartbeat_task&.cancel
152
- listener&.stop
153
- cleanup_old_sse_events(session) if session
154
- sse&.close
155
- begin
156
- response.stream&.close
157
- rescue StandardError
158
- nil
159
- end
35
+ # MCP Streamable HTTP spec allows servers to return 405 if they don't support SSE.
36
+ # ActionMCP uses Tasks for async operations instead of SSE streaming.
37
+ head :method_not_allowed
160
38
  end
161
39
 
162
40
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
@@ -211,15 +89,6 @@ module ActionMCP
211
89
 
212
90
  result = json_rpc_handler.call(jsonrpc_params)
213
91
  process_handler_results(result, session, session_initially_missing, is_initialize_request)
214
- rescue ActionController::Live::ClientDisconnected, IOError => e
215
- if ActionMCP.configuration.verbose_logging
216
- Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
217
- end
218
- begin
219
- response.stream&.close
220
- rescue StandardError
221
- nil
222
- end
223
92
  rescue StandardError => e
224
93
  Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
225
94
  id = begin
@@ -298,7 +167,6 @@ module ActionMCP
298
167
  end
299
168
  end
300
169
 
301
- ActionMCP.logger.debug "MCP-Protocol-Version header validation passed: #{header_version}"
302
170
  true
303
171
  end
304
172
 
@@ -320,28 +188,14 @@ module ActionMCP
320
188
  request.headers[MCP_SESSION_ID_HEADER].presence
321
189
  end
322
190
 
323
- # Checks if the client's Accept header includes the required types.
324
- def accepts_valid_content_types?
325
- request.accepts.any? { |type| type.to_s == "application/json" } &&
326
- request.accepts.any? { |type| type.to_s == "text/event-stream" }
327
- end
328
-
329
- # Checks if the Accept headers for POST are valid according to server preference.
191
+ # Checks if the Accept headers for POST are valid.
330
192
  def post_accept_headers_valid?
331
- if ActionMCP.configuration.post_response_preference == :sse
332
- accepts_valid_content_types?
333
- else
334
- request.accepts.any? { |type| type.to_s == "application/json" }
335
- end
193
+ request.accepts.any? { |type| type.to_s == "application/json" }
336
194
  end
337
195
 
338
196
  # Returns the appropriate error message for POST Accept header validation.
339
197
  def post_accept_headers_error_message
340
- if ActionMCP.configuration.post_response_preference == :sse
341
- "Client must accept 'application/json' and 'text/event-stream'"
342
- else
343
- "Client must accept 'application/json'"
344
- end
198
+ "Client must accept 'application/json'"
345
199
  end
346
200
 
347
201
  # Checks if the parsed body represents an 'initialize' request.
@@ -372,19 +226,11 @@ module ActionMCP
372
226
  result
373
227
  end
374
228
 
375
- # Determine response format
376
- server_preference = ActionMCP.configuration.post_response_preference
377
- use_sse = (server_preference == :sse)
378
229
  add_session_header = is_initialize_request && session_initially_missing && session.persisted?
379
-
380
- if use_sse
381
- render_sse_response(payload, session, add_session_header)
382
- else
383
- render_json_response(payload, session, add_session_header)
384
- end
230
+ render_json_response(payload, session, add_session_header)
385
231
  end
386
232
 
387
- # Renders the JSON-RPC response(s) as a direct JSON HTTP response.
233
+ # Renders the JSON-RPC response as a JSON HTTP response.
388
234
  def render_json_response(payload, session, add_session_header)
389
235
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
390
236
  # Add MCP-Protocol-Version header if session has been initialized
@@ -393,62 +239,6 @@ module ActionMCP
393
239
  render json: payload, status: :ok
394
240
  end
395
241
 
396
- # Renders the JSON-RPC response(s) as an SSE stream.
397
- def render_sse_response(payload, session, add_session_header)
398
- response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
399
- # Add MCP-Protocol-Version header if session has been initialized
400
- response.headers["MCP-Protocol-Version"] = session.protocol_version if session&.initialized?
401
- response.headers["Content-Type"] = "text/event-stream"
402
- response.headers["X-Accel-Buffering"] = "no"
403
- response.headers["Cache-Control"] = "no-cache"
404
- response.headers["Connection"] = "keep-alive"
405
- sse = SSE.new(response.stream)
406
- write_sse_event(sse, session, payload)
407
- ensure
408
- sse&.close
409
- begin
410
- response.stream&.close
411
- rescue StandardError
412
- nil
413
- end
414
- Rails.logger.debug "Unified SSE (POST): Response stream closed." if ActionMCP.configuration.verbose_logging
415
- end
416
-
417
- # Helper to write a JSON payload as an SSE event with a unique ID.
418
- # Also stores the event for potential resumability.
419
- def write_sse_event(sse, session, payload)
420
- event_id = session.increment_sse_counter!
421
- # Ensure we're always writing valid JSON strings
422
- data = case payload
423
- when String
424
- payload
425
- when Hash
426
- MultiJson.dump(payload)
427
- else
428
- MultiJson.dump(payload.to_h)
429
- end
430
- # Use the SSE class's write method with proper options
431
- # According to MCP spec, we need to send with event type "message"
432
- sse.write(data, event: "message", id: event_id)
433
-
434
- begin
435
- session.store_sse_event(event_id, payload, session.max_stored_sse_events)
436
- rescue StandardError => e
437
- Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
438
- end
439
- end
440
-
441
- # Helper to clean up old SSE events for a session
442
- def cleanup_old_sse_events(session)
443
- retention_period = session.sse_event_retention_period
444
- count = session.cleanup_old_sse_events(retention_period)
445
- if count.positive? && ActionMCP.configuration.verbose_logging
446
- Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}"
447
- end
448
- rescue StandardError => e
449
- Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
450
- end
451
-
452
242
  def format_tools_list(tools, session)
453
243
  protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
454
244
  tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
@@ -73,12 +73,6 @@ module ActionMCP
73
73
  dependent: :delete_all,
74
74
  inverse_of: :session
75
75
 
76
- has_many :sse_events,
77
- class_name: "ActionMCP::Session::SSEEvent",
78
- foreign_key: "session_id",
79
- dependent: :delete_all,
80
- inverse_of: :session
81
-
82
76
  has_many :tasks,
83
77
  class_name: "ActionMCP::Session::Task",
84
78
  foreign_key: "session_id",
@@ -184,57 +178,6 @@ module ActionMCP
184
178
  subscriptions.find_by(uri: uri)&.destroy
185
179
  end
186
180
 
187
- # Atomically increments the SSE event counter and returns the new value.
188
- # This ensures unique, sequential IDs for SSE events within the session.
189
- # @return [Integer] The new value of the counter.
190
- def increment_sse_counter!
191
- # Use update_counters for an atomic increment operation
192
- self.class.update_counters(id, sse_event_counter: 1)
193
- # Reload to get the updated value (update_counters doesn't update the instance)
194
- reload.sse_event_counter
195
- end
196
-
197
- # Stores an SSE event for potential resumption
198
- # @param event_id [Integer] The event ID
199
- # @param data [Hash, String] The event data
200
- # @param max_events [Integer] Maximum number of events to store (oldest events are removed when exceeded)
201
- # @return [ActionMCP::Session::SSEEvent] The created event
202
- def store_sse_event(event_id, data, max_events = 100)
203
- # Create the SSE event record
204
- event = sse_events.create!(
205
- event_id: event_id,
206
- data: data
207
- )
208
-
209
- # Maintain cache limit by removing oldest events if needed
210
- count = sse_events.count
211
- excess = count - max_events
212
- sse_events.order(event_id: :asc).limit(excess).delete_all if excess.positive?
213
-
214
- event
215
- end
216
-
217
- # Retrieves SSE events after a given ID
218
- # @param last_event_id [Integer] The ID to retrieve events after
219
- # @param limit [Integer] Maximum number of events to return
220
- # @return [Array<ActionMCP::Session::SSEEvent>] The events
221
- def get_sse_events_after(last_event_id, limit = 50)
222
- sse_events.where("event_id > ?", last_event_id)
223
- .order(event_id: :asc)
224
- .limit(limit)
225
- end
226
-
227
- # Cleans up old SSE events
228
- # @param max_age [ActiveSupport::Duration] Maximum age of events to keep
229
- # @return [Integer] Number of events removed
230
- def cleanup_old_sse_events(max_age = 15.minutes)
231
- cutoff_time = Time.current - max_age
232
- events_to_delete = sse_events.where("created_at < ?", cutoff_time)
233
- count = events_to_delete.count
234
- events_to_delete.destroy_all
235
- count
236
- end
237
-
238
181
  def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
239
182
  # Create a transport handler to send the notification
240
183
  handler = ActionMCP::Server::TransportHandler.new(self)
@@ -246,18 +189,6 @@ module ActionMCP
246
189
  )
247
190
  end
248
191
 
249
- # Calculates the retention period for SSE events based on configuration
250
- # @return [ActiveSupport::Duration] The retention period
251
- def sse_event_retention_period
252
- ActionMCP.configuration.sse_event_retention_period || 15.minutes
253
- end
254
-
255
- # Calculates the maximum number of SSE events to store based on configuration
256
- # @return [Integer] The maximum number of events
257
- def max_stored_sse_events
258
- ActionMCP.configuration.max_stored_sse_events || 100
259
- end
260
-
261
192
  def send_progress_notification_legacy(token:, value:, message: nil)
262
193
  send_progress_notification(progressToken: token, progress: value, message: message)
263
194
  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
@@ -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 ---
@@ -67,8 +62,6 @@ module ActionMCP
67
62
  # Authentication defaults - empty means all configured identifiers will be tried
68
63
  @authentication_methods = []
69
64
 
70
- @sse_heartbeat_interval = 30
71
- @post_response_preference = :json
72
65
  @protocol_version = "2025-06-18" # Default to stable version for backwards compatibility
73
66
 
74
67
  # Tasks defaults (MCP 2025-11-25)
@@ -76,10 +69,6 @@ module ActionMCP
76
69
  @tasks_list_enabled = true
77
70
  @tasks_cancel_enabled = true
78
71
 
79
- # Resumability defaults
80
- @sse_event_retention_period = 15.minutes
81
- @max_stored_sse_events = 100
82
-
83
72
  # Gateway - resolved lazily to account for Zeitwerk autoloading
84
73
  @gateway_class_name = nil
85
74
 
@@ -214,9 +203,6 @@ module ActionMCP
214
203
  capabilities = {}
215
204
  profile = @profiles[active_profile]
216
205
 
217
- Rails.logger.debug "[ActionMCP] Generating capabilities for profile: #{active_profile}"
218
- Rails.logger.debug "[ActionMCP] Profile config: #{profile.inspect}"
219
-
220
206
  # Check profile configuration instead of registry contents
221
207
  # If profile includes tools (either "all" or specific tools), advertise tools capability
222
208
  capabilities[:tools] = { listChanged: @list_changed } if profile && profile[:tools]&.any?
@@ -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
 
@@ -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
@@ -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
- :sse_event_counter, :protocol_version, :client_info,
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.90.0"
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
@@ -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.90.0
4
+ version: 0.100.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -155,7 +155,6 @@ files:
155
155
  - app/models/action_mcp/session.rb
156
156
  - app/models/action_mcp/session/message.rb
157
157
  - app/models/action_mcp/session/resource.rb
158
- - app/models/action_mcp/session/sse_event.rb
159
158
  - app/models/action_mcp/session/subscription.rb
160
159
  - app/models/action_mcp/session/task.rb
161
160
  - app/models/concerns/action_mcp/mcp_console_helpers.rb
@@ -166,6 +165,9 @@ files:
166
165
  - db/migrate/20250727000001_remove_oauth_support.rb
167
166
  - db/migrate/20251125000001_create_action_mcp_session_tasks.rb
168
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
169
171
  - exe/actionmcp_cli
170
172
  - lib/action_mcp.rb
171
173
  - lib/action_mcp/base_response.rb
@@ -275,7 +277,6 @@ files:
275
277
  - lib/action_mcp/server/tools.rb
276
278
  - lib/action_mcp/server/transport_handler.rb
277
279
  - lib/action_mcp/server/volatile_session_store.rb
278
- - lib/action_mcp/sse_listener.rb
279
280
  - lib/action_mcp/string_array.rb
280
281
  - lib/action_mcp/tagged_stream_logging.rb
281
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