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.
@@ -28,7 +28,7 @@ module ActionMCP
28
28
  def log_process_action(payload)
29
29
  messages = super
30
30
  mcp_runtime = payload[:mcp_runtime]
31
- messages << (format("MCP: %.1fms", mcp_runtime.to_f)) if mcp_runtime
31
+ messages << format("MCP: %.1fms", mcp_runtime.to_f) if mcp_runtime
32
32
  messages
33
33
  end
34
34
  end
@@ -75,6 +75,7 @@ module ActionMCP
75
75
  # @param request [Hash]
76
76
  def process_request(request)
77
77
  return unless valid_request?(request)
78
+
78
79
  request = request.with_indifferent_access
79
80
 
80
81
  read(request)
@@ -2,6 +2,11 @@
2
2
 
3
3
  module ActionMCP
4
4
  class LogSubscriber < ActiveSupport::LogSubscriber
5
+ # Thread-local storage for additional metrics
6
+ class << self
7
+ attr_accessor :custom_metrics, :subscribed_events, :formatters, :metric_groups
8
+ end
9
+
5
10
  def self.reset_runtime
6
11
  # Get the combined runtime from both tool and prompt operations
7
12
  tool_rt = Thread.current[:mcp_tool_runtime] || 0
@@ -26,6 +31,161 @@ module ActionMCP
26
31
  Thread.current[:mcp_prompt_runtime] += event.duration
27
32
  end
28
33
 
34
+ # Add a custom metric to be included in logs
35
+ def self.add_metric(name, value)
36
+ self.custom_metrics ||= {}
37
+ self.custom_metrics[name] = value
38
+ end
39
+
40
+ # Measure execution time of a block and add as metric
41
+ def self.measure_metric(name)
42
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ result = yield
44
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
45
+
46
+ add_metric(name, duration)
47
+ result
48
+ end
49
+
50
+ # Reset all custom metrics
51
+ def self.reset_metrics
52
+ self.custom_metrics = nil
53
+ end
54
+
55
+ # Subscribe to a Rails event to capture metrics
56
+ # @param pattern [String] Event name pattern (e.g., "sql.active_record")
57
+ # @param metric_name [Symbol] Name to use for the metric
58
+ # @param options [Hash] Options for capturing the metric
59
+ def self.subscribe_event(pattern, metric_name, options = {})
60
+ self.subscribed_events ||= {}
61
+
62
+ # Store subscription info
63
+ self.subscribed_events[pattern] = {
64
+ metric_name: metric_name,
65
+ options: options
66
+ }
67
+
68
+ # Create the actual subscription
69
+ ActiveSupport::Notifications.subscribe(pattern) do |*args|
70
+ event = ActiveSupport::Notifications::Event.new(*args)
71
+
72
+ # Extract value based on options
73
+ value = if options[:duration]
74
+ event.duration
75
+ elsif options[:extract_value].respond_to?(:call)
76
+ options[:extract_value].call(event)
77
+ else
78
+ 1 # Default to count
79
+ end
80
+
81
+ # Accumulate or set the metric
82
+ if options[:accumulate]
83
+ self.custom_metrics ||= {}
84
+ self.custom_metrics[metric_name] ||= 0
85
+ self.custom_metrics[metric_name] += value
86
+ else
87
+ add_metric(metric_name, value)
88
+ end
89
+ end
90
+ end
91
+
92
+ # Format metrics for display in logs
93
+ def self.format_metrics
94
+ return nil if custom_metrics.nil? || custom_metrics.empty?
95
+
96
+ # If grouping is enabled, organize metrics by groups
97
+ if metric_groups.present?
98
+ grouped_metrics = {}
99
+
100
+ # Initialize groups with empty arrays
101
+ metric_groups.each_key do |group_name|
102
+ grouped_metrics[group_name] = []
103
+ end
104
+
105
+ # Add "other" group for ungrouped metrics
106
+ grouped_metrics[:other] = []
107
+
108
+ # Assign metrics to their groups
109
+ custom_metrics.each do |key, value|
110
+ group = nil
111
+
112
+ # Find which group this metric belongs to
113
+ metric_groups.each do |group_name, metrics|
114
+ if metrics.include?(key)
115
+ group = group_name
116
+ break
117
+ end
118
+ end
119
+
120
+ # Format the metric
121
+ formatter = formatters&.dig(key)
122
+ formatted_value = if formatter.respond_to?(:call)
123
+ formatter.call(value)
124
+ elsif value.is_a?(Float)
125
+ format("%.1fms", value)
126
+ else
127
+ value.to_s
128
+ end
129
+
130
+ formatted_metric = "#{key}: #{formatted_value}"
131
+
132
+ # Add to appropriate group (or "other")
133
+ if group
134
+ grouped_metrics[group] << formatted_metric
135
+ else
136
+ grouped_metrics[:other] << formatted_metric
137
+ end
138
+ end
139
+
140
+ # Join metrics within groups, then join groups
141
+ grouped_metrics.map do |_group, metrics|
142
+ next if metrics.empty?
143
+
144
+ metrics.join(" | ")
145
+ end.compact.join(" | ")
146
+ else
147
+ # No grouping, just format all metrics
148
+ custom_metrics.map do |key, value|
149
+ formatter = formatters&.dig(key)
150
+ formatted_value = if formatter.respond_to?(:call)
151
+ formatter.call(value)
152
+ elsif value.is_a?(Float)
153
+ format("%.1fms", value)
154
+ else
155
+ value.to_s
156
+ end
157
+ "#{key}: #{formatted_value}"
158
+ end.join(" | ")
159
+ end
160
+ end
161
+
162
+ # Register a custom formatter for a specific metric
163
+ # @param metric_name [Symbol] The name of the metric
164
+ # @param block [Proc] The formatter block that takes the value and returns a string
165
+ def self.register_formatter(metric_name, &block)
166
+ self.formatters ||= {}
167
+ self.formatters[metric_name] = block
168
+ end
169
+
170
+ # Define a group of related metrics
171
+ # @param group_name [Symbol] The name of the metric group
172
+ # @param metrics [Array<Symbol>] The metrics that belong to this group
173
+ def self.define_metric_group(group_name, metrics)
174
+ self.metric_groups ||= {}
175
+ self.metric_groups[group_name] = metrics
176
+ end
177
+
178
+ # Enhance process_action to include our custom metrics
179
+ def process_action(event)
180
+ return unless logger.info?
181
+
182
+ return unless self.class.custom_metrics.present?
183
+
184
+ metrics_msg = self.class.format_metrics
185
+ event.payload[:message] = "#{event.payload[:message]} | #{metrics_msg}" if metrics_msg
186
+ self.class.reset_metrics
187
+ end
188
+
29
189
  attach_to :action_mcp
30
190
  end
31
191
  end
@@ -13,7 +13,7 @@ module ActionMCP
13
13
 
14
14
  # Track all registered templates
15
15
  @registered_templates = []
16
- attr_reader :execution_context
16
+ attr_reader :execution_context, :description, :uri_template, :mime_type
17
17
 
18
18
  class << self
19
19
  attr_reader :registered_templates, :description, :uri_template,
@@ -227,8 +227,6 @@ module ActionMCP
227
227
  end
228
228
  end
229
229
 
230
- attr_reader :description, :uri_template, :mime_type
231
-
232
230
  def call
233
231
  run_callbacks :resolve do
234
232
  resolve
@@ -16,7 +16,8 @@ module ActionMCP
16
16
 
17
17
  unless client_protocol_version.is_a?(String) && client_protocol_version.present?
18
18
  send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
19
- return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'protocolVersion'" } } }
19
+ return { type: :error, id: request_id,
20
+ payload: { jsonrpc: "2.0", id: request_id, error: { code: -32_602, message: "Missing or invalid 'protocolVersion'" } } }
20
21
  end
21
22
  # Check if the protocol version is supported
22
23
  unless ActionMCP::SUPPORTED_VERSIONS.include?(client_protocol_version)
@@ -25,20 +26,21 @@ module ActionMCP
25
26
  requested: client_protocol_version
26
27
  }
27
28
  send_jsonrpc_error(request_id, :invalid_params, "Unsupported protocol version", error_data)
28
- return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Unsupported protocol version", data: error_data } } }
29
+ return { type: :error, id: request_id,
30
+ payload: { jsonrpc: "2.0", id: request_id, error: { code: -32_602, message: "Unsupported protocol version", data: error_data } } }
29
31
  end
30
32
 
31
33
  unless client_info.is_a?(Hash)
32
34
  send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'clientInfo'")
33
- return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'clientInfo'" } } }
35
+ return { type: :error, id: request_id,
36
+ payload: { jsonrpc: "2.0", id: request_id, error: { code: -32_602, message: "Missing or invalid 'clientInfo'" } } }
34
37
  end
35
38
  unless client_capabilities.is_a?(Hash)
36
39
  send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
37
- return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'capabilities'" } } }
40
+ return { type: :error, id: request_id,
41
+ payload: { jsonrpc: "2.0", id: request_id, error: { code: -32_602, message: "Missing or invalid 'capabilities'" } } }
38
42
  end
39
43
 
40
-
41
-
42
44
  # Store client information
43
45
  session.store_client_info(client_info)
44
46
  session.store_client_capabilities(client_capabilities)
@@ -47,12 +49,13 @@ module ActionMCP
47
49
  # Initialize the session
48
50
  unless session.initialize!
49
51
  send_jsonrpc_error(request_id, :internal_error, "Failed to initialize session")
50
- return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32603, message: "Failed to initialize session" } } }
52
+ return { type: :error, id: request_id,
53
+ payload: { jsonrpc: "2.0", id: request_id, error: { code: -32_603, message: "Failed to initialize session" } } }
51
54
  end
52
55
 
53
56
  # Send the successful response with the protocol version the client requested
54
57
  capabilities_payload = session.server_capabilities_payload
55
- capabilities_payload[:protocolVersion] = client_protocol_version # Use the client's requested version
58
+ capabilities_payload[:protocolVersion] = client_protocol_version # Use the client's requested version
56
59
 
57
60
  send_jsonrpc_response(request_id, result: capabilities_payload)
58
61
  { type: :responses, id: request_id, payload: { jsonrpc: "2.0", id: request_id, result: capabilities_payload } }
@@ -39,8 +39,10 @@ module ActionMCP
39
39
 
40
40
  yaml = ERB.new(File.read(@config_path)).result
41
41
  YAML.safe_load(yaml, aliases: true) || {}
42
- rescue => e
43
- Rails.logger.error("Error loading ActionMCP config: #{e.message}") if defined?(Rails) && Rails.respond_to?(:logger)
42
+ rescue StandardError => e
43
+ if defined?(Rails) && Rails.respond_to?(:logger)
44
+ Rails.logger.error("Error loading ActionMCP config: #{e.message}")
45
+ end
44
46
  {}
45
47
  end
46
48
 
@@ -52,6 +54,7 @@ module ActionMCP
52
54
  while path != "/"
53
55
  config_path = File.join(path, "config", "mcp.yml")
54
56
  return config_path if File.exist?(config_path)
57
+
55
58
  path = File.dirname(path)
56
59
  end
57
60
 
@@ -4,37 +4,30 @@ module ActionMCP
4
4
  module Server
5
5
  class JsonRpcHandler < JsonRpcHandlerBase
6
6
  # Handle server-specific methods
7
- # @param rpc_method [String]
8
- # @param id [String, Integer]
9
- # @param params [Hash]
10
- def call(line)
11
- request = if line.is_a?(String)
12
- line.strip!
13
- return if line.empty?
14
-
15
- begin
16
- MultiJson.load(line)
17
- rescue MultiJson::ParseError => e
18
- Rails.logger.error("Failed to parse JSON: #{e.message}")
19
- return
20
- end
21
- else
22
- line
7
+ # @param request [JSON_RPC::Request, JSON_RPC::Notification, JSON_RPC::Response]
8
+ def call(request)
9
+ read(request.to_h)
10
+ case request
11
+ when JSON_RPC::Request
12
+ handle_request(request)
13
+ when JSON_RPC::Notification
14
+ handle_notification(request)
15
+ when JSON_RPC::Response
16
+ handle_response(request)
23
17
  end
24
-
25
- # Store the request ID for error responses
26
- @current_request_id = request["id"] if request.is_a?(Hash)
27
-
28
- process_request(request)
29
18
  end
30
19
 
31
- def handle_method(rpc_method, id, params)
32
- # Ensure we have the current request ID
33
- @current_request_id = id
20
+ private
21
+
22
+ def handle_request(request)
23
+ @current_request_id = id = request.id
24
+ rpc_method = request.method
25
+ params = request.params
34
26
 
35
27
  case rpc_method
36
28
  when "initialize"
37
- transport.send_capabilities(id, params)
29
+ message = transport.send_capabilities(id, params)
30
+ extract_message_payload(message, id)
38
31
  when %r{^prompts/}
39
32
  process_prompts(rpc_method, id, params)
40
33
  when %r{^resources/}
@@ -44,104 +37,178 @@ module ActionMCP
44
37
  when "completion/complete"
45
38
  process_completion_complete(id, params)
46
39
  else
47
- transport.send_jsonrpc_error(id, :method_not_found, "Method not found #{rpc_method}")
40
+ error_response(id, :method_not_found, "Method not found: #{rpc_method}")
48
41
  end
49
- rescue StandardError => e
50
- Rails.logger.error("Error handling method #{rpc_method}: #{e.message}")
51
- transport.send_jsonrpc_error(id, :internal_error, "Internal error: #{e.message}")
52
42
  end
53
43
 
44
+ def handle_notification(notification)
45
+ @current_request_id = nil
54
46
 
55
- # Server methods (client → server)
56
-
57
- # @param id [String]
58
- # @param params [Hash]
59
- # @example {
60
- # "ref": {
61
- # "type": "ref/prompt",
62
- # "name": "code_review"
63
- # },
64
- # "argument": {
65
- # "name": "language",
66
- # "value": "py"
67
- # }
68
- # }
69
- # @return [Hash]
70
- # @example {
71
- # "completion": {
72
- # "values": ["python", "pytorch", "pyside"],
73
- # "total": 10,
74
- # "hasMore": true
75
- # }
76
- # }
77
- def process_completion_complete(id, params)
78
- # TODO: Not Implemented, but to remove the error message in the inspector
79
- transport.send_jsonrpc_response(id, result: { completion: { values: [], total: 0, hasMore: false } })
80
- case params["ref"]["type"]
81
- when "ref/prompt"
82
- # TODO: Implement completion
83
- when "ref/resource"
84
- # TODO: Implement completion
47
+ begin
48
+ method_name = notification.method.to_s
49
+ params = notification.params || {}
50
+
51
+ process_notifications(method_name, params)
52
+ { type: :notifications_only }
53
+ rescue StandardError => e
54
+ Rails.logger.error("Error handling notification #{notification.method}: #{e.message}")
55
+ Rails.logger.error(e.backtrace.join("\n"))
56
+ { type: :notifications_only }
85
57
  end
86
58
  end
87
59
 
88
- # @param rpc_method [String]
89
- # @param id [String]
90
- # @param params [Hash]
60
+ def handle_response(response)
61
+ Rails.logger.debug("Received response: #{response.inspect}")
62
+ {
63
+ type: :responses,
64
+ request_id: response.id,
65
+ payload: {
66
+ jsonrpc: "2.0",
67
+ id: response.id,
68
+ result: response.result
69
+ }
70
+ }
71
+ end
72
+
91
73
  def process_prompts(rpc_method, id, params)
74
+ params ||= {}
75
+
92
76
  case rpc_method
93
- when "prompts/get" # Get specific prompt
94
- transport.send_prompts_get(id, params["name"], params["arguments"])
95
- when "prompts/list" # List available prompts
96
- transport.send_prompts_list(id)
77
+ when "prompts/get"
78
+ name = params["name"] || params[:name]
79
+ arguments = params["arguments"] || params[:arguments] || {}
80
+ message = transport.send_prompts_get(id, name, arguments)
81
+ extract_message_payload(message, id)
82
+ when "prompts/list"
83
+ message = transport.send_prompts_list(id)
84
+ extract_message_payload(message, id)
97
85
  else
98
86
  Rails.logger.warn("Unknown prompts method: #{rpc_method}")
87
+ error_response(id, :method_not_found, "Unknown prompts method: #{rpc_method}")
99
88
  end
100
89
  end
101
90
 
102
- # @param rpc_method [String]
103
- # @param id [String]
104
- # @param params [Hash]
105
91
  def process_tools(rpc_method, id, params)
92
+ params ||= {}
93
+
106
94
  case rpc_method
107
- when "tools/list" # List available tools
108
- transport.send_tools_list(id, params)
109
- when "tools/call" # Call a tool
110
- transport.send_tools_call(id, params["name"], params["arguments"])
95
+ when "tools/list"
96
+ message = transport.send_tools_list(id, params)
97
+ extract_message_payload(message, id)
98
+ when "tools/call"
99
+ name = params["name"] || params[:name]
100
+ arguments = params["arguments"] || params[:arguments] || {}
101
+
102
+ return error_response(id, :invalid_params, "Tool name is required") if name.nil?
103
+
104
+ message = transport.send_tools_call(id, name, arguments)
105
+ extract_message_payload(message, id)
111
106
  else
112
107
  Rails.logger.warn("Unknown tools method: #{rpc_method}")
108
+ error_response(id, :method_not_found, "Unknown tools method: #{rpc_method}")
113
109
  end
114
110
  end
115
111
 
116
- # @param rpc_method [String]
117
- # @param id [String]
118
- # @param params [Hash]
119
112
  def process_resources(rpc_method, id, params)
113
+ params ||= {}
114
+
120
115
  case rpc_method
121
- when "resources/list" # List available resources
122
- transport.send_resources_list(id)
123
- when "resources/templates/list" # List resource templates
124
- transport.send_resource_templates_list(id)
125
- when "resources/read" # Read resource content
126
- transport.send_resource_read(id, params)
127
- when "resources/subscribe" # Subscribe to resource updates
128
- transport.send_resource_subscribe(id, params["uri"])
129
- when "resources/unsubscribe" # Unsubscribe from resource updates
130
- transport.send_resource_unsubscribe(id, params["uri"])
116
+ when "resources/list"
117
+ message = transport.send_resources_list(id)
118
+ extract_message_payload(message, id)
119
+ when "resources/templates/list"
120
+ message = transport.send_resource_templates_list(id)
121
+ extract_message_payload(message, id)
122
+ when "resources/read"
123
+ return error_response(id, :invalid_params, "Resource URI is required") if params.nil? || params.empty?
124
+
125
+ message = transport.send_resource_read(id, params)
126
+ extract_message_payload(message, id)
127
+ when "resources/subscribe"
128
+ uri = params["uri"] || params[:uri]
129
+ return error_response(id, :invalid_params, "Resource URI is required") if uri.nil?
130
+
131
+ message = transport.send_resource_subscribe(id, uri)
132
+ extract_message_payload(message, id)
133
+ when "resources/unsubscribe"
134
+ uri = params["uri"] || params[:uri]
135
+ return error_response(id, :invalid_params, "Resource URI is required") if uri.nil?
136
+
137
+ message = transport.send_resource_unsubscribe(id, uri)
138
+ extract_message_payload(message, id)
131
139
  else
132
140
  Rails.logger.warn("Unknown resources method: #{rpc_method}")
141
+ error_response(id, :method_not_found, "Unknown resources method: #{rpc_method}")
142
+ end
143
+ end
144
+
145
+ def process_completion_complete(id, params)
146
+ params ||= {}
147
+
148
+ result = transport.send_jsonrpc_response(id, result: {
149
+ completion: { values: [], total: 0, hasMore: false }
150
+ })
151
+
152
+ if result.is_a?(ActionMCP::Session::Message)
153
+ extract_message_payload(result, id)
154
+ else
155
+ wrap_transport_result(result, id)
133
156
  end
134
157
  end
135
158
 
136
159
  def process_notifications(rpc_method, params)
137
160
  case rpc_method
138
- when "notifications/initialized" # Client initialization complete
139
- puts "\e[31mInitialized\e[0m"
161
+ when "notifications/initialized"
162
+ Rails.logger.info "Client notified initialization complete"
140
163
  transport.initialize!
141
164
  else
142
165
  super
143
166
  end
144
167
  end
168
+
169
+ def extract_message_payload(message, id)
170
+ if message.is_a?(ActionMCP::Session::Message)
171
+ {
172
+ type: :responses,
173
+ request_id: id,
174
+ payload: message.message_json
175
+ }
176
+ else
177
+ message
178
+ end
179
+ end
180
+
181
+ def wrap_transport_result(transport_result, id)
182
+ if transport_result.is_a?(Hash) && transport_result[:type]
183
+ transport_result
184
+ else
185
+ {
186
+ type: :responses,
187
+ request_id: id,
188
+ payload: transport_result
189
+ }
190
+ end
191
+ end
192
+
193
+ def error_response(id, code, message)
194
+ error_code = case code
195
+ when :method_not_found then -32_601
196
+ when :invalid_params then -32_602
197
+ when :internal_error then -32_603
198
+ else -32_000
199
+ end
200
+
201
+ {
202
+ type: :error,
203
+ request_id: id,
204
+ payload: {
205
+ jsonrpc: "2.0",
206
+ id: id,
207
+ error: { code: error_code, message: message }
208
+ },
209
+ status: :bad_request
210
+ }
211
+ end
145
212
  end
146
213
  end
147
214
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/action_mcp/server/registry_management.rb
2
4
  module ActionMCP
3
5
  module Server
@@ -13,7 +13,7 @@ module ActionMCP
13
13
  DEFAULT_MIN_THREADS = 5
14
14
  DEFAULT_MAX_THREADS = 10
15
15
  DEFAULT_MAX_QUEUE = 100
16
- DEFAULT_THREAD_TIMEOUT = 60 # seconds
16
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
17
17
 
18
18
  def initialize(options = {})
19
19
  @subscriptions = Concurrent::Map.new
@@ -58,6 +58,7 @@ module ActionMCP
58
58
  def subscribed_to?(channel)
59
59
  channel_subs = @channels[channel]
60
60
  return false if channel_subs.nil?
61
+
61
62
  !channel_subs.empty?
62
63
  end
63
64
 
@@ -91,11 +92,9 @@ module ActionMCP
91
92
  next unless subscription && subscription[:message_callback]
92
93
 
93
94
  @thread_pool.post do
94
- begin
95
- subscription[:message_callback].call(message)
96
- rescue StandardError => e
97
- log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
98
- end
95
+ subscription[:message_callback].call(message)
96
+ rescue StandardError => e
97
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
99
98
  end
100
99
  end
101
100
  end
@@ -106,6 +105,7 @@ module ActionMCP
106
105
  def has_subscribers?(channel)
107
106
  subscribers = @channels[channel]
108
107
  return false unless subscribers
108
+
109
109
  !subscribers.empty?
110
110
  end
111
111
 
@@ -138,6 +138,7 @@ module ActionMCP
138
138
 
139
139
  def log_error(message)
140
140
  return unless defined?(Rails) && Rails.respond_to?(:logger)
141
+
141
142
  Rails.logger.error("SimplePubSub: #{message}")
142
143
  end
143
144
  end