actionmcp 0.50.2 → 0.50.4

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: 2331cbdf1a1729ce24b729693e34394f3e2725b81106942b5110770d7820225b
4
- data.tar.gz: 672d31525a9f4632d8c2334e93a19bb8eb69a7e03e3ea9312d89d2e55a1d8bf7
3
+ metadata.gz: b2ba13241313978eedd0619b256d320728391aa9afaa899b6299e65e54867358
4
+ data.tar.gz: 6bfc9173a29184139f7b9f0ceb543a56da3a557d3c633abae09e8bdc4c8465ee
5
5
  SHA512:
6
- metadata.gz: ef0c162837326d3e4514e51123eab9597cfa04aa6a9ca1a790b607d460b9544e5d372171f7bb78d38e5c3d7298090ca2dfd5a3228c4c9b6b5feeb126912cff4a
7
- data.tar.gz: e88d3401976a55d7144b3359156bb2d110fa597da8a4df5e22898fc8e3d724f7b6ab42e6b70f53b48e966ba1616057a9e2881f917bae347470160b399e4b97b3
6
+ metadata.gz: 77740b75ca3ec92fdb1eff6ecbfe1020113093899652b5cee0a56fbfbbe0c40c93e26f82803df732f33464e5cf532018901118cc6092d3e204001c490966b36f
7
+ data.tar.gz: c4ddcadeac684c84116e7f6fe04dff67b6f9749425bd4d5901471affacc2bfb1bb644e6a3d955718bd0ae6a788af0fd7981df621a2f15f4e372510b809190710
@@ -4,39 +4,54 @@ module ActionMCP
4
4
  # Implements the MCP endpoints according to the 2025-03-26 specification.
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
- class UnifiedController < MCPController
7
+ class ApplicationController < ActionController::Metal
8
8
  REQUIRED_PROTOCOL_VERSION = "2025-03-26"
9
+ MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
9
10
 
11
+ ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
12
+ include left
13
+ end
14
+ include Engine.routes.url_helpers
10
15
  include JSONRPC_Rails::ControllerHelpers
11
16
  include ActionController::Live
12
17
 
18
+ # Provides the ActionMCP::Session for the current request.
19
+ # Handles finding existing sessions via header/param or initializing a new one.
20
+ # Specific controllers/handlers might need to enforce session ID presence based on context.
21
+ # @return [ActionMCP::Session] The session object (might be unsaved if new)
22
+ def mcp_session
23
+ @mcp_session ||= find_or_initialize_session
24
+ end
25
+
26
+ # Provides a unique key for caching or pub/sub based on the session ID.
27
+ # Ensures mcp_session is called first to establish the session ID.
28
+ # @return [String] The session key string.
29
+ def session_key
30
+ @session_key ||= "action_mcp-sessions-#{mcp_session.id}"
31
+ end
32
+
13
33
  # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
14
- # @route GET /mcp
34
+ # @route GET /
15
35
  def show
16
- # 1. Check Accept Header
17
36
  unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
18
37
  return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
19
38
  end
20
39
 
21
- # 2. Check Session (Must exist and be initialized)
22
40
  session_id_from_header = extract_session_id
23
41
  return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
24
42
 
25
- session = mcp_session # Finds based on header
43
+ session = mcp_session
26
44
  if session.nil? || session.new_record?
27
45
  return render_not_found("Session not found.")
28
46
  elsif !session.initialized?
29
- # Spec doesn't explicitly forbid GET before initialized, but it seems logical
30
47
  return render_bad_request("Session is not fully initialized.")
31
48
  elsif session.status == "closed"
32
49
  return render_not_found("Session has been terminated.")
33
50
  end
34
51
 
35
- # Check for Last-Event-ID header for resumability
36
52
  last_event_id = request.headers["Last-Event-ID"].presence
37
53
  Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
38
54
 
39
- # 3. Set SSE Headers
40
55
  response.headers["Content-Type"] = "text/event-stream"
41
56
  response.headers["X-Accel-Buffering"] = "no"
42
57
  response.headers["Cache-Control"] = "no-cache"
@@ -44,7 +59,6 @@ module ActionMCP
44
59
 
45
60
  Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
46
61
 
47
- # 4. Setup Stream, Listener, and Heartbeat
48
62
  sse = SSE.new(response.stream)
49
63
  listener = SSEListener.new(session)
50
64
  connection_active = Concurrent::AtomicBoolean.new
@@ -53,31 +67,23 @@ module ActionMCP
53
67
  heartbeat_active.make_true
54
68
  heartbeat_task = nil
55
69
 
56
- # Start listener
57
70
  listener_started = listener.start do |message|
58
- # Write message using helper to include event ID
59
71
  write_sse_event(sse, session, message)
60
72
  end
61
73
 
62
74
  unless listener_started
63
75
  Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
64
- # Don't write error to stream as per spec for GET, just close
65
76
  connection_active.make_false
66
- return # Error logged, connection will close in ensure block
77
+ return
67
78
  end
68
79
 
69
- # Handle resumability by sending missed events if Last-Event-ID is provided
70
80
  if last_event_id.present? && last_event_id.to_i.positive?
71
81
  begin
72
- # Fetch events that occurred after the Last-Event-ID
73
82
  missed_events = session.get_sse_events_after(last_event_id.to_i)
74
-
75
83
  if missed_events.any?
76
84
  Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
77
-
78
- # Send each missed event to the client
79
85
  missed_events.each do |event|
80
- sse.stream.write(event.to_sse)
86
+ sse.write(event.to_sse)
81
87
  end
82
88
  else
83
89
  Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
@@ -87,14 +93,12 @@ module ActionMCP
87
93
  end
88
94
  end
89
95
 
90
- # Heartbeat sender proc
91
96
  heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
92
97
  heartbeat_sender = lambda do
93
98
  if connection_active.true? && !response.stream.closed?
94
99
  begin
95
- # Use helper to send ping with event ID
96
100
  future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
97
- future.value!(5) # 5 second timeout for write
101
+ future.value!(5)
98
102
  if heartbeat_active.true?
99
103
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
100
104
  end
@@ -110,26 +114,18 @@ module ActionMCP
110
114
  end
111
115
  end
112
116
 
113
- # Start first heartbeat
114
117
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
115
-
116
- # Keep connection alive while active
117
118
  sleep 0.1 while connection_active.true? && !response.stream.closed?
118
119
  rescue ActionController::Live::ClientDisconnected, IOError => e
119
120
  Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
120
121
  rescue StandardError => e
121
122
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
122
123
  ensure
123
- # Cleanup
124
124
  Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
125
125
  heartbeat_active&.make_false
126
126
  heartbeat_task&.cancel
127
127
  listener&.stop
128
-
129
- # Clean up old SSE events if resumability is enabled
130
128
  cleanup_old_sse_events(session) if session
131
-
132
- # Don't close the session itself here, it might be used by other connections/requests
133
129
  sse&.close
134
130
  begin
135
131
  response.stream&.close
@@ -141,22 +137,18 @@ module ActionMCP
141
137
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
142
138
  # @route POST /mcp
143
139
  def create
144
- # 1. Check Accept Header
145
- unless accepts_valid_content_types?
146
- return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
140
+ unless post_accept_headers_valid?
141
+ return render_not_acceptable(post_accept_headers_error_message)
147
142
  end
148
143
 
149
- # 2. Determine if this is an initialize request (before session check)
150
144
  is_initialize_request = check_if_initialize_request(jsonrpc_params)
151
-
152
- # 3. Check Session (unless it's an initialize request)
153
145
  session_initially_missing = extract_session_id.nil?
154
- session = mcp_session # This finds or initializes
146
+ session = mcp_session
155
147
 
156
148
  unless is_initialize_request
157
149
  if session_initially_missing
158
150
  return render_bad_request("Mcp-Session-Id header is required for this request.")
159
- elsif session.nil? || session.new_record? # Should be found if ID was provided
151
+ elsif session.nil? || session.new_record?
160
152
  return render_not_found("Session not found.")
161
153
  elsif session.status == "closed"
162
154
  return render_not_found("Session has been terminated.")
@@ -168,17 +160,11 @@ module ActionMCP
168
160
  response.headers[MCP_SESSION_ID_HEADER] = session.id
169
161
  end
170
162
 
171
- # 4. Instantiate Handlers
172
163
  transport_handler = Server::TransportHandler.new(session)
173
164
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
174
-
175
- # 5. Call Handler
176
165
  handler_results = json_rpc_handler.call(jsonrpc_params)
177
-
178
- # 6. Process Results
179
166
  process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
180
167
  rescue ActionController::Live::ClientDisconnected, IOError => e
181
- # Ensure stream is closed if SSE response was attempted and client disconnected
182
168
  Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
183
169
  begin
184
170
  response.stream&.close
@@ -191,25 +177,20 @@ module ActionMCP
191
177
  end
192
178
 
193
179
  # Handles DELETE requests for session termination (2025-03-26 spec).
194
- # @route DELETE /mcp
180
+ # @route DELETE /
195
181
  def destroy
196
- # 1. Check Session Header
197
182
  session_id_from_header = extract_session_id
198
183
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
199
184
 
200
- # 2. Find Session
201
185
  session = Session.find_by(id: session_id_from_header)
202
-
203
186
  if session.nil?
204
187
  return render_not_found("Session not found.")
205
188
  elsif session.status == "closed"
206
- # Session already closed, treat as success (idempotent)
207
189
  return head :no_content
208
190
  end
209
191
 
210
- # 3. Terminate Session
211
192
  begin
212
- session.close! # This should handle cleanup like unsubscribing etc.
193
+ session.close!
213
194
  Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
214
195
  head :no_content
215
196
  rescue StandardError => e
@@ -220,6 +201,21 @@ module ActionMCP
220
201
 
221
202
  private
222
203
 
204
+ # Finds an existing session based on header or param, or initializes a new one.
205
+ # Note: This doesn't save the new session; that happens upon first use or explicitly.
206
+ def find_or_initialize_session
207
+ session_id = extract_session_id
208
+ if session_id
209
+ session = Session.find_by(id: session_id)
210
+ if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
211
+ session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
212
+ end
213
+ session
214
+ else
215
+ Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
216
+ end
217
+ end
218
+
223
219
  # @return [String, nil] The extracted session ID or nil if not found.
224
220
  def extract_session_id
225
221
  request.headers[MCP_SESSION_ID_HEADER].presence
@@ -231,66 +227,64 @@ module ActionMCP
231
227
  request.accepts.any? { |type| type.to_s == "text/event-stream" }
232
228
  end
233
229
 
230
+ # Checks if the Accept headers for POST are valid according to server preference.
231
+ def post_accept_headers_valid?
232
+ if ActionMCP.configuration.post_response_preference == :sse
233
+ accepts_valid_content_types?
234
+ else
235
+ request.accepts.any? { |type| type.to_s == "application/json" }
236
+ end
237
+ end
238
+
239
+ # Returns the appropriate error message for POST Accept header validation.
240
+ def post_accept_headers_error_message
241
+ if ActionMCP.configuration.post_response_preference == :sse
242
+ "Client must accept 'application/json' and 'text/event-stream'"
243
+ else
244
+ "Client must accept 'application/json'"
245
+ end
246
+ end
247
+
234
248
  # Checks if the parsed body represents an 'initialize' request.
235
249
  def check_if_initialize_request(payload)
236
250
  return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
237
-
238
251
  payload.method == "initialize"
239
252
  end
240
253
 
241
254
  # Processes the results from the JsonRpcHandler.
242
255
  def process_handler_results(results, session, session_initially_missing, is_initialize_request)
243
- # Make sure we always have a results hash
244
256
  results ||= {}
245
-
246
- # Check if this is a notification request
247
257
  is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
248
-
249
- # Extract request ID from results
250
258
  request_id = nil
251
259
  if results.is_a?(Hash)
252
260
  request_id = results[:request_id] || results[:id]
253
- # If we have a payload that's a response, extract ID from there as well
254
261
  request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
255
262
  end
256
-
257
- # Default to empty hash for response payload if nil
258
263
  result_type = results[:type]
259
264
  result_payload = results[:payload] || {}
260
-
261
- # Ensure payload has the correct ID if it's a hash
262
265
  result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
263
266
 
264
267
  case result_type
265
268
  when :error
266
- # Ensure error responses preserve the ID
267
269
  error_payload = result_payload
268
270
  error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
269
271
  render json: error_payload, status: results.fetch(:status, :bad_request)
270
-
271
272
  when :notifications_only
272
273
  head :accepted
273
-
274
274
  when :responses
275
275
  server_preference = ActionMCP.configuration.post_response_preference
276
276
  use_sse = (server_preference == :sse)
277
277
  add_session_header = is_initialize_request && session_initially_missing && session.persisted?
278
-
279
278
  if use_sse
280
279
  render_sse_response(result_payload, session, add_session_header)
281
280
  else
282
281
  render_json_response(result_payload, session, add_session_header)
283
282
  end
284
-
285
283
  else
286
- # Handle unknown result types
287
284
  Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
288
-
289
- # If the original request was a notification, don't send back a response with an ID
290
285
  if is_notification
291
286
  head :accepted
292
287
  else
293
- # For regular requests, return a proper JSON-RPC response
294
288
  render json: {
295
289
  jsonrpc: "2.0",
296
290
  id: request_id,
@@ -314,11 +308,9 @@ module ActionMCP
314
308
  response.headers["X-Accel-Buffering"] = "no"
315
309
  response.headers["Cache-Control"] = "no-cache"
316
310
  response.headers["Connection"] = "keep-alive"
317
-
318
311
  sse = SSE.new(response.stream)
319
312
  write_sse_event(sse, session, payload)
320
313
  ensure
321
- # Close the stream after sending the response(s)
322
314
  sse&.close
323
315
  begin
324
316
  response.stream&.close
@@ -328,53 +320,68 @@ module ActionMCP
328
320
  Rails.logger.debug "Unified SSE (POST): Response stream closed."
329
321
  end
330
322
 
331
- # Renders a 500 Internal Server Error response.
332
- def render_internal_server_error(message = "Internal Server Error", id = nil)
333
- # Using -32000 for generic server error
334
- render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
323
+ # Helper to write a JSON payload as an SSE event with a unique ID.
324
+ # Also stores the event for potential resumability.
325
+ def write_sse_event(sse, session, payload)
326
+ event_id = session.increment_sse_counter!
327
+ data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
328
+ sse_event = "id: #{event_id}\ndata: #{data}\n\n"
329
+ sse.write(sse_event)
330
+ return unless ActionMCP.configuration.enable_sse_resumability
331
+ begin
332
+ session.store_sse_event(event_id, payload, session.max_stored_sse_events)
333
+ rescue StandardError => e
334
+ Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
335
+ end
335
336
  end
336
337
 
337
338
  # Helper to clean up old SSE events for a session
338
339
  def cleanup_old_sse_events(session)
339
340
  return unless ActionMCP.configuration.enable_sse_resumability
340
-
341
341
  begin
342
- # Get retention period from configuration
343
342
  retention_period = session.sse_event_retention_period
344
343
  count = session.cleanup_old_sse_events(retention_period)
345
-
346
344
  Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
347
345
  rescue StandardError => e
348
346
  Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
349
347
  end
350
348
  end
351
349
 
352
- # Helper to write a JSON payload as an SSE event with a unique ID.
353
- # Also stores the event for potential resumability.
354
- def write_sse_event(sse, session, payload)
355
- event_id = session.increment_sse_counter!
350
+ def format_tools_list(tools, session)
351
+ protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
352
+ tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
353
+ end
356
354
 
357
- # Manually format the SSE event string including the ID
358
- data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
359
- sse_event = "id: #{event_id}\ndata: #{data}\n\n"
355
+ # --- Error Rendering Methods ---
360
356
 
361
- # Write to the stream
362
- sse.stream.write(sse_event)
357
+ # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
358
+ def render_bad_request(message = "Bad Request")
359
+ render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
360
+ end
363
361
 
364
- # Store the event for potential resumption if resumability is enabled
365
- return unless ActionMCP.configuration.enable_sse_resumability
362
+ # Renders a 404 Not Found response with a JSON-RPC-like error structure.
363
+ def render_not_found(message = "Not Found")
364
+ render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
365
+ end
366
366
 
367
- begin
368
- session.store_sse_event(event_id, payload, session.max_stored_sse_events)
369
- rescue StandardError => e
370
- Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
371
- end
367
+ # Renders a 405 Method Not Allowed response.
368
+ def render_method_not_allowed(message = "Method Not Allowed")
369
+ render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
372
370
  end
373
371
 
374
- def format_tools_list(tools, session)
375
- # Pass the session's protocol version when formatting tools
376
- protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
377
- tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
372
+ # Renders a 406 Not Acceptable response.
373
+ def render_not_acceptable(message = "Not Acceptable")
374
+ render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
375
+ end
376
+
377
+ # Renders a 501 Not Implemented response.
378
+ def render_not_implemented(message = "Not Implemented")
379
+ render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
380
+ end
381
+
382
+ # Renders a 500 Internal Server Error response.
383
+ def render_internal_server_error(message = "Internal Server Error", id = nil)
384
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
378
385
  end
379
386
  end
380
387
  end
data/config/routes.rb CHANGED
@@ -4,7 +4,7 @@ ActionMCP::Engine.routes.draw do
4
4
  get "/up", to: "/rails/health#show", as: :action_mcp_health_check
5
5
 
6
6
  # MCP 2025-03-26 Spec routes
7
- get "/", to: "unified#show", as: :mcp_get
8
- post "/", to: "unified#create", as: :mcp_post
9
- delete "/", to: "unified#destroy", as: :mcp_delete
7
+ get "/", to: "application#show", as: :mcp_get
8
+ post "/", to: "application#create", as: :mcp_post
9
+ delete "/", to: "application#destroy", as: :mcp_delete
10
10
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.50.2"
5
+ VERSION = "0.50.4"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.50.2
4
+ version: 0.50.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-05-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -106,8 +105,7 @@ files:
106
105
  - MIT-LICENSE
107
106
  - README.md
108
107
  - Rakefile
109
- - app/controllers/action_mcp/mcp_controller.rb
110
- - app/controllers/action_mcp/unified_controller.rb
108
+ - app/controllers/action_mcp/application_controller.rb
111
109
  - app/models/action_mcp.rb
112
110
  - app/models/action_mcp/application_record.rb
113
111
  - app/models/action_mcp/session.rb
@@ -215,7 +213,6 @@ metadata:
215
213
  source_code_uri: https://github.com/seuros/action_mcp
216
214
  changelog_uri: https://github.com/seuros/action_mcp/blob/master/CHANGELOG.md
217
215
  rubygems_mfa_required: 'true'
218
- post_install_message:
219
216
  rdoc_options: []
220
217
  require_paths:
221
218
  - lib
@@ -230,8 +227,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
230
227
  - !ruby/object:Gem::Version
231
228
  version: '0'
232
229
  requirements: []
233
- rubygems_version: 3.5.22
234
- signing_key:
230
+ rubygems_version: 3.6.7
235
231
  specification_version: 4
236
232
  summary: Provides essential tooling for building Model Context Protocol (MCP) capable
237
233
  servers
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- class MCPController < ActionController::Metal
5
- abstract!
6
- ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
7
- include left
8
- end
9
- include Engine.routes.url_helpers
10
-
11
- # Header name for MCP Session ID (as per 2025-03-26 spec)
12
- MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
13
-
14
- # Provides the ActionMCP::Session for the current request.
15
- # Handles finding existing sessions via header/param or initializing a new one.
16
- # Specific controllers/handlers might need to enforce session ID presence based on context.
17
- # @return [ActionMCP::Session] The session object (might be unsaved if new)
18
- def mcp_session
19
- @mcp_session ||= find_or_initialize_session
20
- end
21
-
22
- # Provides a unique key for caching or pub/sub based on the session ID.
23
- # Ensures mcp_session is called first to establish the session ID.
24
- # @return [String] The session key string.
25
- def session_key
26
- @session_key ||= "action_mcp-sessions-#{mcp_session.id}"
27
- end
28
-
29
- private
30
-
31
- # Finds an existing session based on header or param, or initializes a new one.
32
- # Note: This doesn't save the new session; that happens upon first use or explicitly.
33
- def find_or_initialize_session
34
- session_id = extract_session_id
35
- if 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
42
- else
43
- # Create new session with 2025 protocol
44
- Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
45
- end
46
- end
47
-
48
- # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
49
- def render_bad_request(message = "Bad Request")
50
- # Using -32600 for Invalid Request based on JSON-RPC spec
51
- render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
52
- end
53
-
54
- # Renders a 404 Not Found response with a JSON-RPC-like error structure.
55
- def render_not_found(message = "Not Found")
56
- # Using a custom code or a generic server error range code might be appropriate.
57
- # Let's use -32001 for a generic server error.
58
- render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
59
- end
60
-
61
- # Renders a 405 Method Not Allowed response.
62
- def render_method_not_allowed(message = "Method Not Allowed")
63
- # Using -32601 Method not found from JSON-RPC spec seems applicable
64
- render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
65
- end
66
-
67
- # Renders a 406 Not Acceptable response.
68
- def render_not_acceptable(message = "Not Acceptable")
69
- # No direct JSON-RPC equivalent, using a generic server error code.
70
- render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
71
- end
72
-
73
- # Renders a 501 Not Implemented response.
74
- def render_not_implemented(message = "Not Implemented")
75
- # No direct JSON-RPC equivalent, using a generic server error code.
76
- render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
77
- end
78
- end
79
- end