actionmcp 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -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 +22 -4
  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 +235 -12
  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/configuration.rb +63 -0
  23. data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
  24. data/lib/action_mcp/server/notifications.rb +14 -5
  25. data/lib/action_mcp/server/prompts.rb +18 -5
  26. data/lib/action_mcp/server/registry_management.rb +32 -0
  27. data/lib/action_mcp/server/resources.rb +3 -2
  28. data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
  29. data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
  30. data/lib/action_mcp/server/tools.rb +50 -6
  31. data/lib/action_mcp/server.rb +84 -2
  32. data/lib/action_mcp/sse_listener.rb +6 -5
  33. data/lib/action_mcp/tagged_stream_logging.rb +47 -0
  34. data/lib/action_mcp/test_helper.rb +57 -34
  35. data/lib/action_mcp/tool.rb +45 -9
  36. data/lib/action_mcp/version.rb +1 -1
  37. data/lib/action_mcp.rb +4 -4
  38. data/lib/generators/action_mcp/config/config_generator.rb +29 -0
  39. data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
  40. metadata +23 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04854ab6466f148e4d5db713d595acbedbe52420cdf7cfc9c1635a61b513678d'
4
- data.tar.gz: 69ed1465da06169ed2115c63d108be8cc1d1c5ecc988d1c2ac563dc5ad41c3b9
3
+ metadata.gz: 49bbf32664efb30dff032e7ab8ff3e76d972016cc7cf1a1a966c61cc507d7680
4
+ data.tar.gz: 61182b5e6367ecbbe7db4446a8da8a7ae756dd164e6b302a99f5583be64b3c8c
5
5
  SHA512:
6
- metadata.gz: 38d9feb89e3f5584cdca0ed4adf27ee02321d97c125513ad114e335a0a7c701fbc7ce5e69ec448d806e020a9d89c6f6b91c220a96030422866b1788ed9eb9775
7
- data.tar.gz: 903100988152291df5ea15f53b3c859721bdee80e22a7440999c7cc8a219ae2fbd63f6e18e34d3b481a2e60301712316284d74dd7ae877c89945bebbb7dfb504
6
+ metadata.gz: c2eac5bcb5a430ae67223000f3741f15acf4d6e1fbdd9d9f240640768d4da6e9b62d2ca0ec303be327ae818f1af86ad458b22e30fed6312cb710d02710532b8c
7
+ data.tar.gz: a39eb66c2dd002fd821c2db65b3f848c2a68e5d219d46efb0736d5501572235021be2d188cffd6783a14d8e0704737f313f9f225c2542c2d5ca047af08b9193e
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
 
@@ -220,17 +221,154 @@ end
220
221
 
221
222
  For dynamic versioning, consider adding the `rails_app_version` gem.
222
223
 
224
+ ### PubSub Configuration
225
+
226
+ ActionMCP uses a pub/sub system for real-time communication. You can choose between several adapters:
227
+
228
+ 1. **SolidCable** - Database-backed pub/sub (no Redis required)
229
+ 2. **Simple** - In-memory pub/sub for development and testing
230
+ 3. **Redis** - Redis-backed pub/sub (if you prefer Redis)
231
+
232
+ #### Migrating from ActionCable
233
+
234
+ If you were previously using ActionCable with ActionMCP, you will need to migrate to the new PubSub system. Here's how:
235
+
236
+ 1. Remove the ActionCable dependency from your Gemfile (if you don't need it for other purposes)
237
+ 2. Install one of the PubSub adapters (SolidCable recommended)
238
+ 3. Create a configuration file at `config/mcp.yml` (you can use the generator: `bin/rails g action_mcp:config`)
239
+ 4. Run your tests to ensure everything works correctly
240
+
241
+ The new PubSub system maintains the same API as the previous ActionCable-based implementation, so your existing code should continue to work without changes.
242
+
243
+ Configure your adapter in `config/mcp.yml`:
244
+
245
+ ```yaml
246
+ development:
247
+ adapter: solid_cable
248
+ polling_interval: 0.1.seconds
249
+ # Thread pool configuration (optional)
250
+ # min_threads: 5 # Minimum number of threads in the pool
251
+ # max_threads: 10 # Maximum number of threads in the pool
252
+ # max_queue: 100 # Maximum number of tasks that can be queued
253
+
254
+ test:
255
+ adapter: test # Uses the simple in-memory adapter
256
+
257
+ production:
258
+ adapter: solid_cable
259
+ polling_interval: 0.5.seconds
260
+ # Optional: connects_to: cable # If using a separate database
261
+
262
+ # Thread pool configuration for high-traffic environments
263
+ min_threads: 10 # Minimum number of threads in the pool
264
+ max_threads: 20 # Maximum number of threads in the pool
265
+ max_queue: 500 # Maximum number of tasks that can be queued
266
+ ```
267
+
268
+ #### SolidCable (Database-backed, Recommended)
269
+
270
+ For SolidCable, add it to your Gemfile:
271
+
272
+ ```ruby
273
+ gem "solid_cable" # Database-backed adapter (no Redis needed)
274
+ ```
275
+
276
+ Then install it:
277
+
278
+ ```bash
279
+ bundle install
280
+ bin/rails solid_cable:install
281
+ ```
282
+
283
+ The installer will create the necessary database migration. You'll need to configure it in your `config/mcp.yml`. You can create this file with `bin/rails g action_mcp:config`.
284
+
285
+ #### Redis Adapter
286
+
287
+ If you prefer Redis, add it to your Gemfile:
288
+
289
+ ```ruby
290
+ gem "redis", "~> 5.0"
291
+ ```
292
+
293
+ Then configure the Redis adapter in your `config/mcp.yml`:
294
+
295
+ ```yaml
296
+ production:
297
+ adapter: redis
298
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
299
+ channel_prefix: your_app_production
300
+
301
+ # Thread pool configuration for high-traffic environments
302
+ min_threads: 10 # Minimum number of threads in the pool
303
+ max_threads: 20 # Maximum number of threads in the pool
304
+ max_queue: 500 # Maximum number of tasks that can be queued
305
+ ```
306
+
307
+ ## Thread Pool Management
308
+
309
+ ActionMCP uses thread pools to efficiently handle message callbacks. This prevents the system from being overwhelmed by too many threads under high load.
310
+
311
+ ### Thread Pool Configuration
312
+
313
+ You can configure the thread pool in your `config/mcp.yml`:
314
+
315
+ ```yaml
316
+ production:
317
+ adapter: solid_cable
318
+ # Thread pool configuration
319
+ min_threads: 10 # Minimum number of threads to keep in the pool
320
+ max_threads: 20 # Maximum number of threads the pool can grow to
321
+ max_queue: 500 # Maximum number of tasks that can be queued
322
+ ```
323
+
324
+ The thread pool will automatically:
325
+ - Start with `min_threads` threads
326
+ - Scale up to `max_threads` as needed
327
+ - Queue tasks up to `max_queue` limit
328
+ - Use caller's thread if queue is full (fallback policy)
329
+
330
+ ### Graceful Shutdown
331
+
332
+ When your application is shutting down, you should call:
333
+
334
+ ```ruby
335
+ ActionMCP::Server.shutdown
336
+ ```
337
+
338
+ This ensures all thread pools are properly terminated and tasks are completed.
339
+
223
340
  ## Engine and Mounting
224
341
 
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.
342
+ **ActionMCP** runs as a standalone Rack application. It is **not** mounted in `routes.rb`.
343
+
344
+ ### Installing the Configuration Generator
345
+
346
+ ActionMCP includes a generator to help you create the configuration file:
347
+
348
+ ```bash
349
+ # Generate the mcp.yml configuration file
350
+ bin/rails generate action_mcp:config
351
+ ```
352
+
353
+ This will create `config/mcp.yml` with example configurations for all environments.
227
354
 
228
- To mount the ActionMCP engine in your routes, add the following line to your `config/routes.rb`:
355
+ > **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
356
+
357
+ ### 1. Create `mcp.ru`
229
358
 
230
359
  ```ruby
231
- Rails.application.routes.draw do
232
- mount ActionMCP::Engine => "/action_mcp"
360
+ # Load the full Rails environment to access models, DB, Redis, etc.
361
+ require_relative "config/environment"
362
+
363
+ ActionMCP.configure do |config|
364
+ config.mcp_endpoint_path = "/mcp"
233
365
  end
366
+
367
+ run ActionMCP::Engine
368
+ ```
369
+ ### 2. Start the server
370
+ ```bash
371
+ bin/rails s -c mcp.ru -p 6277 -P tmp/pids/mcp.pid
234
372
  ```
235
373
 
236
374
  ## 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
@@ -35,8 +37,16 @@ module ActionMCP
35
37
  error = build_timeout_error
36
38
  # Safely write error and close the stream
37
39
  Concurrent::Promise.execute do
38
- sse.write(error) rescue nil
39
- 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
40
50
  connection_active.make_false
41
51
  end
42
52
  end
@@ -108,8 +118,16 @@ module ActionMCP
108
118
  heartbeat_active&.make_false # Signal to stop scheduling new heartbeats
109
119
  heartbeat_task&.cancel # Cancel any pending heartbeat task
110
120
  listener&.stop
111
- mcp_session.close! rescue nil
112
- 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
113
131
 
114
132
  Rails.logger.debug "SSE: Connection cleaned up for session: #{session_id}"
115
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,