actionagent 1.9.5 → 1.9.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +62 -61
  4. data/app/controllers/action_agent/api/agents_controller.rb +15 -6
  5. data/app/controllers/action_agent/api/base_controller.rb +3 -2
  6. data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
  7. data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
  8. data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
  9. data/app/controllers/action_agent/api/projects_controller.rb +29 -4
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +31 -6
  13. data/app/controllers/action_agent/traces_controller.rb +3 -2
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
  15. data/app/jobs/action_agent/agent_execution_job.rb +26 -6
  16. data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
  17. data/app/models/action_agent/catalog_scenario.rb +8 -2
  18. data/app/models/action_agent/evaluation_run.rb +11 -3
  19. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  20. data/app/models/action_agent/mcp_server_config.rb +273 -0
  21. data/app/models/action_agent/model_pricing.rb +135 -24
  22. data/app/models/action_agent/telemetry_trace.rb +35 -14
  23. data/app/queries/action_agent/agent_executions.rb +4 -1
  24. data/app/queries/action_agent/metrics_report.rb +1 -1
  25. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  26. data/app/services/action_agent/agent_execution_service.rb +4 -2
  27. data/app/services/action_agent/agent_scorecard.rb +16 -12
  28. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  29. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  30. data/app/services/action_agent/evaluation_report_import.rb +14 -2
  31. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  32. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  33. data/app/services/action_agent/mcp_client.rb +36 -4
  34. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  35. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  36. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  37. data/app/services/action_agent/scenario_catalog_import.rb +2 -0
  38. data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
  39. data/app/services/action_agent/tool_discovery.rb +5 -1
  40. data/config/routes.rb +6 -0
  41. data/lib/action_agent/engine.rb +10 -0
  42. data/lib/action_agent/version.rb +1 -1
  43. data/lib/action_agent.rb +17 -0
  44. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  45. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  46. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  47. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  48. metadata +8 -2
@@ -101,16 +101,16 @@ module ActionAgent
101
101
  costs = Hash.new(0.0)
102
102
  priced = Set.new
103
103
 
104
- run_models(windowed_runs).each do |agent_id, model, input, output|
105
- cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
104
+ run_models(windowed_runs).each do |agent_id, model, provider, input, output|
105
+ cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input, output_tokens: output)
106
106
  next unless cost
107
107
 
108
108
  costs[agent_id] += cost
109
109
  priced << agent_id
110
110
  end
111
111
 
112
- trace_models(unclaimed_traces(agent_ids, window_start)).each do |agent_id, model, input, output|
113
- cost = ModelPricing.estimate(model: model, input_tokens: input, output_tokens: output)
112
+ trace_models(unclaimed_traces(agent_ids, window_start)).each do |agent_id, model, provider, input, output|
113
+ cost = ModelPricing.estimate(model: model, provider: provider, input_tokens: input, output_tokens: output)
114
114
  next unless cost
115
115
 
116
116
  costs[agent_id] += cost
@@ -123,27 +123,31 @@ module ActionAgent
123
123
  end
124
124
  private_class_method :estimated_costs
125
125
 
126
- # [agent_id, model, input_tokens, output_tokens] per run. The model
127
- # lives in the output_metadata JSON, which only PostgreSQL can reach
128
- # from SQL; elsewhere the column is read back in Ruby.
126
+ # [agent_id, model, provider, input_tokens, output_tokens] per run. The
127
+ # model and the provider live in the output_metadata JSON, which only
128
+ # PostgreSQL can reach from SQL; elsewhere the column is read back in
129
+ # Ruby.
129
130
  def self.run_models(runs)
130
131
  if AgentRun.postgres?
131
- runs.pluck(:agent_id, Arel.sql("output_metadata ->> 'model'"), :input_tokens, :output_tokens)
132
+ runs.pluck(:agent_id, Arel.sql("output_metadata ->> 'model'"), Arel.sql("output_metadata ->> 'provider'"),
133
+ :input_tokens, :output_tokens)
132
134
  else
133
135
  runs.pluck(:agent_id, :output_metadata, :input_tokens, :output_tokens).map do |agent_id, metadata, input, output|
134
- [ agent_id, metadata.is_a?(Hash) ? metadata["model"] : nil, input, output ]
136
+ metadata = {} unless metadata.is_a?(Hash)
137
+ [ agent_id, metadata["model"], metadata["provider"], input, output ]
135
138
  end
136
139
  end
137
140
  end
138
141
  private_class_method :run_models
139
142
 
140
- # The same, for reported traces: the model is on the first llm span.
143
+ # The same, for reported traces: the model and provider are on the
144
+ # first llm span.
141
145
  def self.trace_models(traces)
142
146
  table = ActionAgent.trace_model.table_name
143
147
 
144
- ActionAgent.trace_model.pluck_with_llm_model(
148
+ ActionAgent.trace_model.pluck_with_llm_model_and_provider(
145
149
  traces, Arel.sql("#{table}.agent_id"), :total_input_tokens, :total_output_tokens
146
- ).map { |model, agent_id, input, output| [ agent_id, model, input, output ] }
150
+ ).map { |model, provider, agent_id, input, output| [ agent_id, model, provider, input, output ] }
147
151
  end
148
152
  private_class_method :trace_models
149
153
 
@@ -26,8 +26,10 @@ module ActionAgent
26
26
  # AgentToolbox turns into function schemas at generation time.
27
27
  # * **MCP** — never stored on the roster. Computed from the services the
28
28
  # agent enables, which is where they are edited. The services are the
29
- # catalog plus the live checkout sandbox runtimes the caller hands in
30
- # (+runtimes:+), which an agent enables under their "sandbox:<id>" keys.
29
+ # catalog plus the listings the caller hands in (+runtimes:+): the live
30
+ # checkout sandbox runtimes, which an agent enables under their
31
+ # "sandbox:<id>" keys, and the servers the owner added on the MCP Services
32
+ # page, enabled under their "custom:<id>" keys.
31
33
  #
32
34
  # Enablement reads the agent's own configuration: +tools+ for the dashboard
33
35
  # capabilities and the schema tools, +mcp_servers+ for services and their
@@ -135,6 +137,7 @@ module ActionAgent
135
137
  first_party: catalog ? catalog[:first_party] : false,
136
138
  known: !catalog.nil?,
137
139
  runtime: SandboxSession.runtime_server_key?(key),
140
+ custom: MCPServerConfig.key?(key),
138
141
  transport: transport_label(catalog),
139
142
  status: service_status(calls, enabled, catalog),
140
143
  enabled: enabled,
@@ -21,7 +21,7 @@ module ActionAgent
21
21
  after = indexed(@session.verification_run)
22
22
  (before.keys | after.keys).map do |key|
23
23
  first, last = before[key], after[key]
24
- { scenario_key: key[0], model: key[1], change: self.class.change(first, last),
24
+ { scenario_key: key[0], model: key[1], trial: key[2], change: self.class.change(first, last),
25
25
  before: first&.status, after: last&.status, before_score: first&.score&.to_f, after_score: last&.score&.to_f,
26
26
  score_change: first&.score && last&.score ? (last.score - first.score).to_f.round(3) : nil,
27
27
  before_fault: first&.fault, fault: last&.fault }
@@ -45,7 +45,8 @@ module ActionAgent
45
45
  "[Verification run](#{mount}/evaluations/#{base.evaluation_id}/runs/#{@session.verification_run_id})", "",
46
46
  "| Scenario / model | Before | After | Score change | Fault |", "| --- | --- | --- | --- | --- |" ]
47
47
  rows.each do |row|
48
- cells = [ "#{row[:scenario_key]} / #{row[:model]}", row[:before], row[:after] || "pending", row[:score_change], row[:fault] || "—" ]
48
+ trial = " (trial #{row[:trial]})" if row[:trial] > 1
49
+ cells = [ "#{row[:scenario_key]} / #{row[:model]}#{trial}", row[:before], row[:after] || "pending", row[:score_change], row[:fault] || "—" ]
49
50
  lines << "| #{cells.map { |cell| cell.to_s.gsub('|', '\\|').gsub(/[\r\n]/, ' ') }.join(' | ')} |"
50
51
  end
51
52
  lines += [ "", "## Session summary", @session.result.to_s, "", "Code session ##{@session.id}. Review the diff and full-suite regressions before merging." ]
@@ -54,6 +55,9 @@ module ActionAgent
54
55
 
55
56
  private
56
57
 
58
+ # Results by scenario key, model and trial, so a run that replayed each
59
+ # scenario several times compares trial by trial rather than keeping
60
+ # whichever trial was read last.
57
61
  def indexed(run)
58
62
  return {} unless run
59
63
  item = @session.fix_item || {}
@@ -62,7 +66,7 @@ module ActionAgent
62
66
  model = [ result.provider, result.model ].compact_blank.join("/")
63
67
  next unless Array(item["scenario_keys"]).include?(key) &&
64
68
  (Array(item["models"]).include?(model) || Array(item["models"]).include?(result.model))
65
- index[[ key, model ]] = result
69
+ index[[ key, model, result.trial ]] = result
66
70
  end
67
71
  end
68
72
  end
@@ -18,7 +18,7 @@ module ActionAgent
18
18
  # scope (SCOPE_KEYS in its metadata), named for both
19
19
  # scenarios — one per reported scenario key, updated to the reported prompt
20
20
  # run — one EvaluationRun per tenant and run_id, with one
21
- # EvaluationScenarioResult per scenario and model
21
+ # EvaluationScenarioResult per scenario, model and trial
22
22
  #
23
23
  # An envelope's `status` is "complete" when absent. A "running" envelope
24
24
  # stores the results so far as a running run, and each later delivery under
@@ -71,6 +71,9 @@ module ActionAgent
71
71
 
72
72
  MAX_BYTES = 2.megabytes
73
73
  MAX_RESULTS = 1000
74
+ # The highest trial number the collector stores for a result (its
75
+ # `trial`, ActiveAgent::Evals::Result#trial).
76
+ MAX_TRIALS = 1000
74
77
  MAX_MODELS = 50
75
78
  MAX_TEXT = MAX_BYTES
76
79
  # Scenarios one evaluation may hold, and evaluations one agent may hold,
@@ -613,6 +616,7 @@ module ActionAgent
613
616
  recommendation: truncated(result["recommendation"], TEXT_BYTES),
614
617
  diagnosis: (result["diagnosis"] || {}).merge(
615
618
  "_replay_metadata" => result["metadata"] || {},
619
+ "_trial" => trial_of(result),
616
620
  "_scenario_snapshot" => {
617
621
  "key" => result["scenario_key"], "group" => result["group"], "prompt" => result["prompt"],
618
622
  "position" => scenario.position, "expectations" => {}
@@ -622,6 +626,12 @@ module ActionAgent
622
626
  )
623
627
  end
624
628
 
629
+ # Which replay of its scenario under its model a result is: 1 for a
630
+ # result that names none (an absent or null `trial`).
631
+ def trial_of(result)
632
+ result["trial"].nil? ? 1 : result["trial"]
633
+ end
634
+
625
635
  # A judge usage as the engine stores it: the keys the dashboard reads,
626
636
  # each cut to its type, or nil for none.
627
637
  def bounded_judge_usage(usage)
@@ -735,7 +745,9 @@ module ActionAgent
735
745
  object!(result, "result #{index}")
736
746
  %w[scenario_key label provider model].each { |key| string!(result[key], "result.#{key}", 200, required: true) }
737
747
  raise Invalid, "result label #{result['label']} is missing from report.models" unless report["models"].key?(result["label"])
738
- raise Invalid, "duplicate scenario/model result" unless pairs.add?(result.values_at("scenario_key", "label"))
748
+ trial = trial_of(result)
749
+ raise Invalid, "result.trial must be an integer from 1 to #{MAX_TRIALS}" unless trial.is_a?(Integer) && trial.between?(1, MAX_TRIALS)
750
+ raise Invalid, "duplicate scenario/model result" unless pairs.add?([ result["scenario_key"], result["label"], trial ])
739
751
 
740
752
  spec = result.values_at("provider", "model")
741
753
  raise Invalid, "result label #{result['label']} names more than one provider/model" if label_specs.fetch(result["label"], spec) != spec
@@ -542,14 +542,14 @@ module ActionAgent
542
542
  return {} if scope.nil?
543
543
 
544
544
  table = ActionAgent.trace_model.table_name
545
- rows = ActionAgent.trace_model.pluck_with_llm_model(
545
+ rows = ActionAgent.trace_model.pluck_with_llm_model_and_provider(
546
546
  scope.where(trace_id: ids),
547
547
  Arel.sql("#{table}.trace_id"), Arel.sql("#{table}.total_input_tokens"), Arel.sql("#{table}.total_output_tokens"),
548
548
  Arel.sql("#{table}.agent_action")
549
549
  )
550
- rows.to_h do |model, trace_id, input, output, action|
550
+ rows.to_h do |model, provider, trace_id, input, output, action|
551
551
  [ trace_id.to_s, Trace.new(trace_id: trace_id.to_s, input_tokens: input.to_i, output_tokens: output.to_i,
552
- model: model, provider: provider_of(model), action: action) ]
552
+ model: model, provider: provider.presence || provider_of(model), action: action) ]
553
553
  end
554
554
  rescue StandardError => e
555
555
  Rails.logger.warn("[ActionAgent] evaluation run #{@run.id} trace pricing failed: #{e.class}: #{e.message}")
@@ -565,8 +565,9 @@ module ActionAgent
565
565
 
566
566
  private
567
567
 
568
- # A gateway's model names carry the vendor; the model's own name does
569
- # not say which provider served it, which ModelPricing works around.
568
+ # For a trace whose llm span recorded no provider: a gateway's model
569
+ # names carry the vendor; the model's own name does not say which
570
+ # provider served it, which ModelPricing works around.
570
571
  def provider_of(model)
571
572
  head, rest = model.to_s.split("/", 2)
572
573
  rest.present? && ModelPricing::GATEWAY_PREFIXES.include?(head.downcase) ? head.downcase : nil
@@ -11,15 +11,18 @@ module ActionAgent
11
11
  # explicit +mcp__server__tool+ namespace wins because the name said so,
12
12
  # then MCPCatalog's hints for the bare names of well-known servers, then
13
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.
14
+ # lists the tool, then the servers the agent's owner added on the MCP
15
+ # Services page (MCPServerConfig, "custom:<id>"), by the tools their last
16
+ # connection test listed. Nothing here reads telemetry: the resolver runs
17
+ # inside the request that serializes a run, and every lookup is a constant,
18
+ # a single agent attribute, or one query for the owner's configurations.
17
19
  #
18
20
  # The status is what the fix item's action turns on:
19
21
  #
20
22
  # "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
+ # "available" — the catalog (built-in or ActionAgent.mcp_catalog) or the
24
+ # owner's enabled configurations know the server, and the
25
+ # agent has not enabled it
23
26
  # nil — the namespace named a server nothing here knows; the
24
27
  # report renders that as "unknown"
25
28
  #
@@ -60,14 +63,15 @@ module ActionAgent
60
63
 
61
64
  ActiveAgent::Telemetry::ToolOrigin.server_for(name).presence ||
62
65
  MCPCatalog.server_for_tool(name).presence ||
63
- configured_tools[name]
66
+ configured_tools[name] ||
67
+ custom_tools[name]
64
68
  end
65
69
 
66
70
  # @param key [String] a server key
67
71
  # @return [String, nil] ENABLED, AVAILABLE, or nil when unknown
68
72
  def status_for(key)
69
73
  return ENABLED if configured_keys.include?(normalize(key))
70
- return AVAILABLE if MCPCatalog.find(key)
74
+ return AVAILABLE if MCPCatalog.find(key) || custom_config(key)
71
75
 
72
76
  nil
73
77
  end
@@ -105,6 +109,7 @@ module ActionAgent
105
109
  # (which is what MCPCatalog.display_name falls back to as well).
106
110
  def display_name_for(key)
107
111
  return MCPCatalog.display_name(key) if MCPCatalog.find(key)
112
+ return custom_config(key).name if custom_config(key)
108
113
 
109
114
  configured_names[normalize(key)] || MCPCatalog.display_name(key)
110
115
  end
@@ -145,6 +150,29 @@ module ActionAgent
145
150
  end
146
151
  end
147
152
 
153
+ # The agent's owner's enabled MCPServerConfig rows, read once. None
154
+ # without an agent: there is no owner to scope them to.
155
+ def custom_configs
156
+ @custom_configs ||= agent ? MCPServerConfig.for_owner(agent.try(:owner)).enabled.order(:id).to_a : []
157
+ end
158
+
159
+ def custom_config(key)
160
+ return nil unless MCPServerConfig.key?(key)
161
+
162
+ custom_configs.find { |config| config.server_key == key.to_s.strip }
163
+ end
164
+
165
+ # bare tool name => "custom:<id>", from the tools each of the owner's
166
+ # configurations listed when last tested. A configuration the agent
167
+ # enables claims a name before one it does not.
168
+ def custom_tools
169
+ @custom_tools ||= custom_configs
170
+ .sort_by { |config| [ configured_keys.include?(config.server_key) ? 0 : 1, config.id ] }
171
+ .each_with_object({}) do |config, map|
172
+ config.tool_names_list.each { |tool| map[tool] ||= config.server_key }
173
+ end
174
+ end
175
+
148
176
  # The agent's mcp_servers as a list of entries. Agents store an Array of
149
177
  # bare names or builder hashes, but an agent seeded from an older
150
178
  # template carries a top-level Hash keyed by server name
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "resolv"
5
6
  require "uri"
6
7
  require "json"
@@ -17,12 +18,24 @@ module ActionAgent
17
18
 
18
19
  class Error < StandardError; end
19
20
 
21
+ # Failures to reach the server at all, raised as Error so a caller
22
+ # handles one exception for a server that cannot be called.
23
+ NETWORK_ERRORS = [
24
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
25
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, SocketError
26
+ ].freeze
27
+
20
28
  # +headers+ are sent with every request: a checkout sandbox's runtime
21
29
  # authenticates its MCP facade with a bearer token.
22
- def initialize(url:, label: nil, headers: {})
30
+ #
31
+ # +public_only+ refuses a host that resolves to anything but public
32
+ # addresses (MCPHostGuard#public_addresses!), and connects to the address
33
+ # that was checked. A URL a dashboard user supplied is called this way.
34
+ def initialize(url:, label: nil, headers: {}, public_only: false)
23
35
  @uri = URI(url)
24
36
  @label = label.presence || @uri.host
25
37
  @headers = headers.to_h
38
+ @public_only = public_only
26
39
  @mutex = Mutex.new
27
40
  end
28
41
 
@@ -40,6 +53,8 @@ module ActionAgent
40
53
  parameters: tool["inputSchema"] || { type: "object", properties: {} }
41
54
  }
42
55
  end
56
+ rescue *NETWORK_ERRORS => e
57
+ raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
43
58
  end
44
59
 
45
60
  # Returns { text:, is_error: } — the tool result's text content.
@@ -63,7 +78,7 @@ module ActionAgent
63
78
 
64
79
  text = Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
65
80
  { text: text, is_error: result["isError"] ? true : false }
66
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, SocketError => e
81
+ rescue *NETWORK_ERRORS => e
67
82
  raise Error, "MCP server #{@label} unreachable at #{@uri} (#{e.class})"
68
83
  end
69
84
 
@@ -101,6 +116,10 @@ module ActionAgent
101
116
  # fiber, where Net::HTTP reads of SSE bodies misbehave (headers arrive,
102
117
  # body comes back empty). A dedicated thread always does real blocking
103
118
  # IO outside any fiber/scheduler context.
119
+ #
120
+ # The address is resolved first, on the caller's thread, so a host the
121
+ # guard refuses raises here rather than inside the thread.
122
+ connect_address
104
123
  Thread.new { blocking_post_raw(payload, session: session) }.value
105
124
  end
106
125
 
@@ -111,8 +130,8 @@ module ActionAgent
111
130
  # Container->host bridge hostnames (host.orb.internal) publish an IPv6
112
131
  # address whose path doesn't reach the server; dual-stack connects then
113
132
  # fail intermittently. Pin to IPv4 while keeping the Host header.
114
- if (ipv4 = ipv4_address)
115
- http.ipaddr = ipv4
133
+ if (address = connect_address)
134
+ http.ipaddr = address
116
135
  end
117
136
  http.open_timeout = OPEN_TIMEOUT_SECONDS
118
137
  http.read_timeout = READ_TIMEOUT_SECONDS
@@ -161,6 +180,19 @@ module ActionAgent
161
180
  {}
162
181
  end
163
182
 
183
+ # The address to connect to. For a public-only client, a checked public
184
+ # address (IPv4 first), resolved once so every request of this client
185
+ # reaches the address that was checked; otherwise the host's IPv4
186
+ # address when it has one.
187
+ def connect_address
188
+ return ipv4_address unless @public_only
189
+
190
+ @checked_address ||= begin
191
+ addresses = MCPHostGuard.public_addresses!(@uri.hostname)
192
+ addresses.find { |address| address =~ Resolv::IPv4::Regex } || addresses.first
193
+ end
194
+ end
195
+
164
196
  def ipv4_address
165
197
  return @ipv4_address if defined?(@ipv4_address)
166
198
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "resolv"
5
+ require "uri"
6
+
7
+ module ActionAgent
8
+ # Decides whether the dashboard may call a user-supplied MCP server URL
9
+ # (MCPServerConfig). Those servers are reached from the dashboard's own
10
+ # network, so without a check any user could point one at an address only
11
+ # the dashboard can reach: a cloud metadata endpoint, a database admin page
12
+ # on the private network, the host's own loopback.
13
+ #
14
+ # The check runs twice:
15
+ #
16
+ # * When a configuration is saved, #url_refusal reads the URL alone, with
17
+ # no DNS lookup: the scheme is http or https, the URL carries no user
18
+ # name or password, and its host is not a literal non-public address or a
19
+ # localhost name.
20
+ # * When MCPClient connects (+public_only: true+), #public_addresses!
21
+ # resolves the host and refuses it unless every address it resolves to
22
+ # is public. The client then connects to the address that was checked,
23
+ # so a name whose DNS answer changes between the check and the connection
24
+ # still reaches only a checked address.
25
+ #
26
+ # ActionAgent.allow_private_mcp_hosts switches both off, for local
27
+ # development against a server on localhost.
28
+ class MCPHostGuard
29
+ SCHEMES = %w[http https].freeze
30
+
31
+ # Addresses no public server is reached at, beyond what IPAddr already
32
+ # names (loopback?, private?, link_local?): the unspecified "this host"
33
+ # ranges, carrier-grade NAT, benchmarking, multicast and reserved space.
34
+ NON_PUBLIC_RANGES = %w[
35
+ 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4
36
+ ::/128 ff00::/8
37
+ ].map { |range| IPAddr.new(range) }.freeze
38
+
39
+ # Host names that resolve to the machine itself however DNS answers.
40
+ LOCAL_NAMES = /\A(?:localhost|.+\.localhost|localhost\.localdomain)\z/i
41
+
42
+ class << self
43
+ # Resolves a host name to its addresses. Replaceable so tests need no
44
+ # network.
45
+ # @return [#call]
46
+ attr_writer :resolver
47
+
48
+ def resolver
49
+ @resolver || Resolv.method(:getaddresses)
50
+ end
51
+
52
+ # Whether private addresses are allowed (ActionAgent.allow_private_mcp_hosts).
53
+ def allow_private?
54
+ ActionAgent.allow_private_mcp_hosts ? true : false
55
+ end
56
+
57
+ # Why +url+ cannot be saved as an MCP server URL, or nil when it can.
58
+ # Reads the URL alone and resolves nothing.
59
+ #
60
+ # @param url [String]
61
+ # @param allow_private [Boolean]
62
+ # @return [String, nil]
63
+ def url_refusal(url, allow_private: allow_private?)
64
+ uri = URI.parse(url.to_s.strip)
65
+ return "must be an http:// or https:// URL" unless SCHEMES.include?(uri.scheme.to_s.downcase)
66
+ return "must name a host" if uri.hostname.blank?
67
+ return "must not carry a user name or password; send credentials in a header instead" if uri.userinfo.present?
68
+ return nil if allow_private
69
+
70
+ host = uri.hostname
71
+ return "must not point at localhost" if LOCAL_NAMES.match?(host)
72
+
73
+ address = literal_address(host)
74
+ return "must not point at a private, loopback or link-local address" if address && !public_address?(address)
75
+
76
+ nil
77
+ rescue URI::InvalidURIError
78
+ "is not a valid URL"
79
+ end
80
+
81
+ # The addresses +host+ resolves to, every one of them public.
82
+ #
83
+ # @param host [String]
84
+ # @return [Array<String>]
85
+ # @raise [MCPClient::Error] when the host does not resolve, or resolves
86
+ # to any non-public address
87
+ def public_addresses!(host)
88
+ addresses = Array(resolver.call(host.to_s)).map(&:to_s).uniq
89
+ raise MCPClient::Error, "#{host} does not resolve to an address" if addresses.empty?
90
+
91
+ refused = addresses.reject { |address| public_address?(address) }
92
+ if refused.any?
93
+ raise MCPClient::Error,
94
+ "#{host} resolves to a private, loopback or link-local address (#{refused.first}); " \
95
+ "set ActionAgent.allow_private_mcp_hosts to reach it"
96
+ end
97
+
98
+ addresses
99
+ rescue Resolv::ResolvError => e
100
+ raise MCPClient::Error, "#{host} does not resolve: #{e.message}"
101
+ end
102
+
103
+ # Whether +address+ (an IP address string or IPAddr) is publicly
104
+ # routable. An IPv4 address written as IPv6 (::ffff:127.0.0.1) is read
105
+ # as the IPv4 address it carries. Anything that does not parse is not
106
+ # public.
107
+ #
108
+ # @return [Boolean]
109
+ def public_address?(address)
110
+ ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
111
+ ip = ip.native
112
+ return false if ip.loopback? || ip.private? || ip.link_local?
113
+
114
+ NON_PUBLIC_RANGES.none? { |range| range.family == ip.family && range.include?(ip) }
115
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
116
+ false
117
+ end
118
+
119
+ private
120
+
121
+ # +host+ as an IPAddr when it is an IP literal, nil when it is a name.
122
+ def literal_address(host)
123
+ IPAddr.new(host)
124
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
125
+ nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -23,6 +23,12 @@ module ActionAgent
23
23
  # is reached the same way (SandboxSession.browser_server_entry), and only
24
24
  # this way. Only those keys are taken; a catalog server the agent does not
25
25
  # enable stays out of reach.
26
+ #
27
+ # A server a dashboard user added on the MCP Services page is enabled as
28
+ # "custom:<id>" and resolves through MCPServerConfig.server_entry, among the
29
+ # agent's owner's configurations only and only while it is enabled. Its
30
+ # client refuses a host that resolves to a non-public address (see
31
+ # MCPHostGuard) unless the host app allows them.
26
32
  class MCPToolDispatcher
27
33
  HTTP_TRANSPORTS = %w[http streamable_http sse].freeze
28
34
  class SandboxUnavailable < MCPClient::Error; end
@@ -43,6 +49,7 @@ module ActionAgent
43
49
  def self.unreachable_reason(entry)
44
50
  return "Unknown to the catalog" if entry.nil?
45
51
  return nil if SandboxSession.runtime_server_key?(entry[:key].to_s)
52
+ return "Switched off on the MCP Services page" if entry[:custom] && entry[:enabled] == false
46
53
 
47
54
  transport = entry[:transport].to_s
48
55
  unless transport.in?(HTTP_TRANSPORTS)
@@ -284,14 +291,16 @@ module ActionAgent
284
291
  end
285
292
 
286
293
  # A catalog server, the app runtime of a checkout sandbox the agent's
287
- # owner started (keys "sandbox:<session_id>"), or, for a key this run was
288
- # given, that sandbox's browser ("browser:<session_id>"). The sandbox
289
- # lookups are scoped to the agent's owner, so naming another tenant's
290
- # session resolves to nothing.
294
+ # owner started (keys "sandbox:<session_id>"), a server the agent's owner
295
+ # added ("custom:<id>"), or, for a key this run was given, that sandbox's
296
+ # browser ("browser:<session_id>"). The sandbox and custom lookups are
297
+ # scoped to the agent's owner, so naming another tenant's session or
298
+ # configuration resolves to nothing.
291
299
  def catalog_entry(key)
292
300
  if SandboxSession.browser_server_key?(key)
293
301
  return extra_server_keys.include?(key) ? SandboxSession.browser_server_entry(key, owner: agent.try(:owner)) : nil
294
302
  end
303
+ return MCPServerConfig.server_entry(key, owner: agent.try(:owner)) if MCPServerConfig.key?(key)
295
304
  return MCPCatalog.connection(key) unless SandboxSession.runtime_server_key?(key)
296
305
 
297
306
  SandboxSession.runtime_server_entry(key, owner: agent.try(:owner))
@@ -303,7 +312,8 @@ module ActionAgent
303
312
  @clients[entry[:key]] ||= MCPClient.new(
304
313
  url: absolute_url(entry[:url]),
305
314
  label: entry[:name] || entry[:key],
306
- headers: entry[:headers] || {}
315
+ headers: entry[:headers] || {},
316
+ public_only: entry[:public_only] ? true : false
307
317
  )
308
318
  end
309
319
 
@@ -10,12 +10,19 @@ module ActionAgent
10
10
  # The host is the OpenAI-compatible base URL (".../v1"); the probe calls
11
11
  # GET {host}/models. An optional API key is sent as a Bearer token for
12
12
  # remote servers behind an authenticating proxy or Ollama Cloud.
13
+ #
14
+ # The result says whether the host is Ollama Cloud (`cloud`) and, for
15
+ # each listed model a price list names, its rate (`rates`): the cloud's
16
+ # models are metered per token, and a local server's copy of a cloud
17
+ # model is priced at the same list rate (ModelPricing). A model priced
18
+ # only by a guess has no entry.
13
19
  class OllamaHostProbe
14
20
  TIMEOUT_SECONDS = 4
21
+ CLOUD_HOST = "ollama.com"
15
22
 
16
- Result = Struct.new(:ok, :host, :models, :latency_ms, :error, keyword_init: true) do
23
+ Result = Struct.new(:ok, :host, :models, :latency_ms, :error, :cloud, :rates, keyword_init: true) do
17
24
  def to_h
18
- { ok: ok, host: host, models: models, latency_ms: latency_ms, error: error }
25
+ { ok: ok, host: host, models: models, latency_ms: latency_ms, error: error, cloud: cloud, rates: rates }
19
26
  end
20
27
  end
21
28
 
@@ -23,11 +30,24 @@ module ActionAgent
23
30
  new(host: host, api_key: api_key).call
24
31
  end
25
32
 
33
+ # Whether +host+ addresses Ollama Cloud (the framework's
34
+ # ActiveAgent::Providers::Ollama::Cloud.cloud_host?, kept in step).
35
+ def self.cloud_host?(host)
36
+ name = URI.parse(host.to_s.strip).host.to_s.downcase
37
+ name == CLOUD_HOST || name.end_with?(".#{CLOUD_HOST}")
38
+ rescue URI::InvalidURIError
39
+ false
40
+ end
41
+
26
42
  def initialize(host:, api_key: nil)
27
43
  @host = ProviderKey.normalize_host(host)
28
44
  @api_key = api_key.presence
29
45
  end
30
46
 
47
+ def cloud?
48
+ self.class.cloud_host?(@host)
49
+ end
50
+
31
51
  def call
32
52
  return failure("No host configured") if @host.blank?
33
53
 
@@ -49,7 +69,7 @@ module ActionAgent
49
69
 
50
70
  data = JSON.parse(response.body)
51
71
  models = Array(data["data"]).filter_map { |model| model["id"] }.sort
52
- Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil)
72
+ Result.new(ok: true, host: @host, models: models, latency_ms: latency_ms, error: nil, cloud: cloud?, rates: rates_for(models))
53
73
  rescue JSON::ParserError
54
74
  failure("#{@host} did not return JSON — is this the OpenAI-compatible /v1 endpoint?")
55
75
  rescue URI::InvalidURIError, ArgumentError
@@ -68,8 +88,20 @@ module ActionAgent
68
88
  @api_key ? { "Authorization" => "Bearer #{@api_key}" } : {}
69
89
  end
70
90
 
91
+ # `{ "gpt-oss:120b" => { input: 0.15, output: 0.6, source: "catalog" } }`
92
+ # for every listed model a price list names, in $ per million tokens.
93
+ def rates_for(models)
94
+ provider = cloud? ? ModelPricing::OLLAMA_CLOUD_PROVIDER : "ollama"
95
+ models.each_with_object({}) do |id, rates|
96
+ detail = ModelPricing.rate_detail(id, provider: provider)
97
+ next if ModelPricing::FALLBACK_SOURCES.include?(detail[:source])
98
+
99
+ rates[id] = detail.slice(:input, :output, :source)
100
+ end
101
+ end
102
+
71
103
  def failure(message, latency_ms = nil)
72
- Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message)
104
+ Result.new(ok: false, host: @host, models: [], latency_ms: latency_ms, error: message, cloud: cloud?, rates: {})
73
105
  end
74
106
  end
75
107
  end
@@ -213,6 +213,8 @@ module ActionAgent
213
213
  prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
214
214
  tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
215
215
  )
216
+ # Until add_catalog_scenario_metadata runs, imports keep working without extensions.
217
+ record.metadata = scenario.metadata if record.has_attribute?(:metadata)
216
218
  record.position = position_for(record, scenario.position, offset)
217
219
  record.save!
218
220
  end