actionmcp 0.50.1 → 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: 6673ed8cefa0458b617bcb9ceb865c4aef47a5448752cbe230dc950afea1941f
4
- data.tar.gz: 68b8c888e2983cea309cd9498d1bde1887025d37058deabeb1b844c0ae3213d8
3
+ metadata.gz: 7c5d576f3bc8c02f3682c056b8145b9af5ad85ad581a131b4bb7dccdd14b0531
4
+ data.tar.gz: 8233276b69f4033c8b62a015f3960ef8503f16344f3be0c17106441994146d9d
5
5
  SHA512:
6
- metadata.gz: 1493f47a18bcc5892c84efcfd51840b50cb6d69c9834c0d114cdaa8fe4a7f040d2ac9d84aec06a9599f25135e797bf85e056c7f602571fe6c47f07928e3c6aa9
7
- data.tar.gz: a4d9cf8658e976d05d8330b9349d143cdf49abc281e974d40f9b70d11592ec619d1e55b47f74c5e6e94865d05a7d8c8fae363e1d98d1bed738fa7be2ee5c4ff2
6
+ metadata.gz: 5c20d55384bd4b62831f6613c0aecd1e32ffdd4fe2918f315b512fd43ec7065c9ecd8c3a5d62a5c578c868a6c3de8a4989ae54dd9563d58c0b9570affad1833b
7
+ data.tar.gz: da098c411863f6f033ed88aa31cf8745548bd32ec7b9074c9db789e46c065bdf3243e4a596a05459626c484c7a44a3913f6591e90e15c90d7eea51d3609e5482
@@ -4,40 +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
- # TODO: Include Instrumentation::ControllerRuntime if needed for metrics
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 ---
13
34
 
14
35
  # Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
15
- # @route GET /mcp
36
+ # @route GET /
16
37
  def show
17
- # 1. Check Accept Header
18
- unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
19
- 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
20
42
  end
21
43
 
22
- # 2. Check Session (Must exist and be initialized)
23
44
  session_id_from_header = extract_session_id
24
45
  return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
25
46
 
26
- session = mcp_session # Finds based on header
47
+ session = mcp_session
27
48
  if session.nil? || session.new_record?
28
49
  return render_not_found("Session not found.")
29
50
  elsif !session.initialized?
30
- # Spec doesn't explicitly forbid GET before initialized, but it seems logical
31
51
  return render_bad_request("Session is not fully initialized.")
32
52
  elsif session.status == "closed"
33
53
  return render_not_found("Session has been terminated.")
34
54
  end
35
55
 
36
- # Check for Last-Event-ID header for resumability
37
56
  last_event_id = request.headers["Last-Event-ID"].presence
38
57
  Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
39
58
 
40
- # 3. Set SSE Headers
41
59
  response.headers["Content-Type"] = "text/event-stream"
42
60
  response.headers["X-Accel-Buffering"] = "no"
43
61
  response.headers["Cache-Control"] = "no-cache"
@@ -45,59 +63,48 @@ module ActionMCP
45
63
 
46
64
  Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
47
65
 
48
- # 4. Setup Stream, Listener, and Heartbeat
49
66
  sse = SSE.new(response.stream)
50
- listener = SSEListener.new(session) # Use the listener class (defined below or moved)
67
+ listener = SSEListener.new(session)
51
68
  connection_active = Concurrent::AtomicBoolean.new
52
69
  connection_active.make_true
53
70
  heartbeat_active = Concurrent::AtomicBoolean.new
54
71
  heartbeat_active.make_true
55
72
  heartbeat_task = nil
56
73
 
57
- # Start listener
58
74
  listener_started = listener.start do |message|
59
- # Write message using helper to include event ID
60
75
  write_sse_event(sse, session, message)
61
76
  end
62
77
 
63
78
  unless listener_started
64
79
  Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
65
- # Don't write error to stream as per spec for GET, just close
66
80
  connection_active.make_false
67
- return # Error logged, connection will close in ensure block
81
+ return
68
82
  end
69
83
 
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
84
+ if last_event_id.present? && last_event_id.to_i.positive?
72
85
  begin
73
- # Fetch events that occurred after the Last-Event-ID
74
86
  missed_events = session.get_sse_events_after(last_event_id.to_i)
75
-
76
87
  if missed_events.any?
77
88
  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
89
  missed_events.each do |event|
81
- sse.stream.write(event.to_sse)
90
+ sse.write(event.to_sse)
82
91
  end
83
92
  else
84
93
  Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
85
94
  end
86
- rescue => e
95
+ rescue StandardError => e
87
96
  Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
88
97
  end
89
98
  end
90
99
 
91
- # Heartbeat sender proc
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
- heartbeat_task = Concurrent::ScheduledTask.execute(ActionMCP.configuration.sse_heartbeat_interval,
100
- &heartbeat_sender)
107
+ heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
101
108
  end
102
109
  rescue Concurrent::TimeoutError
103
110
  Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
@@ -111,26 +118,18 @@ module ActionMCP
111
118
  end
112
119
  end
113
120
 
114
- # Start first heartbeat
115
- heartbeat_task = Concurrent::ScheduledTask.execute(HEARTBEAT_INTERVAL, &heartbeat_sender)
116
-
117
- # Keep connection alive while active
121
+ heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
118
122
  sleep 0.1 while connection_active.true? && !response.stream.closed?
119
123
  rescue ActionController::Live::ClientDisconnected, IOError => e
120
124
  Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
121
125
  rescue StandardError => e
122
126
  Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
123
127
  ensure
124
- # Cleanup
125
128
  Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
126
129
  heartbeat_active&.make_false
127
130
  heartbeat_task&.cancel
128
131
  listener&.stop
129
-
130
- # Clean up old SSE events if resumability is enabled
131
132
  cleanup_old_sse_events(session) if session
132
-
133
- # Don't close the session itself here, it might be used by other connections/requests
134
133
  sse&.close
135
134
  begin
136
135
  response.stream&.close
@@ -142,43 +141,35 @@ module ActionMCP
142
141
  # Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
143
142
  # @route POST /mcp
144
143
  def create
145
- # 1. Check Accept Header
146
144
  unless accepts_valid_content_types?
147
145
  return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
148
146
  end
149
147
 
150
- # Determine if this is an initialize request (before session check)
151
148
  is_initialize_request = check_if_initialize_request(jsonrpc_params)
152
-
153
- # 3. Check Session (unless it's an initialize request)
154
149
  session_initially_missing = extract_session_id.nil?
155
- session = mcp_session # This finds or initializes
150
+ session = mcp_session
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.")
163
159
  end
164
160
  end
161
+
165
162
  if session.new_record?
166
163
  session.save!
167
164
  response.headers[MCP_SESSION_ID_HEADER] = session.id
168
165
  end
169
- # 4. Instantiate Handlers
166
+
170
167
  transport_handler = Server::TransportHandler.new(session)
171
168
  json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
172
-
173
- # 5. Call Handler
174
- handler_results = json_rpc_handler.call(jsonrpc_params.to_h)
175
-
176
- # 6. Process Results
169
+ handler_results = json_rpc_handler.call(jsonrpc_params)
177
170
  process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
178
171
  rescue ActionController::Live::ClientDisconnected, IOError => e
179
- # Ensure stream is closed if SSE response was attempted and client disconnected
180
172
  Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
181
- # Ensure stream is closed, cleanup might happen in ensure block if needed
182
173
  begin
183
174
  response.stream&.close
184
175
  rescue StandardError
@@ -190,26 +181,20 @@ module ActionMCP
190
181
  end
191
182
 
192
183
  # Handles DELETE requests for session termination (2025-03-26 spec).
193
- # @route DELETE /mcp
184
+ # @route DELETE /
194
185
  def destroy
195
- # 1. Check Session Header
196
186
  session_id_from_header = extract_session_id
197
187
  return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
198
188
 
199
- # 2. Find Session
200
- # Note: mcp_session helper finds based on header, but doesn't raise error if not found
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
@@ -239,72 +239,44 @@ module ActionMCP
239
239
 
240
240
  # Processes the results from the JsonRpcHandler.
241
241
  def process_handler_results(results, session, session_initially_missing, is_initialize_request)
242
- # Make sure we always have a results hash
243
242
  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
243
+ is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
251
244
  request_id = nil
252
245
  if results.is_a?(Hash)
253
246
  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
247
+ request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
259
248
  end
260
-
261
- # Default to empty hash for response payload if nil
262
249
  result_type = results[:type]
263
250
  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)
251
+ result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
274
252
 
275
253
  case result_type
276
254
  when :error
277
- # Ensure error responses preserve the ID
278
255
  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
256
+ error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
282
257
  render json: error_payload, status: results.fetch(:status, :bad_request)
283
258
  when :notifications_only
284
259
  head :accepted
285
260
  when :responses
286
261
  server_preference = ActionMCP.configuration.post_response_preference
287
262
  use_sse = (server_preference == :sse)
288
-
289
263
  add_session_header = is_initialize_request && session_initially_missing && session.persisted?
290
-
291
264
  if use_sse
292
265
  render_sse_response(result_payload, session, add_session_header)
293
266
  else
294
267
  render_json_response(result_payload, session, add_session_header)
295
268
  end
296
269
  else
297
- # This was causing the "Unknown handler result type: " error
298
270
  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
271
+ if is_notification
272
+ head :accepted
273
+ else
274
+ render json: {
275
+ jsonrpc: "2.0",
276
+ id: request_id,
277
+ result: result_payload
278
+ }, status: :ok
279
+ end
308
280
  end
309
281
  end
310
282
 
@@ -312,29 +284,19 @@ module ActionMCP
312
284
  def render_json_response(payload, session, add_session_header)
313
285
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
314
286
  response.headers["Content-Type"] = "application/json"
315
-
316
287
  render json: payload, status: :ok
317
288
  end
318
289
 
319
290
  # Renders the JSON-RPC response(s) as an SSE stream.
320
291
  def render_sse_response(payload, session, add_session_header)
321
- # This is not recommended with puma
322
292
  response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
323
293
  response.headers["Content-Type"] = "text/event-stream"
324
294
  response.headers["X-Accel-Buffering"] = "no"
325
295
  response.headers["Cache-Control"] = "no-cache"
326
296
  response.headers["Connection"] = "keep-alive"
327
-
328
297
  sse = SSE.new(response.stream)
329
- # TODO: Add logic for sending related server requests/notifications before/after response?
330
-
331
- if payload.is_a?(Array)
332
- # Send batched responses as separate events or one event? Spec allows batching.
333
- # Let's send as one event for now, using one ID for the batch.
334
- end
335
298
  write_sse_event(sse, session, payload)
336
299
  ensure
337
- # Close the stream after sending the response(s)
338
300
  sse&.close
339
301
  begin
340
302
  response.stream&.close
@@ -344,56 +306,68 @@ module ActionMCP
344
306
  Rails.logger.debug "Unified SSE (POST): Response stream closed."
345
307
  end
346
308
 
347
- # Renders a 500 Internal Server Error response.
348
- def render_internal_server_error(message = "Internal Server Error", id = nil)
349
- # Using -32000 for generic server error
350
- 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
351
322
  end
352
323
 
353
324
  # Helper to clean up old SSE events for a session
354
325
  def cleanup_old_sse_events(session)
355
326
  return unless ActionMCP.configuration.enable_sse_resumability
356
-
357
327
  begin
358
- # Get retention period from configuration
359
328
  retention_period = session.sse_event_retention_period
360
329
  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
330
+ Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
331
+ rescue StandardError => e
364
332
  Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
365
333
  end
366
334
  end
367
335
 
368
- # Helper to write a JSON payload as an SSE event with a unique ID.
369
- # Also stores the event for potential resumability.
370
- def write_sse_event(sse, session, payload)
371
- 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
372
340
 
373
- # Manually format the SSE event string including the ID
374
- data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
375
- sse_event = "id: #{event_id}\ndata: #{data}\n\n"
341
+ # --- Error Rendering Methods ---
376
342
 
377
- # Write to the stream
378
- 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
379
347
 
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
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 } }
388
351
  end
389
352
 
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) }
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 } }
356
+ end
357
+
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 } }
394
366
  end
395
367
 
396
- # TODO: Add methods for handle_get (SSE setup, listener, heartbeat) - Partially Done
397
- # TODO: Add method for handle_delete (session termination) - DONE (Basic)
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 } }
371
+ end
398
372
  end
399
373
  end
@@ -57,6 +57,9 @@ module ActionMCP
57
57
  scope :notifications, -> { where(message_type: "notification") }
58
58
  scope :responses, -> { where(message_type: "response") }
59
59
 
60
+ validates :message_json, presence: true
61
+ validates :message_type, presence: true
62
+
60
63
  # @param payload [String, Hash]
61
64
  def data=(payload)
62
65
  # Convert string payloads to JSON
@@ -114,22 +117,28 @@ module ActionMCP
114
117
  end
115
118
 
116
119
  def process_json_content(content)
117
- if content.is_a?(Hash) && content["jsonrpc"] == "2.0"
118
- if content.key?("id") && content.key?("method")
119
- self.message_type = "request"
120
- self.jsonrpc_id = content["id"].to_s
121
- # Set is_ping to true if the method is "ping"
122
- self.is_ping = true if content["method"] == "ping"
123
- elsif content.key?("method") && !content.key?("id")
124
- self.message_type = "notification"
125
- elsif content.key?("id") && content.key?("result")
126
- self.message_type = "response"
127
- self.jsonrpc_id = content["id"].to_s
128
- elsif content.key?("id") && content.key?("error")
129
- self.message_type = "error"
130
- self.jsonrpc_id = content["id"].to_s
131
- else
132
- self.message_type = "invalid_jsonrpc"
120
+ if content.is_a?(JSON_RPC::Notification) || content.is_a?(JSON_RPC::Request) || content.is_a?(JSON_RPC::Response)
121
+ content = content.to_h.with_indifferent_access
122
+ end
123
+ if content.is_a?(Hash)
124
+ content = content.with_indifferent_access
125
+ if content["jsonrpc"] == "2.0"
126
+ if content.key?("id") && content.key?("method")
127
+ self.message_type = "request"
128
+ self.jsonrpc_id = content["id"].to_s
129
+ # Set is_ping to true if the method is "ping"
130
+ self.is_ping = true if content["method"] == "ping"
131
+ elsif content.key?("method") && !content.key?("id")
132
+ self.message_type = "notification"
133
+ elsif content.key?("id") && content.key?("result")
134
+ self.message_type = "response"
135
+ self.jsonrpc_id = content["id"].to_s
136
+ elsif content.key?("id") && content.key?("error")
137
+ self.message_type = "error"
138
+ self.jsonrpc_id = content["id"].to_s
139
+ else
140
+ self.message_type = "invalid_jsonrpc"
141
+ end
133
142
  end
134
143
  else
135
144
  self.message_type = "non_jsonrpc_json"
@@ -42,6 +42,7 @@ module ActionMCP
42
42
  # Serializes the data as JSON if it's not already a string
43
43
  def data_for_stream
44
44
  return data if data.is_a?(String)
45
+
45
46
  data.is_a?(Hash) ? data.to_json : data.to_s
46
47
  end
47
48