actionmcp 0.31.0 → 0.32.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -5
  3. data/app/controllers/action_mcp/mcp_controller.rb +13 -17
  4. data/app/controllers/action_mcp/messages_controller.rb +3 -1
  5. data/app/controllers/action_mcp/sse_controller.rb +25 -6
  6. data/app/controllers/action_mcp/unified_controller.rb +147 -52
  7. data/app/models/action_mcp/session/message.rb +1 -0
  8. data/app/models/action_mcp/session/sse_event.rb +55 -0
  9. data/app/models/action_mcp/session.rb +243 -14
  10. data/app/models/concerns/mcp_console_helpers.rb +68 -0
  11. data/app/models/concerns/mcp_message_inspect.rb +73 -0
  12. data/config/routes.rb +4 -2
  13. data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
  14. data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
  15. data/lib/action_mcp/capability.rb +16 -0
  16. data/lib/action_mcp/configuration.rb +16 -4
  17. data/lib/action_mcp/console_detector.rb +12 -0
  18. data/lib/action_mcp/engine.rb +3 -0
  19. data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
  20. data/lib/action_mcp/resource_template.rb +11 -0
  21. data/lib/action_mcp/server/capabilities.rb +28 -22
  22. data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
  23. data/lib/action_mcp/server/notifications.rb +14 -5
  24. data/lib/action_mcp/server/prompts.rb +18 -5
  25. data/lib/action_mcp/server/registry_management.rb +32 -0
  26. data/lib/action_mcp/server/resources.rb +3 -2
  27. data/lib/action_mcp/server/tools.rb +50 -6
  28. data/lib/action_mcp/sse_listener.rb +3 -2
  29. data/lib/action_mcp/tagged_stream_logging.rb +47 -0
  30. data/lib/action_mcp/test_helper.rb +57 -34
  31. data/lib/action_mcp/tool.rb +45 -9
  32. data/lib/action_mcp/version.rb +1 -1
  33. data/lib/action_mcp.rb +4 -4
  34. metadata +25 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92a0e52de9e401a8e10b8aa49d40cba39769e58f4206c7810face00774e9a5ab
4
- data.tar.gz: a0e4fd9420fc44688ea20bb4d4697fbd009594a6d414bafc13563523ed9c5c8a
3
+ metadata.gz: 54b807ad23c23796ef5495fb91afffb9ee10d998516a72d20897c519bed56c4c
4
+ data.tar.gz: e5ec996f8e8dcb73383de34fe5f851382961a74a27e3b28c26271aadb343c30f
5
5
  SHA512:
6
- metadata.gz: 055db3b1d213b6993694f703bd94a729dc70e2c640580e33828ac8a9193a21302fefadb5de40a89cdde88587114af92966f8675a9e97600fdf53dc01bfd6843b
7
- data.tar.gz: 413bc80f29ea5b92b3214a77cae4e42509569b9c5f93b2ce8e10b793797cabad6735fdb4103d96128ca425c1fcf4f04f35cfd5992eaaded1e6262e4f758b0355
6
+ metadata.gz: 55938823b5df5d13b8ffe2adbb9e581331e60fabb2f7111807fa10b119cd898f2ab3dd695cbda8924af0b9be79cf9eb0767b18324a1042ac46fb4facb6562abe
7
+ data.tar.gz: 76c6c5a553e55f06b61165844a3d55e45854dffe515879f14eaaf5b1399aa8f3273563b3c78cf028bab54547cc3165947cd5e2606e19f96be55285c845c641ef
data/README.md CHANGED
@@ -169,6 +169,7 @@ class ProductResourceTemplate < ApplicationMCPResTemplate
169
169
  )
170
170
  end
171
171
  end
172
+ ```
172
173
 
173
174
  # Example of callbacks:
174
175
 
@@ -222,15 +223,25 @@ For dynamic versioning, consider adding the `rails_app_version` gem.
222
223
 
223
224
  ## Engine and Mounting
224
225
 
225
- ActionMCP is implemented as a Rails engine, which means it can be mounted in your application's routes.
226
- The engine provides no authentication or authorization by default, so you'll need to handle that in your application for now.
226
+ **ActionMCP** runs as a standalone Rack application, similar to **ActionCable**. It is **not** mounted in `routes.rb`.
227
+
228
+ > **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
227
229
 
228
- To mount the ActionMCP engine in your routes, add the following line to your `config/routes.rb`:
230
+ ### 1. Create `mcp.ru`
229
231
 
230
232
  ```ruby
231
- Rails.application.routes.draw do
232
- mount ActionMCP::Engine => "/action_mcp"
233
+ # Load the full Rails environment to access models, DB, Redis, etc.
234
+ require_relative "config/environment"
235
+
236
+ ActionMCP.configure do |config|
237
+ config.mcp_endpoint_path = "/mcp"
233
238
  end
239
+
240
+ run ActionMCP::Engine
241
+ ```
242
+ ### 2. Start the server
243
+ ```bash
244
+ bin/rails s -c mcp.ru -p 6277 -P tmp/pids/mcp.pid
234
245
  ```
235
246
 
236
247
  ## Generators
@@ -33,51 +33,47 @@ module ActionMCP
33
33
  def find_or_initialize_session
34
34
  session_id = extract_session_id
35
35
  if session_id
36
- # Attempt to find the session by ID. Return nil if not found.
37
- # Controllers should handle the nil case (e.g., return 404).
38
- Session.find_by(id: session_id)
36
+ session = Session.find_by(id: session_id)
37
+ if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
38
+ # Update existing session to use 2025 protocol
39
+ session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
40
+ end
41
+ session
39
42
  else
40
- # No session ID provided, initialize a new one (likely for 'initialize' request).
41
- Session.new
43
+ # Create new session with 2025 protocol
44
+ Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
42
45
  end
43
46
  end
44
47
 
45
- # Extracts the session ID from the request header or parameters.
46
- # Prefers the Mcp-Session-Id header (new spec) over the param (old spec).
47
- # @return [String, nil] The extracted session ID or nil if not found.
48
- def extract_session_id
49
- request.headers[MCP_SESSION_ID_HEADER].presence || params[:session_id].presence
50
- end
51
-
52
48
  # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
53
49
  def render_bad_request(message = "Bad Request")
54
50
  # Using -32600 for Invalid Request based on JSON-RPC spec
55
- render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }, status: :bad_request
51
+ render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
56
52
  end
57
53
 
58
54
  # Renders a 404 Not Found response with a JSON-RPC-like error structure.
59
55
  def render_not_found(message = "Not Found")
60
56
  # Using a custom code or a generic server error range code might be appropriate.
61
57
  # Let's use -32001 for a generic server error.
62
- render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }, status: :not_found
58
+ render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
63
59
  end
64
60
 
65
61
  # Renders a 405 Method Not Allowed response.
66
62
  def render_method_not_allowed(message = "Method Not Allowed")
67
63
  # Using -32601 Method not found from JSON-RPC spec seems applicable
68
- render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }, status: :method_not_allowed
64
+ render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
69
65
  end
70
66
 
71
67
  # Renders a 406 Not Acceptable response.
72
68
  def render_not_acceptable(message = "Not Acceptable")
73
69
  # No direct JSON-RPC equivalent, using a generic server error code.
74
- render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }, status: :not_acceptable
70
+ render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
75
71
  end
76
72
 
77
73
  # Renders a 501 Not Implemented response.
78
74
  def render_not_implemented(message = "Not Implemented")
79
75
  # No direct JSON-RPC equivalent, using a generic server error code.
80
- render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }, status: :not_implemented
76
+ render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
81
77
  end
82
78
  end
83
79
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionMCP
4
4
  class MessagesController < MCPController
5
+ REQUIRED_PROTOCOL_VERSION = "2024-11-05"
6
+
5
7
  include Instrumentation::ControllerRuntime
6
8
 
7
9
  # @route POST / (sse_in)
@@ -34,7 +36,7 @@ module ActionMCP
34
36
 
35
37
  def filter_jsonrpc_params(params)
36
38
  # Valid JSON-RPC keys (both request and response)
37
- valid_keys = [ "jsonrpc", "method", "params", "id", "result", "error" ]
39
+ valid_keys = %w[jsonrpc method params id result error]
38
40
 
39
41
  params.to_h.slice(*valid_keys)
40
42
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionMCP
4
4
  class SSEController < MCPController
5
+ REQUIRED_PROTOCOL_VERSION = "2024-11-05"
6
+
5
7
  HEARTBEAT_INTERVAL = 30 # in seconds
6
8
  INITIAL_CONNECTION_TIMEOUT = 5 # in seconds
7
9
  include ActionController::Live
@@ -20,8 +22,9 @@ module ActionMCP
20
22
  Rails.logger.info "SSE: Starting connection for session: #{session_id}"
21
23
 
22
24
  # Use Concurrent primitives for state management
23
- message_received = Concurrent::AtomicBoolean.new(false)
24
- connection_active = Concurrent::AtomicBoolean.new(true)
25
+ message_received = Concurrent::AtomicBoolean.new
26
+ connection_active = Concurrent::AtomicBoolean.new
27
+ connection_active.make_true
25
28
 
26
29
  begin
27
30
  # Create SSE instance
@@ -34,8 +37,16 @@ module ActionMCP
34
37
  error = build_timeout_error
35
38
  # Safely write error and close the stream
36
39
  Concurrent::Promise.execute do
37
- sse.write(error) rescue nil
38
- response.stream.close rescue nil
40
+ begin
41
+ sse.write(error)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ begin
46
+ response.stream.close
47
+ rescue StandardError
48
+ nil
49
+ end
39
50
  connection_active.make_false
40
51
  end
41
52
  end
@@ -107,8 +118,16 @@ module ActionMCP
107
118
  heartbeat_active&.make_false # Signal to stop scheduling new heartbeats
108
119
  heartbeat_task&.cancel # Cancel any pending heartbeat task
109
120
  listener&.stop
110
- mcp_session.close! rescue nil
111
- response.stream.close rescue nil
121
+ begin
122
+ mcp_session.close!
123
+ rescue StandardError
124
+ nil
125
+ end
126
+ begin
127
+ response.stream.close
128
+ rescue StandardError
129
+ nil
130
+ end
112
131
 
113
132
  Rails.logger.debug "SSE: Connection cleaned up for session: #{session_id}"
114
133
  end
@@ -5,12 +5,15 @@ module ActionMCP
5
5
  # Supports GET for server-initiated SSE streams, POST for client messages
6
6
  # (responding with JSON or SSE), and optionally DELETE for session termination.
7
7
  class UnifiedController < MCPController
8
+ REQUIRED_PROTOCOL_VERSION = "2025-03-26"
9
+
10
+ include JSONRPC_Rails::ControllerHelpers
8
11
  include ActionController::Live
9
12
  # TODO: Include Instrumentation::ControllerRuntime if needed for metrics
10
13
 
11
14
  # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
12
15
  # @route GET /mcp
13
- def handle_get
16
+ def show
14
17
  # 1. Check Accept Header
15
18
  unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
16
19
  return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
@@ -30,7 +33,9 @@ module ActionMCP
30
33
  return render_not_found("Session has been terminated.")
31
34
  end
32
35
 
33
- # TODO: Handle Last-Event-ID header for stream resumption
36
+ # Check for Last-Event-ID header for resumability
37
+ last_event_id = request.headers["Last-Event-ID"].presence
38
+ Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
34
39
 
35
40
  # 3. Set SSE Headers
36
41
  response.headers["Content-Type"] = "text/event-stream"
@@ -43,8 +48,10 @@ module ActionMCP
43
48
  # 4. Setup Stream, Listener, and Heartbeat
44
49
  sse = SSE.new(response.stream)
45
50
  listener = SSEListener.new(session) # Use the listener class (defined below or moved)
46
- connection_active = Concurrent::AtomicBoolean.new(true)
47
- heartbeat_active = Concurrent::AtomicBoolean.new(true)
51
+ connection_active = Concurrent::AtomicBoolean.new
52
+ connection_active.make_true
53
+ heartbeat_active = Concurrent::AtomicBoolean.new
54
+ heartbeat_active.make_true
48
55
  heartbeat_task = nil
49
56
 
50
57
  # Start listener
@@ -60,6 +67,27 @@ module ActionMCP
60
67
  return # Error logged, connection will close in ensure block
61
68
  end
62
69
 
70
+ # Handle resumability by sending missed events if Last-Event-ID is provided
71
+ if last_event_id.present? && last_event_id.to_i > 0
72
+ begin
73
+ # Fetch events that occurred after the Last-Event-ID
74
+ missed_events = session.get_sse_events_after(last_event_id.to_i)
75
+
76
+ if missed_events.any?
77
+ Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
78
+
79
+ # Send each missed event to the client
80
+ missed_events.each do |event|
81
+ sse.stream.write(event.to_sse)
82
+ end
83
+ else
84
+ Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
85
+ end
86
+ rescue => e
87
+ Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
88
+ end
89
+ end
90
+
63
91
  # Heartbeat sender proc
64
92
  heartbeat_sender = lambda do
65
93
  if connection_active.true? && !response.stream.closed?
@@ -98,6 +126,10 @@ module ActionMCP
98
126
  heartbeat_active&.make_false
99
127
  heartbeat_task&.cancel
100
128
  listener&.stop
129
+
130
+ # Clean up old SSE events if resumability is enabled
131
+ cleanup_old_sse_events(session) if session
132
+
101
133
  # Don't close the session itself here, it might be used by other connections/requests
102
134
  sse&.close
103
135
  begin
@@ -109,18 +141,14 @@ module ActionMCP
109
141
 
110
142
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
111
143
  # @route POST /mcp
112
- def handle_post
144
+ def create
113
145
  # 1. Check Accept Header
114
146
  unless accepts_valid_content_types?
115
147
  return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
116
148
  end
117
149
 
118
- # 2. Parse Request Body
119
- parsed_body = parse_request_body
120
- return unless parsed_body # Error rendered in parse_request_body
121
-
122
150
  # Determine if this is an initialize request (before session check)
123
- is_initialize_request = check_if_initialize_request(parsed_body)
151
+ is_initialize_request = check_if_initialize_request(jsonrpc_params)
124
152
 
125
153
  # 3. Check Session (unless it's an initialize request)
126
154
  session_initially_missing = extract_session_id.nil?
@@ -134,13 +162,16 @@ module ActionMCP
134
162
  return render_not_found("Session has been terminated.")
135
163
  end
136
164
  end
137
-
165
+ if session.new_record?
166
+ session.save!
167
+ response.headers[MCP_SESSION_ID_HEADER] = session.id
168
+ end
138
169
  # 4. Instantiate Handlers
139
170
  transport_handler = Server::TransportHandler.new(session)
140
171
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
141
172
 
142
173
  # 5. Call Handler
143
- handler_results = json_rpc_handler.call(parsed_body)
174
+ handler_results = json_rpc_handler.call(jsonrpc_params.to_h)
144
175
 
145
176
  # 6. Process Results
146
177
  process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
@@ -160,13 +191,7 @@ module ActionMCP
160
191
 
161
192
  # Handles DELETE requests for session termination (2025-03-26 spec).
162
193
  # @route DELETE /mcp
163
- def handle_delete
164
- allow_termination = ActionMCP.configuration.allow_client_session_termination
165
-
166
- unless allow_termination
167
- return render_method_not_allowed("Session termination via DELETE is not supported by this server.")
168
- end
169
-
194
+ def destroy
170
195
  # 1. Check Session Header
171
196
  session_id_from_header = extract_session_id
172
197
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
@@ -195,58 +220,91 @@ module ActionMCP
195
220
 
196
221
  private
197
222
 
223
+ # @return [String, nil] The extracted session ID or nil if not found.
224
+ def extract_session_id
225
+ request.headers[MCP_SESSION_ID_HEADER].presence
226
+ end
227
+
198
228
  # Checks if the client's Accept header includes the required types.
199
229
  def accepts_valid_content_types?
200
230
  request.accepts.any? { |type| type.to_s == "application/json" } &&
201
231
  request.accepts.any? { |type| type.to_s == "text/event-stream" }
202
232
  end
203
233
 
204
- # Parses the JSON request body. Renders error if invalid.
205
- def parse_request_body
206
- body = request.body.read
207
- MultiJson.load(body)
208
- rescue MultiJson::ParseError => e
209
- render_bad_request("Invalid JSON in request body: #{e.message}")
210
- nil # Indicate failure
211
- end
212
-
213
234
  # Checks if the parsed body represents an 'initialize' request.
214
- def check_if_initialize_request(parsed_body)
215
- if parsed_body.is_a?(Hash) && parsed_body["method"] == "initialize"
216
- true
217
- elsif parsed_body.is_a?(Array) # Cannot be in a batch
218
- false
219
- else
220
- false
221
- end
235
+ def check_if_initialize_request(payload)
236
+ return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
237
+ payload.method == "initialize"
222
238
  end
223
239
 
224
240
  # Processes the results from the JsonRpcHandler.
225
241
  def process_handler_results(results, session, session_initially_missing, is_initialize_request)
226
- case results[:type]
242
+ # Make sure we always have a results hash
243
+ results ||= {}
244
+
245
+ # Check if this is a notification request
246
+ is_notification = jsonrpc_params.is_a?(Hash) &&
247
+ jsonrpc_params["method"].to_s.start_with?("notifications/") &&
248
+ !jsonrpc_params.key?("id")
249
+
250
+ # Extract request ID from results
251
+ request_id = nil
252
+ if results.is_a?(Hash)
253
+ request_id = results[:request_id] || results[:id]
254
+
255
+ # If we have a payload that's a response, extract ID from there as well
256
+ if results[:payload].is_a?(Hash) && results[:payload][:id]
257
+ request_id ||= results[:payload][:id]
258
+ end
259
+ end
260
+
261
+ # Default to empty hash for response payload if nil
262
+ result_type = results[:type]
263
+ result_payload = results[:payload] || {}
264
+
265
+ # Ensure payload has the correct ID if it's a hash
266
+ if result_payload.is_a?(Hash) && request_id
267
+ result_payload[:id] = request_id unless result_payload.key?(:id)
268
+ end
269
+
270
+ # Check if the payload is a notification
271
+ is_notification_payload = result_payload.is_a?(Hash) &&
272
+ result_payload[:method]&.to_s&.start_with?("notifications/") &&
273
+ !result_payload.key?(:id)
274
+
275
+ case result_type
227
276
  when :error
228
- # Handle handler-level errors (e.g., batch parse error)
229
- render json: results[:payload], status: results.fetch(:status, :bad_request)
277
+ # Ensure error responses preserve the ID
278
+ error_payload = result_payload
279
+ if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
280
+ error_payload[:id] = request_id
281
+ end
282
+ render json: error_payload, status: results.fetch(:status, :bad_request)
230
283
  when :notifications_only
231
- # No response needed, just accept
232
284
  head :accepted
233
285
  when :responses
234
- # Determine response format based on server preference and client acceptance.
235
- # Client MUST accept both 'application/json' and 'text/event-stream' (checked earlier).
236
- server_preference = ActionMCP.configuration.post_response_preference # :json or :sse
286
+ server_preference = ActionMCP.configuration.post_response_preference
237
287
  use_sse = (server_preference == :sse)
238
288
 
239
- # Add session ID header if this was a successful initialize request that created the session
240
289
  add_session_header = is_initialize_request && session_initially_missing && session.persisted?
241
290
 
242
291
  if use_sse
243
- render_sse_response(results[:payload], session, add_session_header)
292
+ render_sse_response(result_payload, session, add_session_header)
244
293
  else
245
- render_json_response(results[:payload], session, add_session_header)
294
+ render_json_response(result_payload, session, add_session_header)
246
295
  end
247
296
  else
248
- # Should not happen
249
- render_internal_server_error("Unknown handler result type: #{results[:type]}")
297
+ # This was causing the "Unknown handler result type: " error
298
+ Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
299
+
300
+ # Return a proper JSON-RPC response with the preserved ID
301
+ # Default to a response with JSON-RPC message format
302
+ status = is_notification ? :accepted : :ok
303
+ render json: {
304
+ jsonrpc: "2.0",
305
+ id: request_id,
306
+ result: {}
307
+ }, status: status
250
308
  end
251
309
  end
252
310
 
@@ -254,11 +312,13 @@ module ActionMCP
254
312
  def render_json_response(payload, session, add_session_header)
255
313
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
256
314
  response.headers["Content-Type"] = "application/json"
315
+
257
316
  render json: payload, status: :ok
258
317
  end
259
318
 
260
319
  # Renders the JSON-RPC response(s) as an SSE stream.
261
320
  def render_sse_response(payload, session, add_session_header)
321
+ # This is not recommended with puma
262
322
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
263
323
  response.headers["Content-Type"] = "text/event-stream"
264
324
  response.headers["X-Accel-Buffering"] = "no"
@@ -285,17 +345,52 @@ module ActionMCP
285
345
  end
286
346
 
287
347
  # Renders a 500 Internal Server Error response.
288
- def render_internal_server_error(message = "Internal Server Error")
348
+ def render_internal_server_error(message = "Internal Server Error", id = nil)
289
349
  # Using -32000 for generic server error
290
- render json: { jsonrpc: "2.0", error: { code: -32_000, message: message } }, status: :internal_server_error
350
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
351
+ end
352
+
353
+ # Helper to clean up old SSE events for a session
354
+ def cleanup_old_sse_events(session)
355
+ return unless ActionMCP.configuration.enable_sse_resumability
356
+
357
+ begin
358
+ # Get retention period from configuration
359
+ retention_period = session.sse_event_retention_period
360
+ count = session.cleanup_old_sse_events(retention_period)
361
+
362
+ Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count > 0
363
+ rescue => e
364
+ Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
365
+ end
291
366
  end
292
367
 
293
368
  # Helper to write a JSON payload as an SSE event with a unique ID.
369
+ # Also stores the event for potential resumability.
294
370
  def write_sse_event(sse, session, payload)
295
371
  event_id = session.increment_sse_counter!
372
+
296
373
  # Manually format the SSE event string including the ID
297
- data = MultiJson.dump(payload)
298
- sse.stream.write("id: #{event_id}\ndata: #{data}\n\n")
374
+ data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
375
+ sse_event = "id: #{event_id}\ndata: #{data}\n\n"
376
+
377
+ # Write to the stream
378
+ sse.stream.write(sse_event)
379
+
380
+ # Store the event for potential resumption if resumability is enabled
381
+ if ActionMCP.configuration.enable_sse_resumability
382
+ begin
383
+ session.store_sse_event(event_id, payload, session.max_stored_sse_events)
384
+ rescue => e
385
+ Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
386
+ end
387
+ end
388
+ end
389
+
390
+ def format_tools_list(tools, session)
391
+ # Pass the session's protocol version when formatting tools
392
+ protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
393
+ tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
299
394
  end
300
395
 
301
396
  # TODO: Add methods for handle_get (SSE setup, listener, heartbeat) - Partially Done
@@ -32,6 +32,7 @@ module ActionMCP
32
32
  # including the direction (client or server), message type (request, response, notification),
33
33
  # and any associated JSON-RPC ID.
34
34
  class Message < ApplicationRecord
35
+ include MCPMessageInspect
35
36
  belongs_to :session,
36
37
  class_name: "ActionMCP::Session",
37
38
  inverse_of: :messages,
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: action_mcp_sse_events
6
+ #
7
+ # id :bigint not null, primary key
8
+ # data :text not null
9
+ # created_at :datetime not null
10
+ # updated_at :datetime not null
11
+ # event_id :integer not null
12
+ # session_id :string not null
13
+ #
14
+ # Indexes
15
+ #
16
+ # index_action_mcp_sse_events_on_created_at (created_at)
17
+ # index_action_mcp_sse_events_on_session_id (session_id)
18
+ # index_action_mcp_sse_events_on_session_id_and_event_id (session_id,event_id) UNIQUE
19
+ #
20
+ # Foreign Keys
21
+ #
22
+ # fk_rails_... (session_id => action_mcp_sessions.id)
23
+ #
24
+ module ActionMCP
25
+ class Session
26
+ # Represents a Server-Sent Event (SSE) in an MCP session
27
+ # These events are stored for potential resumption when a client reconnects
28
+ class SSEEvent < ApplicationRecord
29
+ self.table_name = "action_mcp_sse_events"
30
+
31
+ belongs_to :session, class_name: "ActionMCP::Session"
32
+
33
+ # Validations
34
+ validates :event_id, presence: true, numericality: { only_integer: true, greater_than: 0 }
35
+ validates :data, presence: true
36
+
37
+ # Scopes
38
+ scope :recent, -> { order(event_id: :desc) }
39
+ scope :after_id, ->(id) { where("event_id > ?", id) }
40
+ scope :before, ->(time) { where("created_at < ?", time) }
41
+
42
+ # Serializes the data as JSON if it's not already a string
43
+ def data_for_stream
44
+ return data if data.is_a?(String)
45
+ data.is_a?(Hash) ? data.to_json : data.to_s
46
+ end
47
+
48
+ # Generates the SSE formatted event string
49
+ # @return [String] The formatted SSE event
50
+ def to_sse
51
+ "id: #{event_id}\ndata: #{data_for_stream}\n\n"
52
+ end
53
+ end
54
+ end
55
+ end