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 +4 -4
- data/app/controllers/action_mcp/application_controller.rb +10 -220
- data/app/models/action_mcp/session.rb +0 -69
- 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/lib/action_mcp/configuration.rb +0 -14
- data/lib/action_mcp/engine.rb +0 -1
- data/lib/action_mcp/filtered_logger.rb +0 -3
- data/lib/action_mcp/server/base_session.rb +1 -71
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- data/lib/tasks/action_mcp_tasks.rake +1 -35
- metadata +4 -3
- data/app/models/action_mcp/session/sse_event.rb +0 -62
- data/lib/action_mcp/sse_listener.rb +0 -81
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b01884f2ba4ded4a59d6c235583edbd00dae36da78215da26a1fbf83039838f
|
|
4
|
+
data.tar.gz: c97745755f33ece7ce05a4a2a904cde23e3fe67c3f1ecf9ee0cc962e6bdc9177
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?
|
data/lib/action_mcp/engine.rb
CHANGED
|
@@ -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
|
-
:
|
|
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
|
data/lib/action_mcp/version.rb
CHANGED
data/lib/action_mcp.rb
CHANGED
|
@@ -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 "
|
|
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.
|
|
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
|