diff --git a/actionagent/app/controllers/action_agent/api/mcp_controller.rb b/actionagent/app/controllers/action_agent/api/mcp_controller.rb index bf6fe447..f5a56b9c 100644 --- a/actionagent/app/controllers/action_agent/api/mcp_controller.rb +++ b/actionagent/app/controllers/action_agent/api/mcp_controller.rb @@ -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! @@ -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 diff --git a/actionagent/app/controllers/action_agent/api/mcp_servers_controller.rb b/actionagent/app/controllers/action_agent/api/mcp_servers_controller.rb index a2d64a46..d47cda91 100644 --- a/actionagent/app/controllers/action_agent/api/mcp_servers_controller.rb +++ b/actionagent/app/controllers/action_agent/api/mcp_servers_controller.rb @@ -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 @@ -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, @@ -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 diff --git a/actionagent/app/models/action_agent/sandbox_session.rb b/actionagent/app/models/action_agent/sandbox_session.rb index 3f706439..59f8e3e7 100644 --- a/actionagent/app/models/action_agent/sandbox_session.rb +++ b/actionagent/app/models/action_agent/sandbox_session.rb @@ -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 diff --git a/actionagent/app/services/action_agent/agent_toolbox.rb b/actionagent/app/services/action_agent/agent_toolbox.rb index 38e073e3..aa0fdb4d 100644 --- a/actionagent/app/services/action_agent/agent_toolbox.rb +++ b/actionagent/app/services/action_agent/agent_toolbox.rb @@ -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" => [ @@ -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 } diff --git a/actionagent/app/services/action_agent/mcp_catalog.rb b/actionagent/app/services/action_agent/mcp_catalog.rb index 0f1a8c7e..0efbc1e0 100644 --- a/actionagent/app/services/action_agent/mcp_catalog.rb +++ b/actionagent/app/services/action_agent/mcp_catalog.rb @@ -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 diff --git a/actionagent/app/services/action_agent/mcp_recording_middleware.rb b/actionagent/app/services/action_agent/mcp_recording_middleware.rb index 553a4b0f..536dfba6 100644 --- a/actionagent/app/services/action_agent/mcp_recording_middleware.rb +++ b/actionagent/app/services/action_agent/mcp_recording_middleware.rb @@ -7,7 +7,7 @@ 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 @@ -15,7 +15,7 @@ module ActionAgent # 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", diff --git a/actionagent/app/services/action_agent/playwright_mcp_client.rb b/actionagent/app/services/action_agent/playwright_mcp_client.rb index d35af643..d33fc530 100644 --- a/actionagent/app/services/action_agent/playwright_mcp_client.rb +++ b/actionagent/app/services/action_agent/playwright_mcp_client.rb @@ -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 @@ -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", @@ -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 @@ -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 diff --git a/actionagent/app/services/action_agent/tool_discovery.rb b/actionagent/app/services/action_agent/tool_discovery.rb index 924bd634..e62e8fc3 100644 --- a/actionagent/app/services/action_agent/tool_discovery.rb +++ b/actionagent/app/services/action_agent/tool_discovery.rb @@ -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 @@ -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. @@ -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 @@ -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. @@ -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 diff --git a/actionagent/config/routes.rb b/actionagent/config/routes.rb index bb720bc2..4c42e059 100644 --- a/actionagent/config/routes.rb +++ b/actionagent/config/routes.rb @@ -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 diff --git a/actionagent/lib/action_agent/engine.rb b/actionagent/lib/action_agent/engine.rb index 2857c068..47f0c4b2 100644 --- a/actionagent/lib/action_agent/engine.rb +++ b/actionagent/lib/action_agent/engine.rb @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/actionagent/test/engine_integration_test.rb b/actionagent/test/engine_integration_test.rb index 2dc54821..054053ce 100644 --- a/actionagent/test/engine_integration_test.rb +++ b/actionagent/test/engine_integration_test.rb @@ -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] diff --git a/actionagent/test/tool_discovery_test.rb b/actionagent/test/tool_discovery_test.rb index 147690fb..3ee41771 100644 --- a/actionagent/test/tool_discovery_test.rb +++ b/actionagent/test/tool_discovery_test.rb @@ -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 @@ -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 diff --git a/docs/framework/v2-extraction-roadmap.md b/docs/framework/v2-extraction-roadmap.md index 15d0cc54..e21c7f6e 100644 --- a/docs/framework/v2-extraction-roadmap.md +++ b/docs/framework/v2-extraction-roadmap.md @@ -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.