actionmcp 0.50.2 → 0.50.3

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: 7c5d576f3bc8c02f3682c056b8145b9af5ad85ad581a131b4bb7dccdd14b0531
4
+ data.tar.gz: 8233276b69f4033c8b62a015f3960ef8503f16344f3be0c17106441994146d9d
5
5
  SHA512:
6
- metadata.gz: ef0c162837326d3e4514e51123eab9597cfa04aa6a9ca1a790b607d460b9544e5d372171f7bb78d38e5c3d7298090ca2dfd5a3228c4c9b6b5feeb126912cff4a
7
- data.tar.gz: e88d3401976a55d7144b3359156bb2d110fa597da8a4df5e22898fc8e3d724f7b6ab42e6b70f53b48e966ba1616057a9e2881f917bae347470160b399e4b97b3
6
+ metadata.gz: 5c20d55384bd4b62831f6613c0aecd1e32ffdd4fe2918f315b512fd43ec7065c9ecd8c3a5d62a5c578c868a6c3de8a4989ae54dd9563d58c0b9570affad1833b
7
+ data.tar.gz: da098c411863f6f033ed88aa31cf8745548bd32ec7b9074c9db789e46c065bdf3243e4a596a05459626c484c7a44a3913f6591e90e15c90d7eea51d3609e5482
@@ -4,39 +4,58 @@ 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
+
33
+ # --- MCP UnifiedController actions ---
34
+
13
35
  # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
14
- # @route GET /mcp
36
+ # @route GET /
15
37
  def show
16
- # 1. Check Accept Header
17
- unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
18
- return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
38
+ if ActionMCP.configuration.post_response_preference == :sse
39
+ unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
40
+ return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
41
+ end
19
42
  end
20
43
 
21
- # 2. Check Session (Must exist and be initialized)
22
44
  session_id_from_header = extract_session_id
23
45
  return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
24
46
 
25
- session = mcp_session # Finds based on header
47
+ session = mcp_session
26
48
  if session.nil? || session.new_record?
27
49
  return render_not_found("Session not found.")
28
50
  elsif !session.initialized?
29
- # Spec doesn't explicitly forbid GET before initialized, but it seems logical
30
51
  return render_bad_request("Session is not fully initialized.")
31
52
  elsif session.status == "closed"
32
53
  return render_not_found("Session has been terminated.")
33
54
  end
34
55
 
35
- # Check for Last-Event-ID header for resumability
36
56
  last_event_id = request.headers["Last-Event-ID"].presence
37
57
  Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
38
58
 
39
- # 3. Set SSE Headers
40
59
  response.headers["Content-Type"] = "text/event-stream"
41
60
  response.headers["X-Accel-Buffering"] = "no"
42
61
  response.headers["Cache-Control"] = "no-cache"
@@ -44,7 +63,6 @@ module ActionMCP
44
63
 
45
64
  Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
46
65
 
47
- # 4. Setup Stream, Listener, and Heartbeat
48
66
  sse = SSE.new(response.stream)
49
67
  listener = SSEListener.new(session)
50
68
  connection_active = Concurrent::AtomicBoolean.new
@@ -53,31 +71,23 @@ module ActionMCP
53
71
  heartbeat_active.make_true
54
72
  heartbeat_task = nil
55
73
 
56
- # Start listener
57
74
  listener_started = listener.start do |message|
58
- # Write message using helper to include event ID
59
75
  write_sse_event(sse, session, message)
60
76
  end
61
77
 
62
78
  unless listener_started
63
79
  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
80
  connection_active.make_false
66
- return # Error logged, connection will close in ensure block
81
+ return
67
82
  end
68
83
 
69
- # Handle resumability by sending missed events if Last-Event-ID is provided
70
84
  if last_event_id.present? && last_event_id.to_i.positive?
71
85
  begin
72
- # Fetch events that occurred after the Last-Event-ID
73
86
  missed_events = session.get_sse_events_after(last_event_id.to_i)
74
-
75
87
  if missed_events.any?
76
88
  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
89
  missed_events.each do |event|
80
- sse.stream.write(event.to_sse)
90
+ sse.write(event.to_sse)
81
91
  end
82
92
  else
83
93
  Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
@@ -87,14 +97,12 @@ module ActionMCP
87
97
  end
88
98
  end
89
99
 
90
- # Heartbeat sender proc
91
100
  heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
92
101
  heartbeat_sender = lambda do
93
102
  if connection_active.true? && !response.stream.closed?
94
103
  begin
95
- # Use helper to send ping with event ID
96
104
  future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
97
- future.value!(5) # 5 second timeout for write
105
+ future.value!(5)
98
106
  if heartbeat_active.true?
99
107
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
100
108
  end
@@ -110,26 +118,18 @@ module ActionMCP
110
118
  end
111
119
  end
112
120
 
113
- # Start first heartbeat
114
121
  heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
115
-
116
- # Keep connection alive while active
117
122
  sleep 0.1 while connection_active.true? && !response.stream.closed?
118
123
  rescue ActionController::Live::ClientDisconnected, IOError => e
119
124
  Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
120
125
  rescue StandardError => e
121
126
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
122
127
  ensure
123
- # Cleanup
124
128
  Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
125
129
  heartbeat_active&.make_false
126
130
  heartbeat_task&.cancel
127
131
  listener&.stop
128
-
129
- # Clean up old SSE events if resumability is enabled
130
132
  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
133
  sse&.close
134
134
  begin
135
135
  response.stream&.close
@@ -141,22 +141,18 @@ module ActionMCP
141
141
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
142
142
  # @route POST /mcp
143
143
  def create
144
- # 1. Check Accept Header
145
144
  unless accepts_valid_content_types?
146
145
  return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
147
146
  end
148
147
 
149
- # 2. Determine if this is an initialize request (before session check)
150
148
  is_initialize_request = check_if_initialize_request(jsonrpc_params)
151
-
152
- # 3. Check Session (unless it's an initialize request)
153
149
  session_initially_missing = extract_session_id.nil?
154
- session = mcp_session # This finds or initializes
150
+ session = mcp_session
155
151
 
156
152
  unless is_initialize_request
157
153
  if session_initially_missing
158
154
  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
155
+ elsif session.nil? || session.new_record?
160
156
  return render_not_found("Session not found.")
161
157
  elsif session.status == "closed"
162
158
  return render_not_found("Session has been terminated.")
@@ -168,17 +164,11 @@ module ActionMCP
168
164
  response.headers[MCP_SESSION_ID_HEADER] = session.id
169
165
  end
170
166
 
171
- # 4. Instantiate Handlers
172
167
  transport_handler = Server::TransportHandler.new(session)
173
168
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
174
-
175
- # 5. Call Handler
176
169
  handler_results = json_rpc_handler.call(jsonrpc_params)
177
-
178
- # 6. Process Results
179
170
  process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
180
171
  rescue ActionController::Live::ClientDisconnected, IOError => e
181
- # Ensure stream is closed if SSE response was attempted and client disconnected
182
172
  Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
183
173
  begin
184
174
  response.stream&.close
@@ -191,25 +181,20 @@ module ActionMCP
191
181
  end
192
182
 
193
183
  # Handles DELETE requests for session termination (2025-03-26 spec).
194
- # @route DELETE /mcp
184
+ # @route DELETE /
195
185
  def destroy
196
- # 1. Check Session Header
197
186
  session_id_from_header = extract_session_id
198
187
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
199
188
 
200
- # 2. Find Session
201
189
  session = Session.find_by(id: session_id_from_header)
202
-
203
190
  if session.nil?
204
191
  return render_not_found("Session not found.")
205
192
  elsif session.status == "closed"
206
- # Session already closed, treat as success (idempotent)
207
193
  return head :no_content
208
194
  end
209
195
 
210
- # 3. Terminate Session
211
196
  begin
212
- session.close! # This should handle cleanup like unsubscribing etc.
197
+ session.close!
213
198
  Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
214
199
  head :no_content
215
200
  rescue StandardError => e
@@ -220,6 +205,21 @@ module ActionMCP
220
205
 
221
206
  private
222
207
 
208
+ # Finds an existing session based on header or param, or initializes a new one.
209
+ # Note: This doesn't save the new session; that happens upon first use or explicitly.
210
+ def find_or_initialize_session
211
+ session_id = extract_session_id
212
+ if session_id
213
+ session = Session.find_by(id: session_id)
214
+ if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
215
+ session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
216
+ end
217
+ session
218
+ else
219
+ Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
220
+ end
221
+ end
222
+
223
223
  # @return [String, nil] The extracted session ID or nil if not found.
224
224
  def extract_session_id
225
225
  request.headers[MCP_SESSION_ID_HEADER].presence
@@ -234,63 +234,43 @@ module ActionMCP
234
234
  # Checks if the parsed body represents an 'initialize' request.
235
235
  def check_if_initialize_request(payload)
236
236
  return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
237
-
238
237
  payload.method == "initialize"
239
238
  end
240
239
 
241
240
  # Processes the results from the JsonRpcHandler.
242
241
  def process_handler_results(results, session, session_initially_missing, is_initialize_request)
243
- # Make sure we always have a results hash
244
242
  results ||= {}
245
-
246
- # Check if this is a notification request
247
243
  is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
248
-
249
- # Extract request ID from results
250
244
  request_id = nil
251
245
  if results.is_a?(Hash)
252
246
  request_id = results[:request_id] || results[:id]
253
- # If we have a payload that's a response, extract ID from there as well
254
247
  request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
255
248
  end
256
-
257
- # Default to empty hash for response payload if nil
258
249
  result_type = results[:type]
259
250
  result_payload = results[:payload] || {}
260
-
261
- # Ensure payload has the correct ID if it's a hash
262
251
  result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
263
252
 
264
253
  case result_type
265
254
  when :error
266
- # Ensure error responses preserve the ID
267
255
  error_payload = result_payload
268
256
  error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
269
257
  render json: error_payload, status: results.fetch(:status, :bad_request)
270
-
271
258
  when :notifications_only
272
259
  head :accepted
273
-
274
260
  when :responses
275
261
  server_preference = ActionMCP.configuration.post_response_preference
276
262
  use_sse = (server_preference == :sse)
277
263
  add_session_header = is_initialize_request && session_initially_missing && session.persisted?
278
-
279
264
  if use_sse
280
265
  render_sse_response(result_payload, session, add_session_header)
281
266
  else
282
267
  render_json_response(result_payload, session, add_session_header)
283
268
  end
284
-
285
269
  else
286
- # Handle unknown result types
287
270
  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
271
  if is_notification
291
272
  head :accepted
292
273
  else
293
- # For regular requests, return a proper JSON-RPC response
294
274
  render json: {
295
275
  jsonrpc: "2.0",
296
276
  id: request_id,
@@ -314,11 +294,9 @@ module ActionMCP
314
294
  response.headers["X-Accel-Buffering"] = "no"
315
295
  response.headers["Cache-Control"] = "no-cache"
316
296
  response.headers["Connection"] = "keep-alive"
317
-
318
297
  sse = SSE.new(response.stream)
319
298
  write_sse_event(sse, session, payload)
320
299
  ensure
321
- # Close the stream after sending the response(s)
322
300
  sse&.close
323
301
  begin
324
302
  response.stream&.close
@@ -328,53 +306,68 @@ module ActionMCP
328
306
  Rails.logger.debug "Unified SSE (POST): Response stream closed."
329
307
  end
330
308
 
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 } }
309
+ # Helper to write a JSON payload as an SSE event with a unique ID.
310
+ # Also stores the event for potential resumability.
311
+ def write_sse_event(sse, session, payload)
312
+ event_id = session.increment_sse_counter!
313
+ data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
314
+ sse_event = "id: #{event_id}\ndata: #{data}\n\n"
315
+ sse.write(sse_event)
316
+ return unless ActionMCP.configuration.enable_sse_resumability
317
+ begin
318
+ session.store_sse_event(event_id, payload, session.max_stored_sse_events)
319
+ rescue StandardError => e
320
+ Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
321
+ end
335
322
  end
336
323
 
337
324
  # Helper to clean up old SSE events for a session
338
325
  def cleanup_old_sse_events(session)
339
326
  return unless ActionMCP.configuration.enable_sse_resumability
340
-
341
327
  begin
342
- # Get retention period from configuration
343
328
  retention_period = session.sse_event_retention_period
344
329
  count = session.cleanup_old_sse_events(retention_period)
345
-
346
330
  Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
347
331
  rescue StandardError => e
348
332
  Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
349
333
  end
350
334
  end
351
335
 
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!
336
+ def format_tools_list(tools, session)
337
+ protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
338
+ tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
339
+ end
356
340
 
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"
341
+ # --- Error Rendering Methods ---
360
342
 
361
- # Write to the stream
362
- sse.stream.write(sse_event)
343
+ # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
344
+ def render_bad_request(message = "Bad Request")
345
+ render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
346
+ end
363
347
 
364
- # Store the event for potential resumption if resumability is enabled
365
- return unless ActionMCP.configuration.enable_sse_resumability
348
+ # Renders a 404 Not Found response with a JSON-RPC-like error structure.
349
+ def render_not_found(message = "Not Found")
350
+ render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
351
+ end
366
352
 
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
353
+ # Renders a 405 Method Not Allowed response.
354
+ def render_method_not_allowed(message = "Method Not Allowed")
355
+ render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
372
356
  end
373
357
 
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) }
358
+ # Renders a 406 Not Acceptable response.
359
+ def render_not_acceptable(message = "Not Acceptable")
360
+ render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
361
+ end
362
+
363
+ # Renders a 501 Not Implemented response.
364
+ def render_not_implemented(message = "Not Implemented")
365
+ render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
366
+ end
367
+
368
+ # Renders a 500 Internal Server Error response.
369
+ def render_internal_server_error(message = "Internal Server Error", id = nil)
370
+ render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
378
371
  end
379
372
  end
380
373
  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.3"
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.3
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