actionagent 1.2.2 → 1.5.0

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. metadata +22 -5
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Names the MCP server behind a tool an evaluation run needs, for the
5
+ # report's fix items (ActiveAgent::Evals::Report#fix_items): a scenario
6
+ # that expected +search_slots+ and never got it is fixed by enabling the
7
+ # server that serves it, and the item can only say so — and deep-link to
8
+ # MCP Services — when something here can name that server.
9
+ #
10
+ # Resolution follows the order ToolDiscovery attributes traffic in: an
11
+ # explicit +mcp__server__tool+ namespace wins because the name said so,
12
+ # then MCPCatalog's hints for the bare names of well-known servers, then
13
+ # the servers the agent itself declares in +mcp_servers+ when one of them
14
+ # lists the tool. Nothing here reads telemetry: the resolver runs inside
15
+ # the request that serializes a run, and every lookup is a constant or a
16
+ # single agent attribute.
17
+ #
18
+ # The status is what the fix item's action turns on:
19
+ #
20
+ # "enabled" — the agent's mcp_servers configuration names the server
21
+ # "available" — the catalog (built-in or ActionAgent.mcp_catalog) knows
22
+ # the server and the agent has not enabled it
23
+ # nil — the namespace named a server nothing here knows; the
24
+ # report renders that as "unknown"
25
+ #
26
+ # @example
27
+ # resolver = EvaluationToolResolver.new(agent)
28
+ # resolver.call("browser_navigate")
29
+ # # => { "key" => "playwright", "name" => "Playwright", "status" => "available" }
30
+ class EvaluationToolResolver
31
+ ENABLED = "enabled"
32
+ AVAILABLE = "available"
33
+
34
+ attr_reader :agent
35
+
36
+ # @param agent [ActionAgent::Agent, nil] the agent the run evaluated; nil
37
+ # resolves against the catalog alone
38
+ def initialize(agent)
39
+ @agent = agent
40
+ end
41
+
42
+ # The Report's +tool_resolver+ contract.
43
+ #
44
+ # @param tool_name [String, Symbol] a tool name as a scenario expected it
45
+ # or the model called it
46
+ # @return [Hash, nil] +{ "key", "name", "status" }+, or nil when no
47
+ # server can be named for the tool
48
+ def call(tool_name)
49
+ key = server_key_for(tool_name)
50
+ return nil if key.nil?
51
+
52
+ { "key" => key, "name" => display_name_for(key), "status" => status_for(key) }
53
+ end
54
+
55
+ # @param tool_name [String, Symbol]
56
+ # @return [String, nil] the server key the tool belongs to
57
+ def server_key_for(tool_name)
58
+ name = tool_name.to_s.strip
59
+ return nil if name.blank?
60
+
61
+ ActiveAgent::Telemetry::ToolOrigin.server_for(name).presence ||
62
+ MCPCatalog.server_for_tool(name).presence ||
63
+ configured_tools[name]
64
+ end
65
+
66
+ # @param key [String] a server key
67
+ # @return [String, nil] ENABLED, AVAILABLE, or nil when unknown
68
+ def status_for(key)
69
+ return ENABLED if configured_keys.include?(normalize(key))
70
+ return AVAILABLE if MCPCatalog.find(key)
71
+
72
+ nil
73
+ end
74
+
75
+ # The server keys the agent declares, normalized. Callers that need to know
76
+ # what an agent is wired to — rather than where one tool lives — read this.
77
+ #
78
+ # @return [Array<String>]
79
+ def declared_server_keys
80
+ configured_keys.to_a
81
+ end
82
+
83
+ private
84
+
85
+ # The catalog's name when it has one; otherwise the name the agent's
86
+ # own configuration gives the server, and the key as a last resort
87
+ # (which is what MCPCatalog.display_name falls back to as well).
88
+ def display_name_for(key)
89
+ return MCPCatalog.display_name(key) if MCPCatalog.find(key)
90
+
91
+ configured_names[normalize(key)] || MCPCatalog.display_name(key)
92
+ end
93
+
94
+ # Server keys the agent declares, normalized for comparison.
95
+ def configured_keys
96
+ @configured_keys ||= configured_entries.filter_map { |entry| normalize(entry_key(entry)) }.to_set
97
+ end
98
+
99
+ # normalized key => the display name a configured hash entry carries
100
+ # alongside its key ({"key" => "booking", "name" => "Booking Service"}).
101
+ def configured_names
102
+ @configured_names ||= configured_entries.each_with_object({}) do |entry, map|
103
+ next unless entry.respond_to?(:key?)
104
+
105
+ key = normalize(entry_key(entry))
106
+ name = (entry["name"] || entry[:name]).to_s.strip
107
+ next if key.nil? || name.blank? || name.downcase == key
108
+
109
+ map[key] ||= name
110
+ end
111
+ end
112
+
113
+ # bare tool name => server key, from configured entries that list the
114
+ # tools they serve ({"name" => "booking", "tools" => ["search_slots"]}),
115
+ # in the catalog's own +tool_hints+ spelling or as tool hashes.
116
+ def configured_tools
117
+ @configured_tools ||= configured_entries.each_with_object({}) do |entry, map|
118
+ next unless entry.respond_to?(:key?)
119
+
120
+ key = entry_key(entry)
121
+ next if key.nil?
122
+
123
+ Array(entry["tools"] || entry[:tools] || entry["tool_hints"] || entry[:tool_hints]).each do |tool|
124
+ name = (tool.respond_to?(:key?) ? tool["name"] || tool[:name] : tool).to_s.strip
125
+ map[name] ||= key unless name.blank?
126
+ end
127
+ end
128
+ end
129
+
130
+ # The agent's mcp_servers as a list of entries. Agents store an Array of
131
+ # bare names or builder hashes, but an agent seeded from an older
132
+ # template carries a top-level Hash keyed by server name
133
+ # ({"playwright" => {"command" => ...}}) — the same shape ToolDiscovery
134
+ # tolerates — whose values become entries carrying that key.
135
+ def configured_entries
136
+ @configured_entries ||= begin
137
+ servers = agent&.mcp_servers
138
+
139
+ if servers.is_a?(Hash)
140
+ servers.map do |key, value|
141
+ value.respond_to?(:key?) ? value.to_h.stringify_keys.merge("key" => key.to_s) : key.to_s
142
+ end
143
+ else
144
+ Array(servers)
145
+ end
146
+ end
147
+ end
148
+
149
+ # An entry names its server as a bare string, or under +key+ or +name+
150
+ # in a builder hash. Anything else (a stray Array, a number) is skipped.
151
+ def entry_key(entry)
152
+ return entry.to_s.strip.presence if entry.is_a?(String) || entry.is_a?(Symbol)
153
+ return nil unless entry.respond_to?(:key?)
154
+
155
+ (entry["key"] || entry[:key] || entry["name"] || entry[:name]).to_s.strip.presence
156
+ end
157
+
158
+ def normalize(key)
159
+ key.to_s.strip.downcase.presence
160
+ end
161
+ end
162
+ end
@@ -19,7 +19,7 @@ module ActionAgent
19
19
  # shows up in the MCP Services view (as +known: false+) the moment a
20
20
  # namespaced tool call from it is ingested. The catalog only adds names,
21
21
  # descriptions, and the ability to launch.
22
- class McpCatalog
22
+ class MCPCatalog
23
23
  # Whether a server can be started inside a sandbox session. Servers that
24
24
  # need workspace-specific credentials (github, slack, postgres) are
25
25
  # listable and attributable but not launchable from the dashboard — there
@@ -156,7 +156,12 @@ module ActionAgent
156
156
  sandbox: false,
157
157
  first_party: true,
158
158
  requires_credentials: [ "Platform API key" ],
159
- tool_hints: %w[call_agent list_agents]
159
+ # No tool_hints: the facade's tools are run_<slug> (one per agent, see
160
+ # Api::MCPController#tools_list), which no static list can name. The
161
+ # old hints advertised call_agent and list_agents, which the facade
162
+ # never exposed, and would have misattributed an agent-defined tool
163
+ # of either name to this server.
164
+ tool_hints: []
160
165
  }
161
166
  ].freeze
162
167
 
@@ -172,11 +177,12 @@ module ActionAgent
172
177
  end.freeze
173
178
 
174
179
  class << self
175
- # Every catalog entry, as API-shaped hashes.
180
+ # Every catalog entry — built-ins plus the host's
181
+ # +ActionAgent.mcp_catalog+ registrations — as API-shaped hashes.
176
182
  #
177
183
  # @return [Array<Hash>]
178
184
  def all
179
- SERVERS.map { |server| present(server) }
185
+ entries.map { |server| present(server) }
180
186
  end
181
187
 
182
188
  # Entries that can be started inside a sandbox session.
@@ -189,14 +195,21 @@ module ActionAgent
189
195
  # @param key [String, Symbol]
190
196
  # @return [Hash, nil] the catalog entry, or nil when unknown
191
197
  def find(key)
192
- entry = BY_KEY[key.to_s]
198
+ entry = index[key.to_s]
193
199
  present(entry) if entry
194
200
  end
195
201
 
202
+ # Every catalog key, built-ins first.
203
+ #
204
+ # @return [Array<String>]
205
+ def keys
206
+ entries.map { |server| server[:key] }
207
+ end
208
+
196
209
  # @param key [String, Symbol]
197
210
  # @return [Boolean] whether the server can be started in a sandbox
198
211
  def launchable?(key)
199
- BY_KEY[key.to_s]&.fetch(:sandbox, false) || false
212
+ index[key.to_s]&.fetch(:sandbox, false) || false
200
213
  end
201
214
 
202
215
  # The catalog server a bare (non-namespaced) tool name belongs to.
@@ -208,7 +221,7 @@ module ActionAgent
208
221
  # @param tool_name [String, Symbol]
209
222
  # @return [String, nil] the server key
210
223
  def server_for_tool(tool_name)
211
- TOOL_HINTS[tool_name.to_s]
224
+ tool_hints[tool_name.to_s]
212
225
  end
213
226
 
214
227
  # The display name for a server key, falling back to the key itself for
@@ -217,11 +230,36 @@ module ActionAgent
217
230
  # @param key [String, Symbol]
218
231
  # @return [String]
219
232
  def display_name(key)
220
- BY_KEY[key.to_s]&.fetch(:name) || key.to_s
233
+ index[key.to_s]&.[](:name) || key.to_s
221
234
  end
222
235
 
223
236
  private
224
237
 
238
+ # Built-ins plus host registrations, deduplicated by key (first
239
+ # declaration wins, so a built-in keeps its key). Derived per call
240
+ # rather than memoized: the host list is tiny and tests reconfigure it.
241
+ def entries
242
+ (SERVERS + host_entries).uniq { |server| server[:key] }
243
+ end
244
+
245
+ def host_entries
246
+ Array(ActionAgent.mcp_catalog).filter_map do |entry|
247
+ normalized = entry.to_h.symbolize_keys
248
+ normalized[:key] = normalized[:key].to_s
249
+ normalized unless normalized[:key].blank?
250
+ end
251
+ end
252
+
253
+ def index
254
+ entries.index_by { |server| server[:key] }
255
+ end
256
+
257
+ def tool_hints
258
+ entries.each_with_object({}) do |server, map|
259
+ Array(server[:tool_hints]).each { |tool| map[tool.to_s] ||= server[:key] }
260
+ end
261
+ end
262
+
225
263
  # Drops the internal-only tool_hints and normalizes optional keys so
226
264
  # every entry serializes with the same shape.
227
265
  def present(server)
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "resolv"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module ActionAgent
9
+ # Speaks Streamable HTTP MCP: initialize once per client, then call tools
10
+ # under the session id the server hands back. One instance per server url.
11
+ #
12
+ # A server answers as plain JSON or as an SSE stream whose data: lines carry
13
+ # the JSON-RPC response; both are accepted.
14
+ class MCPClient
15
+ OPEN_TIMEOUT_SECONDS = 5
16
+ READ_TIMEOUT_SECONDS = 60
17
+
18
+ class Error < StandardError; end
19
+
20
+ def initialize(url:, label: nil)
21
+ @uri = URI(url)
22
+ @label = label.presence || @uri.host
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # The server's own tool definitions, as the provider expects them:
27
+ # { name:, description:, parameters: }. An MCP server describes its tools
28
+ # in tools/list, so the model is told about them in the server's words
29
+ # rather than a copy kept in the dashboard.
30
+ def list_tools
31
+ ensure_session!
32
+ response = post({ jsonrpc: "2.0", id: next_id, method: "tools/list", params: {} }, session: @session_id)
33
+ Array(response.dig("result", "tools")).map do |tool|
34
+ {
35
+ name: tool["name"],
36
+ description: tool["description"].to_s,
37
+ parameters: tool["inputSchema"] || { type: "object", properties: {} }
38
+ }
39
+ end
40
+ end
41
+
42
+ # Returns { text:, is_error: } — the tool result's text content.
43
+ def call_tool(name, arguments = {})
44
+ Rails.logger.debug("[MCPClient] call #{name} args=#{arguments.inspect[0, 200]}")
45
+ ensure_session!
46
+ response = post(
47
+ { jsonrpc: "2.0", id: next_id, method: "tools/call",
48
+ params: { name: name, arguments: arguments } },
49
+ session: @session_id
50
+ )
51
+ result = response["result"]
52
+ unless result
53
+ Rails.logger.warn("[MCPClient] #{name} unexpected response: #{response.inspect[0, 500]}")
54
+ raise Error, (response.dig("error", "message") || "empty MCP response")
55
+ end
56
+
57
+ text = Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
58
+ { text: text, is_error: result["isError"] ? true : false }
59
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, SocketError => e
60
+ raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
61
+ end
62
+
63
+ private
64
+
65
+ # Opens the session once. A server that keeps per-session state answers
66
+ # initialize with an Mcp-Session-Id and expects it on every later request;
67
+ # a stateless server (the shape a multi-worker Rails host serves) returns
68
+ # none, and later requests carry no session header. Both are the protocol's
69
+ # own contract, so an absent id is not an error — @initialized records that
70
+ # the handshake happened either way.
71
+ def ensure_session!
72
+ @mutex.synchronize do
73
+ next if @initialized
74
+
75
+ _body, response = post_raw(
76
+ { jsonrpc: "2.0", id: next_id, method: "initialize",
77
+ params: { protocolVersion: "2025-03-26", capabilities: {},
78
+ clientInfo: { name: "activeagents", version: "1.0" } } }
79
+ )
80
+ @session_id = response["mcp-session-id"].presence
81
+ @initialized = true
82
+
83
+ post({ jsonrpc: "2.0", method: "notifications/initialized" }, session: @session_id)
84
+ end
85
+ end
86
+
87
+ def post(payload, session: nil)
88
+ body, _response = post_raw(payload, session: session)
89
+ body
90
+ end
91
+
92
+ def post_raw(payload, session: nil)
93
+ # Tool calls run inside the provider SDK's streaming enumerator — a
94
+ # fiber, where Net::HTTP reads of SSE bodies misbehave (headers arrive,
95
+ # body comes back empty). A dedicated thread always does real blocking
96
+ # IO outside any fiber/scheduler context.
97
+ Thread.new { blocking_post_raw(payload, session: session) }.value
98
+ end
99
+
100
+ def blocking_post_raw(payload, session: nil)
101
+ http = Net::HTTP.new(@uri.host, @uri.port)
102
+ # Without this an https:// endpoint is sent as plaintext to port 443.
103
+ http.use_ssl = @uri.scheme == "https"
104
+ # Container->host bridge hostnames (host.orb.internal) publish an IPv6
105
+ # address whose path doesn't reach the server; dual-stack connects then
106
+ # fail intermittently. Pin to IPv4 while keeping the Host header.
107
+ if (ipv4 = ipv4_address)
108
+ http.ipaddr = ipv4
109
+ end
110
+ http.open_timeout = OPEN_TIMEOUT_SECONDS
111
+ http.read_timeout = READ_TIMEOUT_SECONDS
112
+ request = Net::HTTP::Post.new(@uri.request_uri)
113
+ request["Content-Type"] = "application/json"
114
+ request["Accept"] = "application/json, text/event-stream"
115
+ request["Mcp-Session-Id"] = session if session
116
+ request.body = payload.to_json
117
+
118
+ response = http.request(request)
119
+ Rails.logger.debug(
120
+ "[MCPClient] #{payload[:method]} -> #{response.code} " \
121
+ "ct=#{response['Content-Type']} bytes=#{response.body.to_s.bytesize} session=#{session ? 'yes' : 'no'}"
122
+ )
123
+ unless response.code.to_i.between?(200, 299)
124
+ Rails.logger.warn("[MCPClient] HTTP #{response.code}: #{response.body.to_s[0, 300]}")
125
+ raise Error, "MCP server returned HTTP #{response.code}"
126
+ end
127
+
128
+ parsed = parse_body(response)
129
+ if parsed.empty? && payload[:id]
130
+ Rails.logger.warn("[MCPClient] unparsed body (#{response['Content-Type']}): #{response.body.to_s[0, 500]}")
131
+ end
132
+ [ parsed, response ]
133
+ end
134
+
135
+ # Streamable HTTP answers as plain JSON or as an SSE stream whose data:
136
+ # lines carry the JSON-RPC response — accept both.
137
+ def parse_body(response)
138
+ body = response.body.to_s
139
+ return {} if body.empty?
140
+
141
+ if response["Content-Type"].to_s.include?("text/event-stream")
142
+ body.lines
143
+ .select { |line| line.start_with?("data:") }
144
+ .filter_map { |line| JSON.parse(line.delete_prefix("data:").strip) rescue nil }
145
+ .find { |json| json["result"] || json["error"] } || {}
146
+ else
147
+ # A notification carries no id, and a server may answer it with a bare
148
+ # `null` body — JSON, but not an object.
149
+ JSON.parse(body) || {}
150
+ end
151
+ rescue JSON::ParserError
152
+ {}
153
+ end
154
+
155
+ def ipv4_address
156
+ return @ipv4_address if defined?(@ipv4_address)
157
+
158
+ @ipv4_address = Resolv.getaddresses(@uri.host).find { |address| address =~ Resolv::IPv4::Regex }
159
+ rescue Resolv::ResolvError
160
+ @ipv4_address = nil
161
+ end
162
+
163
+ def next_id
164
+ @id = (@id || 0) + 1
165
+ end
166
+ end
167
+ end
@@ -7,7 +7,7 @@ module ActionAgent
7
7
  # to a SessionRecording for playback and debugging.
8
8
  #
9
9
  # Usage:
10
- # middleware = McpRecordingMiddleware.new(session_recording: recording)
10
+ # middleware = MCPRecordingMiddleware.new(session_recording: recording)
11
11
  #
12
12
  # # Process a tool call
13
13
  # result = middleware.intercept(tool_call) do
@@ -15,7 +15,7 @@ module ActionAgent
15
15
  # mcp_client.call_tool(tool_call)
16
16
  # end
17
17
  #
18
- class McpRecordingMiddleware
18
+ class MCPRecordingMiddleware
19
19
  # Map of Playwright MCP tool names to our action types
20
20
  PLAYWRIGHT_TOOLS = {
21
21
  "browser_navigate" => "navigate",
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Routes a tool call to the MCP server that serves it.
5
+ #
6
+ # An agent names the servers it uses in +mcp_servers+, and a catalog entry
7
+ # carries the url to reach one over HTTP. A tool the agent's servers claim is
8
+ # called there; anything else returns nil, and the caller falls back to the
9
+ # engine's own AgentToolbox.
10
+ #
11
+ # Only HTTP transports are dispatchable. A stdio server runs as a child
12
+ # process of whatever launched it, so the dashboard has no address to call —
13
+ # those stay listable and attributable without being callable.
14
+ class MCPToolDispatcher
15
+ HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
16
+
17
+ def initialize(agent)
18
+ @agent = agent
19
+ @resolver = EvaluationToolResolver.new(agent)
20
+ @clients = {}
21
+ end
22
+
23
+ # Whether this tool belongs to one of the agent's own reachable servers.
24
+ def dispatchable?(tool_name)
25
+ endpoint_for(tool_name).present?
26
+ end
27
+
28
+ # Whether the agent names any server the dashboard can call. An agent with
29
+ # none has nothing to execute beyond the engine's own toolbox.
30
+ def any_reachable_server?
31
+ resolver.declared_server_keys.any? do |key|
32
+ entry = MCPCatalog.find(key)
33
+ entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
34
+ end
35
+ end
36
+
37
+ # Calls the tool on its server. Returns the same shape AgentToolbox
38
+ # produces for a text result, or an { error: } hash when the server
39
+ # refuses — a failing tool is a result to score, not an exception to
40
+ # abort the run.
41
+ #
42
+ # @return [Hash, nil] nil when no configured server claims the tool
43
+ def call(tool_name, arguments = {})
44
+ endpoint = endpoint_for(tool_name)
45
+ return nil unless endpoint
46
+
47
+ result = client_for(endpoint).call_tool(tool_name.to_s, arguments)
48
+ return { error: "#{tool_name} failed: #{result[:text]}" } if result[:is_error]
49
+
50
+ { text: result[:text] }
51
+ rescue MCPClient::Error => e
52
+ { error: "#{tool_name} failed: #{e.message}" }
53
+ end
54
+
55
+ # Tool definitions from every reachable server the agent declares, in the
56
+ # shape tool_schemas hands the provider. A server that cannot be reached
57
+ # contributes nothing rather than failing the run — the tools it serves
58
+ # then simply are not offered, and a scenario expecting them fails with a
59
+ # fault naming them.
60
+ def tool_definitions
61
+ resolver.declared_server_keys.flat_map do |key|
62
+ entry = MCPCatalog.find(key)
63
+ next [] unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS) && entry[:url].present?
64
+
65
+ begin
66
+ client_for(entry).list_tools
67
+ rescue MCPClient::Error => e
68
+ Rails.logger.warn("[MCPToolDispatcher] #{key} tools/list failed: #{e.message}")
69
+ []
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :agent, :resolver
77
+
78
+ # The catalog entry for the server that serves this tool, but only when the
79
+ # agent configured that server and the entry carries an http url. Scoping to
80
+ # the agent's own servers is what keeps one agent's tools from reaching
81
+ # another's.
82
+ def endpoint_for(tool_name)
83
+ key = resolver.server_key_for(tool_name)
84
+ return nil if key.blank?
85
+ return nil unless resolver.status_for(key) == EvaluationToolResolver::ENABLED
86
+
87
+ entry = MCPCatalog.find(key)
88
+ return nil unless entry && entry[:transport].to_s.in?(HTTP_TRANSPORTS)
89
+ return nil if entry[:url].blank?
90
+
91
+ entry
92
+ end
93
+
94
+ # One client per server for the life of this dispatcher, so a run's tool
95
+ # calls share the MCP session the first call opens.
96
+ def client_for(entry)
97
+ @clients[entry[:key]] ||= MCPClient.new(url: absolute_url(entry[:url]), label: entry[:name] || entry[:key])
98
+ end
99
+
100
+ # A host registers its own servers with a path ("/mcp/diagnostic"), since it
101
+ # does not know the origin it will be served under. ACTIONAGENT_MCP_ORIGIN
102
+ # names that origin; without it a relative path is not reachable.
103
+ def absolute_url(url)
104
+ return url if url.to_s.start_with?("http://", "https://")
105
+
106
+ origin = ENV["ACTIONAGENT_MCP_ORIGIN"].presence
107
+ raise MCPClient::Error, "set ACTIONAGENT_MCP_ORIGIN to reach #{url}" if origin.blank?
108
+
109
+ # URI.join, not File.join: a path is a URL reference, and only URI
110
+ # resolves one against an origin that carries its own path.
111
+ URI.join(origin, url).to_s
112
+ rescue URI::Error => e
113
+ raise MCPClient::Error, "ACTIONAGENT_MCP_ORIGIN #{origin.inspect} cannot reach #{url}: #{e.message}"
114
+ end
115
+ end
116
+ end