actionmcp 0.50.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6673ed8cefa0458b617bcb9ceb865c4aef47a5448752cbe230dc950afea1941f
4
- data.tar.gz: 68b8c888e2983cea309cd9498d1bde1887025d37058deabeb1b844c0ae3213d8
3
+ metadata.gz: 2331cbdf1a1729ce24b729693e34394f3e2725b81106942b5110770d7820225b
4
+ data.tar.gz: 672d31525a9f4632d8c2334e93a19bb8eb69a7e03e3ea9312d89d2e55a1d8bf7
5
5
  SHA512:
6
- metadata.gz: 1493f47a18bcc5892c84efcfd51840b50cb6d69c9834c0d114cdaa8fe4a7f040d2ac9d84aec06a9599f25135e797bf85e056c7f602571fe6c47f07928e3c6aa9
7
- data.tar.gz: a4d9cf8658e976d05d8330b9349d143cdf49abc281e974d40f9b70d11592ec619d1e55b47f74c5e6e94865d05a7d8c8fae363e1d98d1bed738fa7be2ee5c4ff2
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) # Use the listener class (defined below or moved)
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 > 0
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(ActionMCP.configuration.sse_heartbeat_interval,
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(HEARTBEAT_INTERVAL, &heartbeat_sender)
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.to_h)
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?(Hash) &&
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
- # This was causing the "Unknown handler result type: " error
286
+ # Handle unknown result types
298
287
  Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
299
288
 
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
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 > 0
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
- 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
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?(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
 
@@ -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
- if self.tool_registry.delete(tool_name)
244
- save!
245
- send_tools_list_changed_notification
246
- end
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
- if self.prompt_registry.delete(prompt_name)
267
- save!
268
- send_prompts_list_changed_notification
269
- end
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
- if self.resource_registry.delete(template_name)
290
- save!
291
- send_resources_list_changed_notification
292
- end
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) rescue nil
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) rescue nil
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) rescue nil
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
- if server_capabilities.dig("tools", "listChanged")
383
- write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
384
- end
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
- if server_capabilities.dig("prompts", "listChanged")
389
- write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
390
- end
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
- if server_capabilities.dig("resources", "listChanged")
395
- write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
396
- end
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() # Rails default inspect
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
- 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
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
- null: false,
52
- foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
53
- type: :string
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
- null: false,
65
- foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
66
- type: :string
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 [ :session_id, :event_id ], unique: true
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
- if table_exists?(:action_mcp_session_messages)
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
- unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
120
- add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false
121
- end
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
- unless column_exists?(:action_mcp_session_messages, :request_cancelled)
124
- add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
125
- end
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
- if column_exists?(:action_mcp_session_messages, :message_text)
128
- remove_column :action_mcp_session_messages, :message_text
129
- end
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
- if column_exists?(:action_mcp_session_messages, :direction)
132
- change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
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 "Error: Only HTTP(S) endpoints are supported. STDIO/command endpoints are not allowed."
59
+ puts 'Error: Only HTTP(S) endpoints are supported. STDIO/command endpoints are not allowed.'
60
60
  exit 1
61
61
  end
62
62
 
@@ -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
- if endpoint =~ %r{\Ahttps?://}
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
@@ -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