actionmcp 0.50.0 → 0.50.2
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 +39 -58
- 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/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
- data/lib/action_mcp.rb +15 -1
- metadata +20 -17
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2331cbdf1a1729ce24b729693e34394f3e2725b81106942b5110770d7820225b
|
4
|
+
data.tar.gz: 672d31525a9f4632d8c2334e93a19bb8eb69a7e03e3ea9312d89d2e55a1d8bf7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ef0c162837326d3e4514e51123eab9597cfa04aa6a9ca1a790b607d460b9544e5d372171f7bb78d38e5c3d7298090ca2dfd5a3228c4c9b6b5feeb126912cff4a
|
7
|
+
data.tar.gz: e88d3401976a55d7144b3359156bb2d110fa597da8a4df5e22898fc8e3d724f7b6ab42e6b70f53b48e966ba1616057a9e2881f917bae347470160b399e4b97b3
|
@@ -9,7 +9,6 @@ module ActionMCP
|
|
9
9
|
|
10
10
|
include JSONRPC_Rails::ControllerHelpers
|
11
11
|
include ActionController::Live
|
12
|
-
# TODO: Include Instrumentation::ControllerRuntime if needed for metrics
|
13
12
|
|
14
13
|
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
15
14
|
# @route GET /mcp
|
@@ -47,7 +46,7 @@ module ActionMCP
|
|
47
46
|
|
48
47
|
# 4. Setup Stream, Listener, and Heartbeat
|
49
48
|
sse = SSE.new(response.stream)
|
50
|
-
listener = SSEListener.new(session)
|
49
|
+
listener = SSEListener.new(session)
|
51
50
|
connection_active = Concurrent::AtomicBoolean.new
|
52
51
|
connection_active.make_true
|
53
52
|
heartbeat_active = Concurrent::AtomicBoolean.new
|
@@ -68,7 +67,7 @@ module ActionMCP
|
|
68
67
|
end
|
69
68
|
|
70
69
|
# Handle resumability by sending missed events if Last-Event-ID is provided
|
71
|
-
if last_event_id.present? && last_event_id.to_i
|
70
|
+
if last_event_id.present? && last_event_id.to_i.positive?
|
72
71
|
begin
|
73
72
|
# Fetch events that occurred after the Last-Event-ID
|
74
73
|
missed_events = session.get_sse_events_after(last_event_id.to_i)
|
@@ -83,12 +82,13 @@ module ActionMCP
|
|
83
82
|
else
|
84
83
|
Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
|
85
84
|
end
|
86
|
-
rescue => e
|
85
|
+
rescue StandardError => e
|
87
86
|
Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
|
88
87
|
end
|
89
88
|
end
|
90
89
|
|
91
90
|
# Heartbeat sender proc
|
91
|
+
heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
|
92
92
|
heartbeat_sender = lambda do
|
93
93
|
if connection_active.true? && !response.stream.closed?
|
94
94
|
begin
|
@@ -96,8 +96,7 @@ module ActionMCP
|
|
96
96
|
future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
|
97
97
|
future.value!(5) # 5 second timeout for write
|
98
98
|
if heartbeat_active.true?
|
99
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(
|
100
|
-
&heartbeat_sender)
|
99
|
+
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
101
100
|
end
|
102
101
|
rescue Concurrent::TimeoutError
|
103
102
|
Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
|
@@ -112,7 +111,7 @@ module ActionMCP
|
|
112
111
|
end
|
113
112
|
|
114
113
|
# Start first heartbeat
|
115
|
-
heartbeat_task = Concurrent::ScheduledTask.execute(
|
114
|
+
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
116
115
|
|
117
116
|
# Keep connection alive while active
|
118
117
|
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
@@ -147,12 +146,13 @@ module ActionMCP
|
|
147
146
|
return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
|
148
147
|
end
|
149
148
|
|
150
|
-
# Determine if this is an initialize request (before session check)
|
149
|
+
# 2. Determine if this is an initialize request (before session check)
|
151
150
|
is_initialize_request = check_if_initialize_request(jsonrpc_params)
|
152
151
|
|
153
152
|
# 3. Check Session (unless it's an initialize request)
|
154
153
|
session_initially_missing = extract_session_id.nil?
|
155
154
|
session = mcp_session # This finds or initializes
|
155
|
+
|
156
156
|
unless is_initialize_request
|
157
157
|
if session_initially_missing
|
158
158
|
return render_bad_request("Mcp-Session-Id header is required for this request.")
|
@@ -162,23 +162,24 @@ module ActionMCP
|
|
162
162
|
return render_not_found("Session has been terminated.")
|
163
163
|
end
|
164
164
|
end
|
165
|
+
|
165
166
|
if session.new_record?
|
166
167
|
session.save!
|
167
168
|
response.headers[MCP_SESSION_ID_HEADER] = session.id
|
168
169
|
end
|
170
|
+
|
169
171
|
# 4. Instantiate Handlers
|
170
172
|
transport_handler = Server::TransportHandler.new(session)
|
171
173
|
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
172
174
|
|
173
175
|
# 5. Call Handler
|
174
|
-
handler_results = json_rpc_handler.call(jsonrpc_params
|
176
|
+
handler_results = json_rpc_handler.call(jsonrpc_params)
|
175
177
|
|
176
178
|
# 6. Process Results
|
177
179
|
process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
|
178
180
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
179
181
|
# Ensure stream is closed if SSE response was attempted and client disconnected
|
180
182
|
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
183
|
begin
|
183
184
|
response.stream&.close
|
184
185
|
rescue StandardError
|
@@ -197,7 +198,6 @@ module ActionMCP
|
|
197
198
|
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
198
199
|
|
199
200
|
# 2. Find Session
|
200
|
-
# Note: mcp_session helper finds based on header, but doesn't raise error if not found
|
201
201
|
session = Session.find_by(id: session_id_from_header)
|
202
202
|
|
203
203
|
if session.nil?
|
@@ -234,6 +234,7 @@ 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
|
+
|
237
238
|
payload.method == "initialize"
|
238
239
|
end
|
239
240
|
|
@@ -243,19 +244,14 @@ module ActionMCP
|
|
243
244
|
results ||= {}
|
244
245
|
|
245
246
|
# Check if this is a notification request
|
246
|
-
is_notification = jsonrpc_params.is_a?(
|
247
|
-
jsonrpc_params["method"].to_s.start_with?("notifications/") &&
|
248
|
-
!jsonrpc_params.key?("id")
|
247
|
+
is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
|
249
248
|
|
250
249
|
# Extract request ID from results
|
251
250
|
request_id = nil
|
252
251
|
if results.is_a?(Hash)
|
253
252
|
request_id = results[:request_id] || results[:id]
|
254
|
-
|
255
253
|
# 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
|
254
|
+
request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
|
259
255
|
end
|
260
256
|
|
261
257
|
# Default to empty hash for response payload if nil
|
@@ -263,29 +259,21 @@ module ActionMCP
|
|
263
259
|
result_payload = results[:payload] || {}
|
264
260
|
|
265
261
|
# 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)
|
262
|
+
result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
|
274
263
|
|
275
264
|
case result_type
|
276
265
|
when :error
|
277
266
|
# Ensure error responses preserve the ID
|
278
267
|
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
|
268
|
+
error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
|
282
269
|
render json: error_payload, status: results.fetch(:status, :bad_request)
|
270
|
+
|
283
271
|
when :notifications_only
|
284
272
|
head :accepted
|
273
|
+
|
285
274
|
when :responses
|
286
275
|
server_preference = ActionMCP.configuration.post_response_preference
|
287
276
|
use_sse = (server_preference == :sse)
|
288
|
-
|
289
277
|
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
290
278
|
|
291
279
|
if use_sse
|
@@ -293,18 +281,22 @@ module ActionMCP
|
|
293
281
|
else
|
294
282
|
render_json_response(result_payload, session, add_session_header)
|
295
283
|
end
|
284
|
+
|
296
285
|
else
|
297
|
-
#
|
286
|
+
# Handle unknown result types
|
298
287
|
Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
|
299
288
|
|
300
|
-
#
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
289
|
+
# If the original request was a notification, don't send back a response with an ID
|
290
|
+
if is_notification
|
291
|
+
head :accepted
|
292
|
+
else
|
293
|
+
# For regular requests, return a proper JSON-RPC response
|
294
|
+
render json: {
|
295
|
+
jsonrpc: "2.0",
|
296
|
+
id: request_id,
|
297
|
+
result: result_payload
|
298
|
+
}, status: :ok
|
299
|
+
end
|
308
300
|
end
|
309
301
|
end
|
310
302
|
|
@@ -312,13 +304,11 @@ module ActionMCP
|
|
312
304
|
def render_json_response(payload, session, add_session_header)
|
313
305
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
314
306
|
response.headers["Content-Type"] = "application/json"
|
315
|
-
|
316
307
|
render json: payload, status: :ok
|
317
308
|
end
|
318
309
|
|
319
310
|
# Renders the JSON-RPC response(s) as an SSE stream.
|
320
311
|
def render_sse_response(payload, session, add_session_header)
|
321
|
-
# This is not recommended with puma
|
322
312
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
323
313
|
response.headers["Content-Type"] = "text/event-stream"
|
324
314
|
response.headers["X-Accel-Buffering"] = "no"
|
@@ -326,12 +316,6 @@ module ActionMCP
|
|
326
316
|
response.headers["Connection"] = "keep-alive"
|
327
317
|
|
328
318
|
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
319
|
write_sse_event(sse, session, payload)
|
336
320
|
ensure
|
337
321
|
# Close the stream after sending the response(s)
|
@@ -359,8 +343,8 @@ module ActionMCP
|
|
359
343
|
retention_period = session.sse_event_retention_period
|
360
344
|
count = session.cleanup_old_sse_events(retention_period)
|
361
345
|
|
362
|
-
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count
|
363
|
-
rescue => e
|
346
|
+
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
|
347
|
+
rescue StandardError => e
|
364
348
|
Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
|
365
349
|
end
|
366
350
|
end
|
@@ -378,12 +362,12 @@ module ActionMCP
|
|
378
362
|
sse.stream.write(sse_event)
|
379
363
|
|
380
364
|
# Store the event for potential resumption if resumability is enabled
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
365
|
+
return unless ActionMCP.configuration.enable_sse_resumability
|
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}"
|
387
371
|
end
|
388
372
|
end
|
389
373
|
|
@@ -392,8 +376,5 @@ module ActionMCP
|
|
392
376
|
protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
|
393
377
|
tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
|
394
378
|
end
|
395
|
-
|
396
|
-
# TODO: Add methods for handle_get (SSE setup, listener, heartbeat) - Partially Done
|
397
|
-
# TODO: Add method for handle_delete (session termination) - DONE (Basic)
|
398
379
|
end
|
399
380
|
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"
|
@@ -167,9 +167,7 @@ module ActionMCP
|
|
167
167
|
)
|
168
168
|
|
169
169
|
# Maintain cache limit by removing oldest events if needed
|
170
|
-
if sse_events.count > max_events
|
171
|
-
sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all
|
172
|
-
end
|
170
|
+
sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all if sse_events.count > max_events
|
173
171
|
|
174
172
|
event
|
175
173
|
end
|
@@ -240,10 +238,10 @@ module ActionMCP
|
|
240
238
|
tool_name = normalize_name(tool_class_or_name, :tool)
|
241
239
|
self.tool_registry ||= []
|
242
240
|
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
241
|
+
return unless self.tool_registry.delete(tool_name)
|
242
|
+
|
243
|
+
save!
|
244
|
+
send_tools_list_changed_notification
|
247
245
|
end
|
248
246
|
|
249
247
|
def register_prompt(prompt_class_or_name)
|
@@ -263,10 +261,10 @@ module ActionMCP
|
|
263
261
|
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
264
262
|
self.prompt_registry ||= []
|
265
263
|
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
264
|
+
return unless self.prompt_registry.delete(prompt_name)
|
265
|
+
|
266
|
+
save!
|
267
|
+
send_prompts_list_changed_notification
|
270
268
|
end
|
271
269
|
|
272
270
|
def register_resource_template(template_class_or_name)
|
@@ -286,28 +284,34 @@ module ActionMCP
|
|
286
284
|
template_name = normalize_name(template_class_or_name, :resource_template)
|
287
285
|
self.resource_registry ||= []
|
288
286
|
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
287
|
+
return unless self.resource_registry.delete(template_name)
|
288
|
+
|
289
|
+
save!
|
290
|
+
send_resources_list_changed_notification
|
293
291
|
end
|
294
292
|
|
295
293
|
# Get registered items for this session
|
296
294
|
def registered_tools
|
297
295
|
(self.tool_registry || []).filter_map do |tool_name|
|
298
|
-
ActionMCP::ToolsRegistry.find(tool_name)
|
296
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
297
|
+
rescue StandardError
|
298
|
+
nil
|
299
299
|
end
|
300
300
|
end
|
301
301
|
|
302
302
|
def registered_prompts
|
303
303
|
(self.prompt_registry || []).filter_map do |prompt_name|
|
304
|
-
ActionMCP::PromptsRegistry.find(prompt_name)
|
304
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
305
|
+
rescue StandardError
|
306
|
+
nil
|
305
307
|
end
|
306
308
|
end
|
307
309
|
|
308
310
|
def registered_resource_templates
|
309
311
|
(self.resource_registry || []).filter_map do |template_name|
|
310
|
-
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
312
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
313
|
+
rescue StandardError
|
314
|
+
nil
|
311
315
|
end
|
312
316
|
end
|
313
317
|
|
@@ -379,21 +383,21 @@ module ActionMCP
|
|
379
383
|
|
380
384
|
def send_tools_list_changed_notification
|
381
385
|
# Only send if server capabilities allow it
|
382
|
-
|
383
|
-
|
384
|
-
|
386
|
+
return unless server_capabilities.dig("tools", "listChanged")
|
387
|
+
|
388
|
+
write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
|
385
389
|
end
|
386
390
|
|
387
391
|
def send_prompts_list_changed_notification
|
388
|
-
|
389
|
-
|
390
|
-
|
392
|
+
return unless server_capabilities.dig("prompts", "listChanged")
|
393
|
+
|
394
|
+
write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
|
391
395
|
end
|
392
396
|
|
393
397
|
def send_resources_list_changed_notification
|
394
|
-
|
395
|
-
|
396
|
-
|
398
|
+
return unless server_capabilities.dig("resources", "listChanged")
|
399
|
+
|
400
|
+
write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
|
397
401
|
end
|
398
402
|
end
|
399
403
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# app/models/concerns/mcp_console_helpers.rb
|
2
4
|
module MCPConsoleHelpers
|
3
5
|
extend ActiveSupport::Concern
|
@@ -12,9 +14,7 @@ module MCPConsoleHelpers
|
|
12
14
|
|
13
15
|
messages.each do |msg|
|
14
16
|
puts msg.inspect
|
15
|
-
if msg.data&.dig("method")
|
16
|
-
puts " └─ #{msg.data['method']}"
|
17
|
-
end
|
17
|
+
puts " └─ #{msg.data['method']}" if msg.data&.dig("method")
|
18
18
|
puts
|
19
19
|
end
|
20
20
|
end
|
@@ -1,10 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# app/models/concerns/mcp_message_inspect.rb
|
2
4
|
module MCPMessageInspect
|
3
5
|
extend ActiveSupport::Concern
|
4
6
|
|
5
7
|
def inspect(show_data: false)
|
6
8
|
if show_data
|
7
|
-
super()
|
9
|
+
super() # Rails default inspect
|
8
10
|
else
|
9
11
|
build_summary_inspect
|
10
12
|
end
|
@@ -54,9 +56,7 @@ module MCPMessageInspect
|
|
54
56
|
|
55
57
|
def console?
|
56
58
|
# Check if we're in a Rails console environment
|
57
|
-
defined?(Rails::Console)
|
58
|
-
defined?(::Rails.application) && Rails.application.console? ||
|
59
|
-
(defined?(IRB) && IRB.CurrentContext.kind_of?(IRB::ExtendCommandBundle))
|
59
|
+
defined?(Rails::Console)
|
60
60
|
end
|
61
61
|
|
62
62
|
def colorize(text, color)
|
@@ -29,10 +29,10 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
|
|
29
29
|
unless table_exists?(:action_mcp_session_messages)
|
30
30
|
create_table :action_mcp_session_messages do |t|
|
31
31
|
t.references :session, null: false,
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
32
|
+
foreign_key: { to_table: :action_mcp_sessions,
|
33
|
+
on_delete: :cascade,
|
34
|
+
on_update: :cascade,
|
35
|
+
name: 'fk_action_mcp_session_messages_session_id' }, type: :string
|
36
36
|
t.string :direction, null: false, comment: 'The message recipient', default: 'client'
|
37
37
|
t.string :message_type, null: false, comment: 'The type of the message'
|
38
38
|
t.string :jsonrpc_id
|
@@ -48,9 +48,9 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
|
|
48
48
|
unless table_exists?(:action_mcp_session_subscriptions)
|
49
49
|
create_table :action_mcp_session_subscriptions do |t|
|
50
50
|
t.references :session,
|
51
|
-
|
52
|
-
|
53
|
-
|
51
|
+
null: false,
|
52
|
+
foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
|
53
|
+
type: :string
|
54
54
|
t.string :uri, null: false
|
55
55
|
t.datetime :last_notification_at
|
56
56
|
t.timestamps
|
@@ -61,9 +61,9 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
|
|
61
61
|
unless table_exists?(:action_mcp_session_resources)
|
62
62
|
create_table :action_mcp_session_resources do |t|
|
63
63
|
t.references :session,
|
64
|
-
|
65
|
-
|
66
|
-
|
64
|
+
null: false,
|
65
|
+
foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
|
66
|
+
type: :string
|
67
67
|
t.string :uri, null: false
|
68
68
|
t.string :name
|
69
69
|
t.text :description
|
@@ -84,7 +84,7 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
|
|
84
84
|
t.timestamps
|
85
85
|
|
86
86
|
# Index for efficiently retrieving events after a given ID for a specific session
|
87
|
-
t.index [
|
87
|
+
t.index %i[session_id event_id], unique: true
|
88
88
|
t.index :created_at # For cleanup of old events
|
89
89
|
end
|
90
90
|
end
|
@@ -111,27 +111,28 @@ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
|
|
111
111
|
end
|
112
112
|
|
113
113
|
# For action_mcp_session_messages
|
114
|
-
|
115
|
-
unless column_exists?(:action_mcp_session_messages, :is_ping)
|
116
|
-
add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false, comment: 'Whether the message is a ping'
|
117
|
-
end
|
114
|
+
return unless table_exists?(:action_mcp_session_messages)
|
118
115
|
|
119
|
-
|
120
|
-
|
121
|
-
|
116
|
+
unless column_exists?(:action_mcp_session_messages, :is_ping)
|
117
|
+
add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false,
|
118
|
+
comment: 'Whether the message is a ping'
|
119
|
+
end
|
122
120
|
|
123
|
-
|
124
|
-
|
125
|
-
|
121
|
+
unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
|
122
|
+
add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false
|
123
|
+
end
|
126
124
|
|
127
|
-
|
128
|
-
|
129
|
-
|
125
|
+
unless column_exists?(:action_mcp_session_messages, :request_cancelled)
|
126
|
+
add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
|
127
|
+
end
|
130
128
|
|
131
|
-
|
132
|
-
|
133
|
-
end
|
129
|
+
if column_exists?(:action_mcp_session_messages, :message_text)
|
130
|
+
remove_column :action_mcp_session_messages, :message_text
|
134
131
|
end
|
132
|
+
|
133
|
+
return unless column_exists?(:action_mcp_session_messages, :direction)
|
134
|
+
|
135
|
+
change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
|
135
136
|
end
|
136
137
|
|
137
138
|
private
|
data/exe/actionmcp_cli
CHANGED
@@ -56,7 +56,7 @@ if endpoint.nil?
|
|
56
56
|
end
|
57
57
|
|
58
58
|
unless endpoint =~ %r{\Ahttps?://}
|
59
|
-
puts
|
59
|
+
puts 'Error: Only HTTP(S) endpoints are supported. STDIO/command endpoints are not allowed.'
|
60
60
|
exit 1
|
61
61
|
end
|
62
62
|
|
data/lib/action_mcp/client.rb
CHANGED
@@ -13,12 +13,12 @@ module ActionMCP
|
|
13
13
|
# client = ActionMCP.create_client("http://127.0.0.1:3001/action_mcp")
|
14
14
|
# client.connect
|
15
15
|
def self.create_client(endpoint, logger: Logger.new($stdout), **options)
|
16
|
-
|
17
|
-
logger.info("Creating SSE client for endpoint: #{endpoint}")
|
18
|
-
Client::SSEClient.new(endpoint, logger: logger, **options)
|
19
|
-
else
|
16
|
+
unless endpoint =~ %r{\Ahttps?://}
|
20
17
|
raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
|
21
18
|
end
|
19
|
+
|
20
|
+
logger.info("Creating SSE client for endpoint: #{endpoint}")
|
21
|
+
Client::SSEClient.new(endpoint, logger: logger, **options)
|
22
22
|
end
|
23
23
|
|
24
24
|
module Client
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -47,5 +47,32 @@ module ActionMCP
|
|
47
47
|
initializer "action_mcp.logger" do
|
48
48
|
ActionMCP.logger = ::Rails.logger
|
49
49
|
end
|
50
|
+
|
51
|
+
# Add metrics instrumentation
|
52
|
+
initializer "action_mcp.metrics" do
|
53
|
+
ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, start, finish, id, payload|
|
54
|
+
if payload[:controller].to_s.start_with?("ActionMCP::")
|
55
|
+
# Process action through our log subscriber
|
56
|
+
ActionMCP::LogSubscriber.new.process_action(
|
57
|
+
ActiveSupport::Notifications::Event.new(name, start, finish, id, payload)
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Set up default event metrics
|
63
|
+
# SQL queries
|
64
|
+
ActionMCP::LogSubscriber.subscribe_event "sql.active_record", :db_queries, accumulate: true
|
65
|
+
|
66
|
+
# Query runtime
|
67
|
+
ActionMCP::LogSubscriber.subscribe_event "sql.active_record", :sql_runtime,
|
68
|
+
duration: true, accumulate: true
|
69
|
+
|
70
|
+
# View rendering
|
71
|
+
ActionMCP::LogSubscriber.subscribe_event "render_template.action_view", :view_runtime,
|
72
|
+
duration: true, accumulate: true
|
73
|
+
|
74
|
+
# Cache operations
|
75
|
+
ActionMCP::LogSubscriber.subscribe_event "cache_*.*", :cache_operations, accumulate: true
|
76
|
+
end
|
50
77
|
end
|
51
78
|
end
|