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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ module Api
# Connect from an MCP client with:
# { "type": "http", "url": "https://activeagents.ai/mcp",
# "headers": { "Authorization": "Bearer aa_..." } }
class McpController < BaseController
class MCPController < BaseController
# Authenticated by API key rather than by the host app's sessions.
allow_unauthenticated_access
before_action :authenticate_api_key!
Expand Down Expand Up @@ -51,7 +51,7 @@ def create
rescue McpError => e
render_error(request_id, e.code, e.message)
rescue StandardError => e
Rails.logger.error("[Api::McpController] #{e.class}: #{e.message}")
Rails.logger.error("[Api::MCPController] #{e.class}: #{e.message}")
render_error(request_id, JSONRPC_SERVER_ERROR, "Internal error")
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ module Api
#
# 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
# 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
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
Expand All @@ -33,7 +33,7 @@ def index

render json: {
servers: servers,
catalog: McpCatalog.all,
catalog: MCPCatalog.all,
summary: summary_for(servers),
sandboxes: active_sandboxes,
window_hours: finder.window_hours,
Expand Down Expand Up @@ -111,7 +111,7 @@ def assign_owner(sandbox, association, record)
end

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

Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/models/action_agent/sandbox_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class SandboxSession < ApplicationRecord
# 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) }
Array(mcp_servers).filter_map { |key| MCPCatalog.find(key) }
end

# Check if session is still valid
Expand Down
8 changes: 4 additions & 4 deletions actionagent/app/services/action_agent/agent_toolbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class AgentToolbox
}
}
],
# A real browser via a Playwright MCP server (PlaywrightMcpClient).
# A real browser via a Playwright MCP server (PlaywrightMCPClient).
# Stateful: navigate changes what snapshot/click see, so these bypass
# the toolbox result cache.
"playwright_mcp" => [
Expand Down Expand Up @@ -288,17 +288,17 @@ def browser_click(ref:, element: nil)
SNAPSHOT_LINK = /\[Snapshot\]\(([^)]+)\)/

def playwright_mcp(tool, arguments, retried: false)
result = PlaywrightMcpClient.instance.call_tool(tool, arguments)
result = PlaywrightMCPClient.instance.call_tool(tool, arguments)
text = inline_snapshot(result[:text].to_s)
if text.length > PLAYWRIGHT_RESULT_LIMIT
text = "#{text[0, PLAYWRIGHT_RESULT_LIMIT]}\n…(truncated, #{text.length} chars total)"
end
result[:is_error] ? { error: text.presence || "browser tool failed" } : { text: text }
rescue PlaywrightMcpClient::Error => e
rescue PlaywrightMCPClient::Error => e
# One fresh-session retry: the first call after a server (re)start can
# race the browser launch.
unless retried
PlaywrightMcpClient.reset!
PlaywrightMCPClient.reset!
return playwright_mcp(tool, arguments, retried: true)
end
{ error: e.message }
Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/services/action_agent/mcp_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ module ActionAgent
# shows up in the MCP Services view (as +known: false+) the moment a
# namespaced tool call from it is ingested. The catalog only adds names,
# descriptions, and the ability to launch.
class McpCatalog
class MCPCatalog
# Whether a server can be started inside a sandbox session. Servers that
# need workspace-specific credentials (github, slack, postgres) are
# listable and attributable but not launchable from the dashboard — there
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ module ActionAgent
# to a SessionRecording for playback and debugging.
#
# Usage:
# middleware = McpRecordingMiddleware.new(session_recording: recording)
# middleware = MCPRecordingMiddleware.new(session_recording: recording)
#
# # Process a tool call
# result = middleware.intercept(tool_call) do
# # Execute the actual MCP tool
# mcp_client.call_tool(tool_call)
# end
#
class McpRecordingMiddleware
class MCPRecordingMiddleware
# Map of Playwright MCP tool names to our action types
PLAYWRIGHT_TOOLS = {
"browser_navigate" => "navigate",
Expand Down
12 changes: 6 additions & 6 deletions actionagent/app/services/action_agent/playwright_mcp_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module ActionAgent
# app in development, or a sandbox-provisioned browser container in
# production. Speaks just enough JSON-RPC for tools/call: initialize once
# per process, then call tools under the session id the server hands back.
class PlaywrightMcpClient
class PlaywrightMCPClient
DEFAULT_URL = ENV.fetch("PLAYWRIGHT_MCP_URL", "http://host.orb.internal:8931/mcp")
OPEN_TIMEOUT_SECONDS = 5
READ_TIMEOUT_SECONDS = 60
Expand All @@ -31,7 +31,7 @@ def initialize(url: DEFAULT_URL)

# Returns { text:, is_error: } — the tool result's text content.
def call_tool(name, arguments = {})
Rails.logger.debug("[PlaywrightMcpClient] call #{name} args=#{arguments.inspect[0, 200]}")
Rails.logger.debug("[PlaywrightMCPClient] call #{name} args=#{arguments.inspect[0, 200]}")
ensure_session!
response = post(
{ jsonrpc: "2.0", id: next_id, method: "tools/call",
Expand All @@ -40,7 +40,7 @@ def call_tool(name, arguments = {})
)
result = response["result"]
unless result
Rails.logger.warn("[PlaywrightMcpClient] #{name} unexpected response: #{response.inspect[0, 500]}")
Rails.logger.warn("[PlaywrightMCPClient] #{name} unexpected response: #{response.inspect[0, 500]}")
raise Error, (response.dig("error", "message") || "empty MCP response")
end

Expand Down Expand Up @@ -100,17 +100,17 @@ def blocking_post_raw(payload, session: nil)

response = http.request(request)
Rails.logger.debug(
"[PlaywrightMcpClient] #{payload[:method]} -> #{response.code} " \
"[PlaywrightMCPClient] #{payload[:method]} -> #{response.code} " \
"ct=#{response['Content-Type']} bytes=#{response.body.to_s.bytesize} session=#{session ? 'yes' : 'no'}"
)
unless response.code.to_i.between?(200, 299)
Rails.logger.warn("[PlaywrightMcpClient] HTTP #{response.code}: #{response.body.to_s[0, 300]}")
Rails.logger.warn("[PlaywrightMCPClient] HTTP #{response.code}: #{response.body.to_s[0, 300]}")
raise Error, "MCP server returned HTTP #{response.code}"
end

parsed = parse_body(response)
if parsed.empty? && payload[:id]
Rails.logger.warn("[PlaywrightMcpClient] unparsed body (#{response['Content-Type']}): #{response.body.to_s[0, 500]}")
Rails.logger.warn("[PlaywrightMCPClient] unparsed body (#{response['Content-Type']}): #{response.body.to_s[0, 500]}")
end
[ parsed, response ]
end
Expand Down
10 changes: 5 additions & 5 deletions actionagent/app/services/action_agent/tool_discovery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module ActionAgent
#
# MCP attribution comes from ActiveAgent::Telemetry::ToolOrigin (the
# +mcp__server__tool+ convention, tagged onto spans at instrumentation
# time), then McpCatalog's hints for bare tool names, then the tool is
# time), then MCPCatalog's hints for bare tool names, then the tool is
# treated as a method the agent class defines.
#
# Scopes are passed in rather than derived, so the caller's ownership
Expand Down Expand Up @@ -404,7 +404,7 @@ def classify(name)
classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
return { origin: ORIGIN_MCP, server: classification[:server] } if classification[:server].present?

if (hinted = McpCatalog.server_for_tool(name))
if (hinted = MCPCatalog.server_for_tool(name))
# A catalog hint is weaker evidence than a namespaced name: the tool
# is *probably* this server's, but a builtin of the same name is the
# dashboard's own implementation, so builtins win the tie.
Expand Down Expand Up @@ -474,7 +474,7 @@ def base_name(name)

def source_label(origin, server)
case origin
when ORIGIN_MCP then server.present? ? "MCP · #{McpCatalog.display_name(server)}" : "MCP"
when ORIGIN_MCP then server.present? ? "MCP · #{MCPCatalog.display_name(server)}" : "MCP"
when ORIGIN_BUILTIN then "Dashboard toolbox"
else "Agent-defined"
end
Expand All @@ -500,7 +500,7 @@ def servers_for(tools)
end
end

keys = (McpCatalog::BY_KEY.keys + detected.keys + configured_servers.keys).uniq
keys = (MCPCatalog::BY_KEY.keys + detected.keys + configured_servers.keys).uniq

# detected has a default block that would materialize a bucket on
# lookup, so unseen servers are passed through as an explicit nil.
Expand All @@ -509,7 +509,7 @@ def servers_for(tools)
end

def server_row(key, bucket)
catalog = McpCatalog.find(key)
catalog = MCPCatalog.find(key)
configured = configured_servers[key].to_a.sort
calls = bucket ? bucket[:calls] : 0

Expand Down
2 changes: 1 addition & 1 deletion actionagent/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
resources :tools, only: [ :index ]

# MCP services — detected servers unioned with the default catalog
# (McpCatalog), plus on-demand sandbox provisioning. Keys are catalog
# (MCPCatalog), plus on-demand sandbox provisioning. Keys are catalog
# slugs like "sequential-thinking", so the id segment allows dashes.
resources :mcp_servers, only: [ :index, :show ], id: /[^\/]+/ do
member do
Expand Down
57 changes: 39 additions & 18 deletions actionagent/lib/action_agent/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,25 @@ class Engine < ::Rails::Engine
engine_name "action_agent"

# Basenames this engine spells differently from Zeitwerk's default
# camelization, consulted by the inflections initializer below. Empty
# today: every file here is named for the constant default camelization
# produces. An engine file that wants a genuine acronym in its constant
# adds its basename here rather than relying on the host to register one.
INFLECTION_OVERRIDES = {}.freeze
# camelization, consulted by the inflections initializer below. An engine
# file that wants a genuine acronym in its constant adds its basename here
# rather than relying on the host to register one.
INFLECTION_OVERRIDES = {
"mcp_catalog" => "MCPCatalog",
"mcp_controller" => "MCPController",
"mcp_recording_middleware" => "MCPRecordingMiddleware",
"mcp_servers_controller" => "MCPServersController",
"playwright_mcp_client" => "PlaywrightMCPClient"
}.freeze

# The spelling default camelization produces for each overridden file,
# mapped to the constant the file actually defines. The const_missing shim
# below uses it to answer lookups that camelized with default inflections
# (a plain host's router, or caller code written against the pre-acronym
# names) with the acronym constant.
DEFAULT_SPELLINGS = INFLECTION_OVERRIDES.to_h { |basename, constant|
[ Zeitwerk::Inflector.new.camelize(basename, nil), constant ]
}.freeze

config.action_agent = ActiveSupport::OrderedOptions.new

Expand All @@ -34,14 +48,13 @@ class Engine < ::Rails::Engine
end

# This engine's constants are spelled the way Zeitwerk's own inflector
# spells them — Api, McpCatalog, ApiKey — but an engine's files are
# spells them — Api, ApiKey — but an engine's files are
# autoloaded by the host's `rails.main` loader, under the *host's*
# inflections. A host that declares `inflect.acronym "API"` or "MCP" (both
# common, and documented by Rails) makes Zeitwerk expect
# ActionAgent::API::TracesController or ActionAgent::MCPCatalog from files
# that define ActionAgent::Api::TracesController and
# ActionAgent::McpCatalog. The constant never resolves and the request
# raises Zeitwerk::NameError.
# ActionAgent::API::TracesController from a file
# that defines ActionAgent::Api::TracesController. The constant never
# resolves and the request raises Zeitwerk::NameError.
#
# Every path under this engine therefore camelizes with Zeitwerk's default
# rules, ignoring whatever acronyms the host has registered. Applied by
Expand All @@ -51,7 +64,8 @@ class Engine < ::Rails::Engine
# engine's files from the host's.
#
# Basenames whose spelling this engine cannot express through default
# camelization (a genuine acronym it wants uppercased) go in OVERRIDES.
# camelization (a genuine acronym it wants uppercased) go in
# INFLECTION_OVERRIDES.
initializer "action_agent.inflections", before: :set_autoload_paths do
engine_root = File.join(root.to_s, "")
default = Zeitwerk::Inflector.new
Expand All @@ -74,15 +88,22 @@ class Engine < ::Rails::Engine
# So the namespace answers to both. `const_missing` rather than an eager
# alias because the controllers are autoloaded on demand, and naming them at
# boot would load the whole dashboard.
# The router does not consult the autoloader's inflector, so an acronym
# host asks for ActionAgent::API::MCPServersController while the constants
# are Api::McpServersController. Rather than enumerate the pairs, an
# all-caps run in a missing constant is retried in the spelling default
# camelization produces: API -> Api, MCPServersController -> McpServers-
# Controller. Only the engine's own namespaces are touched, and only for a
# constant that is already missing.
# The router does not consult the autoloader's inflector, so its lookups
# miss in both directions. An acronym host asks for
# ActionAgent::API::TracesController while the constant is
# Api::TracesController: an all-caps run in a missing constant is retried
# in the spelling default camelization produces (API -> Api). A plain host
# asks for Api::McpServersController while the constant is
# MCPServersController (the file is in INFLECTION_OVERRIDES): a missing
# constant matching an override's default spelling is retried as the
# acronym constant, via DEFAULT_SPELLINGS — which also keeps caller code
# written against the pre-acronym names resolving. Only the engine's own
# namespaces are touched, and only for a constant that is already missing.
inflection_shim = Module.new do
def const_missing(name)
acronym = ActionAgent::Engine::DEFAULT_SPELLINGS[name.to_s]
return const_get(acronym, false) if acronym && const_defined?(acronym, false)

relaxed = name.to_s.gsub(/([A-Z])([A-Z]+)(?=[A-Z][a-z]|\d|\z)/) { "#{$1}#{$2.downcase}" }

return super if relaxed == name.to_s || !const_defined?(relaxed, false)
Expand Down
10 changes: 10 additions & 0 deletions actionagent/test/engine_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ def setup
assert ActionAgent::Api::TracesController < ActionController::API
end

test "acronym constants answer to their default spellings" do
# A plain-inflection host's router camelizes the engine's mcp_* files to
# Mcp* names; the files define MCP* constants (INFLECTION_OVERRIDES), and
# the const_missing shim bridges the lookup.
assert_equal ActionAgent::MCPCatalog, ActionAgent::McpCatalog
assert_equal ActionAgent::PlaywrightMCPClient, ActionAgent::PlaywrightMcpClient
assert_equal ActionAgent::Api::MCPServersController, ActionAgent::Api::McpServersController
assert_equal ActionAgent::Api::MCPController, ActionAgent::Api::McpController
end

test "every engine route maps to a shipped controller action" do
ActionAgent::Engine.routes.routes.each do |route|
controller = route.defaults[:controller]
Expand Down
12 changes: 6 additions & 6 deletions actionagent/test/tool_discovery_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def tool_named(name, body = tools_response)

# The MCP Services API: detected servers unioned with the default catalog,
# and starting a catalog server inside a sandbox.
class McpServersApiTest < ActionDispatch::IntegrationTest
class MCPServersApiTest < ActionDispatch::IntegrationTest
def setup
ActionAgent::Agent.delete_all
ActionAgent::TelemetryTrace.delete_all
Expand Down Expand Up @@ -415,11 +415,11 @@ def server_named(key, body = servers_response)
end

test "a catalog server resolves to its entry and its launchability" do
assert ActionAgent::McpCatalog.launchable?("playwright")
assert_not ActionAgent::McpCatalog.launchable?("github")
assert_nil ActionAgent::McpCatalog.find("nope")
assert_equal "Playwright", ActionAgent::McpCatalog.display_name("playwright")
assert ActionAgent::MCPCatalog.launchable?("playwright")
assert_not ActionAgent::MCPCatalog.launchable?("github")
assert_nil ActionAgent::MCPCatalog.find("nope")
assert_equal "Playwright", ActionAgent::MCPCatalog.display_name("playwright")
# An unknown key displays as itself rather than blank.
assert_equal "acme", ActionAgent::McpCatalog.display_name("acme")
assert_equal "acme", ActionAgent::MCPCatalog.display_name("acme")
end
end
2 changes: 1 addition & 1 deletion docs/framework/v2-extraction-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ JSON-RPC, served by `actionagent`.
On the client side MCP is still pass-through only: `mcps:` options are
normalized into each vendor's *remote* MCP format (the LLM vendor's servers
do the connecting), and the only client in either gem
(`ActionAgent::PlaywrightMcpClient`, also in `actionagent`) speaks just
(`ActionAgent::PlaywrightMCPClient`, also in `actionagent`) speaks just
enough JSON-RPC for one server. Remaining for v2: a general MCP client in
`activeagent` (stdio/HTTP, `tools/list` discovery → routable actions) that
turns any MCP server's tools into agent actions.
Expand Down