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 +4 -4
- data/app/controllers/action_mcp/{unified_controller.rb → application_controller.rb} +118 -144
- data/app/models/action_mcp/session/message.rb +25 -16
- data/app/models/action_mcp/session/sse_event.rb +1 -0
- data/app/models/action_mcp/session.rb +31 -27
- data/app/models/concerns/mcp_console_helpers.rb +3 -3
- data/app/models/concerns/mcp_message_inspect.rb +4 -4
- data/config/routes.rb +3 -3
- data/db/migrate/20250512154359_consolidated_migration.rb +28 -27
- data/exe/actionmcp_cli +1 -1
- data/lib/action_mcp/client.rb +4 -4
- data/lib/action_mcp/engine.rb +27 -0
- data/lib/action_mcp/instrumentation/controller_runtime.rb +1 -1
- data/lib/action_mcp/json_rpc_handler_base.rb +1 -0
- data/lib/action_mcp/log_subscriber.rb +160 -0
- data/lib/action_mcp/resource_template.rb +1 -3
- data/lib/action_mcp/server/capabilities.rb +11 -8
- data/lib/action_mcp/server/configuration.rb +5 -2
- data/lib/action_mcp/server/json_rpc_handler.rb +155 -88
- data/lib/action_mcp/server/registry_management.rb +2 -0
- data/lib/action_mcp/server/simple_pub_sub.rb +7 -6
- data/lib/action_mcp/server/solid_cable_adapter.rb +12 -13
- data/lib/action_mcp/server/tools.rb +2 -2
- data/lib/action_mcp/server.rb +5 -4
- data/lib/action_mcp/tool.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- metadata +16 -17
- data/app/controllers/action_mcp/mcp_controller.rb +0 -79
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7c5d576f3bc8c02f3682c056b8145b9af5ad85ad581a131b4bb7dccdd14b0531
|
4
|
+
data.tar.gz: 8233276b69f4033c8b62a015f3960ef8503f16344f3be0c17106441994146d9d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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 ---
|
13
34
|
|
14
35
|
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
15
|
-
# @route GET /
|
36
|
+
# @route GET /
|
16
37
|
def show
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
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)
|
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
|
81
|
+
return
|
68
82
|
end
|
69
83
|
|
70
|
-
|
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.
|
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
|
-
|
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)
|
105
|
+
future.value!(5)
|
98
106
|
if heartbeat_active.true?
|
99
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(
|
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
|
-
|
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
|
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?
|
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
|
-
|
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 /
|
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!
|
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
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
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
|
-
#
|
348
|
-
|
349
|
-
|
350
|
-
|
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
|
-
|
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
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
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
|
-
|
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
|
-
|
378
|
-
|
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
|
-
|
381
|
-
|
382
|
-
|
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
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
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
|
-
#
|
397
|
-
|
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?(
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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"
|