Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 43 additions & 43 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions actionagent/app/controllers/action_agent/api/mcp_servers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# frozen_string_literal: true

module ActionAgent
module Api
# Read API for MCP services, plus sandbox provisioning for the servers
# that can be started on demand. Backs the dashboard MCP Services view.
#
# The list is the union of three things: servers detected from telemetry
# and solid_agent records (ToolDiscovery), servers an agent declares in
# its configuration, and the default catalog (McpCatalog). An install
# therefore sees both what it is already using and what it could turn on.
class McpServersController < BaseController
before_action :require_owner!
# Launching provisions a sandbox and runs a server in it, so it answers
# to the same two gates as any other execution: the read-only kill
# switch, and whatever limits the host app imposes.
before_action :require_execution_enabled!, only: [ :launch ]
before_action :enforce_execution_quota!, only: [ :launch ]
before_action :set_catalog_entry, only: [ :show, :launch ]

STATUS_LABELS = {
"active" => "Called in this window",
"configured" => "Declared by an agent, no traffic yet",
"available" => "Available to connect",
"idle" => "Seen previously, no traffic in this window"
}.freeze

# GET /api/mcp_servers
def index
finder = discovery
tools = finder.detected_tools
servers = finder.servers(tools)

render json: {
servers: servers,
catalog: McpCatalog.all,
summary: summary_for(servers),
sandboxes: active_sandboxes,
window_hours: finder.window_hours,
statuses: STATUS_LABELS
}
end

# GET /api/mcp_servers/:id
#
# One server with the tools detected for it, so the view can expand a
# row without refetching the whole inventory.
def show
finder = discovery
tools = finder.detected_tools
server = finder.servers(tools).find { |row| row[:key] == params[:id] }

render json: {
server: server || @catalog_entry,
tools: tools.select { |tool| tool[:mcp_server] == params[:id] }
}
end

# POST /api/mcp_servers/:id/launch
#
# Starts the server inside a sandbox session. Only catalog entries
# marked +sandbox: true+ are launchable — the rest need credentials
# the dashboard has nowhere safe to source, so they are listed but not
# startable.
def launch
unless @catalog_entry[:sandbox]
return render json: {
error: "#{@catalog_entry[:name]} can't be started from the dashboard",
reason: launch_blocked_reason(@catalog_entry)
}, status: :unprocessable_entity
end

sandbox = SandboxSession.new(
sandbox_type: @catalog_entry[:sandbox_type] || "terminal",
mcp_servers: [ @catalog_entry[:key] ]
)
# A sandbox belongs to whoever opened it, which is how
# SandboxesController assigns them too — not to whatever
# `current_owner` resolves to, since that is the account in a
# multi-tenant install and this model prefers :user. Both are set
# when the host app has them; a single-user install declares neither
# association, so both are skipped.
# respond_to? alone isn't enough: the association is declared from
# configuration, but a host app's pre-existing table may not carry
# the column behind it.
assign_owner(sandbox, :user, current_user)
assign_owner(sandbox, :account, current_account)

if sandbox.save
sandbox.provision!
sandbox.reload
record_execution_usage
render json: { sandbox: sandbox.summary, server: @catalog_entry }, status: :created
else
render json: { error: sandbox.errors.full_messages }, status: :unprocessable_entity
end
end

private

def discovery
ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
end

def assign_owner(sandbox, association, record)
return if record.nil?
return unless sandbox.respond_to?(:"#{association}=")
return unless sandbox.has_attribute?(:"#{association}_id")

sandbox.public_send(:"#{association}=", record)
end

def set_catalog_entry
@catalog_entry = McpCatalog.find(params[:id])
render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found if @catalog_entry.nil?
end

def launch_blocked_reason(entry)
if entry[:requires_credentials].any?
"Needs #{entry[:requires_credentials].to_sentence}. Configure it on an agent instead."
else
"This server runs outside the sandbox environment."
end
end

def summary_for(servers)
{
total: servers.size,
active: servers.count { |server| server[:status] == "active" },
configured: servers.count { |server| server[:status] == "configured" },
available: servers.count { |server| server[:status] == "available" },
launchable: servers.count { |server| server[:launchable] },
# Servers seen in traffic that the catalog doesn't describe — worth
# surfacing, since they're the ones nobody documented.
unknown: servers.count { |server| !server[:known] },
total_calls: servers.sum { |server| server[:calls] }
}
end

# Running sandboxes started with an MCP server, so the view can show
# "running" beside the launch button instead of starting a second copy.
def active_sandboxes
owned(SandboxSession)
.active
.where(SandboxSession.json_array_not_empty_sql(:mcp_servers))
.recent
.limit(20)
.map { |sandbox| sandbox.summary.merge(mcp_servers: Array(sandbox.mcp_servers)) }
end

def window_hours
params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
end
end
end
end
58 changes: 58 additions & 0 deletions actionagent/app/controllers/action_agent/api/tools_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

module ActionAgent
module Api
# Read API for the tool inventory, backing the dashboard Tools view.
#
# Nothing here is registered by hand: ToolDiscovery derives the
# inventory from the tool roster each generation request offered, from
# telemetry tool spans, and from solid_agent generation/message records,
# then unions it with the tools each agent has enabled in the builder so
# configured-but-unused tools are visible too.
class ToolsController < BaseController
before_action :require_owner!

# Filter labels for the view's origin tabs, so the set of buckets is
# defined server-side alongside the classification that produces them.
ORIGIN_FILTERS = {
"all" => "All tools",
ToolDiscovery::ORIGIN_MCP => "MCP",
ToolDiscovery::ORIGIN_BUILTIN => "Dashboard",
ToolDiscovery::ORIGIN_AGENT => "Agent-defined"
}.freeze

# GET /api/tools
def index
inventory = discovery.inventory

render json: inventory.merge(
tools: filtered(inventory[:tools]),
origins: ORIGIN_FILTERS
)
end

private

def discovery
ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
end

def filtered(tools)
if ORIGIN_FILTERS.key?(params[:origin].to_s) && params[:origin] != "all"
tools = tools.select { |tool| tool[:origin] == params[:origin] }
end
tools = tools.select { |tool| tool[:mcp_server] == params[:server] } if params[:server].present?

if (query = params[:q].to_s.strip.downcase).present?
tools = tools.select { |tool| tool[:name].downcase.include?(query) }
end

tools
end

def window_hours
params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
end
end
end
end
2 changes: 1 addition & 1 deletion actionagent/app/models/action_agent/agent_generation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class AgentGeneration < ApplicationRecord

scope :recent, -> { order(created_at: :desc) }
scope :by_model, ->(model) { where(model: model) }
scope :with_tool_calls, -> { where.not(tool_calls: []) }
scope :with_tool_calls, -> { where(json_array_not_empty_sql(:tool_calls)) }
scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }

def total_tokens
Expand Down
10 changes: 9 additions & 1 deletion actionagent/app/models/action_agent/sandbox_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ class SandboxSession < ApplicationRecord
scope :anonymous, -> { where(user_id: nil) }
scope :recent, -> { order(created_at: :desc) }

# Catalog entries for the MCP servers this session was started with.
# Unknown keys are dropped rather than raising — a session outlives a
# catalog edit.
def mcp_catalog_entries
Array(mcp_servers).filter_map { |key| McpCatalog.find(key) }
end

# Check if session is still valid
def active?
!expired? && !failed? && !completed? && expires_at > Time.current
Expand Down Expand Up @@ -124,7 +131,8 @@ def summary
total_tokens: total_tokens,
expires_at: expires_at&.iso8601,
created_at: created_at.iso8601,
cloud_run_url: cloud_run_url
cloud_run_url: cloud_run_url,
mcp_servers: Array(mcp_servers)
}
end

Expand Down
112 changes: 112 additions & 0 deletions actionagent/app/models/action_agent/telemetry_trace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,75 @@ def tool_spans
spans&.select { |s| s["type"] == "tool" } || []
end

# Returns each tool call in this trace, normalized for display.
#
# Tool spans are tagged with their origin at instrumentation time
# (ActiveAgent::Telemetry::ToolOrigin), but traces ingested before that
# shipped — or sent by another SDK — only carry +tool.name+. Those are
# classified on read from the same naming convention, so a dashboard
# sees consistent attribution across old and new traces.
#
# @return [Array<Hash>] one entry per tool span with :name, :base_name,
# :origin, :mcp_server, :duration_ms, :status, :error, :arguments and
# :result
def tool_usage
tool_spans.map do |span|
attributes = span["attributes"] || {}
name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
classification = classify_tool(name, attributes)

{
name: name,
base_name: attributes["tool.base_name"] || classification[:tool],
origin: attributes["tool.origin"] || classification[:origin],
mcp_server: attributes["tool.mcp_server"] || classification[:server],
duration_ms: span["duration_ms"],
status: span["status"],
error: attributes["error.message"],
arguments: attributes["tool.input.args"],
result: attributes["tool.output.result"]
}
end
end

# Returns the tools this trace's generation request OFFERED the
# provider, whether or not the model went on to call any of them.
#
# Instrumentation records the roster on the prompt span as
# +prompt.input.tools+ (name, description, parameter keys), which is
# the agent's declared tool surface for that generation. Reading it
# here is what lets a dashboard show a tool that exists but has never
# been invoked — a state that tool spans alone can't express.
#
# @return [Array<Hash>] entries with :name, :description, :parameters,
# :origin and :mcp_server
def declared_tools
Array(tool_roster).filter_map do |tool|
next unless tool.is_a?(Hash)

name = (tool["name"] || tool[:name]).to_s
next if name.empty?

classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
{
name: name,
description: (tool["description"] || tool[:description]).presence,
parameters: normalize_parameters(tool["parameters"] || tool[:parameters]),
origin: classification[:origin],
mcp_server: classification[:server]
}
end
end

# Returns the distinct MCP servers this trace touched — both the ones
# it called and the ones it was merely offered.
#
# @return [Array<String>] server names, in first-seen order
def mcp_servers
(tool_usage.filter_map { |tool| tool[:mcp_server] } +
declared_tools.filter_map { |tool| tool[:mcp_server] }).uniq
end

# Returns total token count.
#
# @return [Integer] Total tokens used
Expand Down Expand Up @@ -241,5 +310,48 @@ def model

llm_span.dig("attributes", "llm.model")
end

private

# The offered roster, as stored. Instrumentation writes JSON onto the
# prompt span; +llm.tools+ is accepted as an alias because some SDK
# versions put the roster on the llm span instead, and a payload that
# arrived already decoded is passed straight through.
def tool_roster
raw = spans.to_a.filter_map do |span|
attributes = span["attributes"] || {}
attributes["prompt.input.tools"].presence || attributes["llm.tools"].presence
end.first
return nil if raw.blank?
return raw unless raw.is_a?(String)

JSON.parse(raw)
rescue JSON::ParserError
nil
end

# The roster records parameters as a name list, but a raw JSON Schema
# arrives instead when an SDK forwards tool definitions verbatim.
def normalize_parameters(parameters)
return [] if parameters.blank?
return parameters.map(&:to_s) if parameters.is_a?(Array)

if parameters.is_a?(Hash)
properties = parameters["properties"] || parameters[:properties]
return properties.keys.map(&:to_s) if properties.is_a?(Hash)
end

[]
end

# Recovers a tool's origin for traces that predate origin tagging.
# Prefers an explicit server attribute when the SDK sent one, then
# falls back to the shared name-convention classifier.
def classify_tool(name, attributes)
explicit = attributes["mcp.server"] || attributes["tool.server"]
return { origin: "mcp", server: explicit, tool: name } if explicit.present?

ActiveAgent::Telemetry::ToolOrigin.classify(name)
end
end
end
14 changes: 14 additions & 0 deletions actionagent/app/models/concerns/action_agent/adapter_aware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ def hour_bucket_sql(column = :timestamp)
end
end

# SQL matching rows whose JSON array +column+ actually has entries.
#
# `where.not(column: [])` cannot express this and is not a slower way
# of doing it — Active Record reads the empty array as an empty IN
# list, so the condition compiles to `1=1` and matches every row.
# +column+ is always a literal from this codebase, never user input.
def json_array_not_empty_sql(column)
case connection.adapter_name.to_s.downcase
when /postgres/ then "COALESCE(jsonb_array_length(#{column}::jsonb), 0) > 0"
when /mysql/ then "COALESCE(JSON_LENGTH(#{column}), 0) > 0"
else "COALESCE(json_array_length(#{column}), 0) > 0"
end
end

# PostgreSQL hands back a Time; the others a UTC string.
def hour_bucket_epoch(value)
return value.to_i if value.is_a?(Time) || value.is_a?(DateTime)
Expand Down
Loading