From 3914cff53b19be79928d04249b8a0c216b4e77b4 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:19:27 -0700 Subject: [PATCH 1/2] feat(mcp): serve the host's schema tools through the MCP facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/list at POST /mcp offers every tool the dashboard's discovered ActiveAgent::SchemaTools classes generate — find_, count_, get_ — beside the run_ agents, each with its own parameter schema, and tools/call runs one as the key's caller through the host's own scope, exactly as it would inside an agent run. A boundary violation is a tool result with isError; a refusal from the host's scope is a JSON-RPC -32003; neither the execution switch nor the execution quota applies, because nothing generates. ActionAgent.mcp_schema_tools = false keeps the tools reachable only through agents. ActiveAgent::NotAuthorized becomes autoloadable: it was defined only when Base loaded, and the engine referencing it first raised NameError. Also records this batch of work under docs/work/schema-tools-follow-ups. Closes #439. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XMSRnSxYS9mRx1hSjytB9Z --- CHANGELOG.md | 12 ++ .../action_agent/api/mcp_controller.rb | 75 +++++++++- actionagent/lib/action_agent.rb | 17 +++ actionagent/test/mcp_schema_tools_test.rb | 139 ++++++++++++++++++ docs/framework/dashboard.md | 31 ++++ docs/work/schema-tools-follow-ups/branch.md | 31 ++++ docs/work/schema-tools-follow-ups/issues.md | 28 ++++ .../schema-tools-follow-ups/milestones.md | 13 ++ .../schema-tools-follow-ups/pull-request.md | 21 +++ lib/active_agent.rb | 3 + 10 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 actionagent/test/mcp_schema_tools_test.rb create mode 100644 docs/work/schema-tools-follow-ups/branch.md create mode 100644 docs/work/schema-tools-follow-ups/issues.md create mode 100644 docs/work/schema-tools-follow-ups/milestones.md create mode 100644 docs/work/schema-tools-follow-ups/pull-request.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d672532f..8c2da90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The MCP facade serves the host's schema tools directly.** `tools/list` + at `POST /mcp` now offers every tool the dashboard's discovered + `ActiveAgent::SchemaTools` classes generate — `find_`, + `count_`, `get_` — beside the `run_` agents, each + with its own parameter schema, and `tools/call` runs one as the key's + caller through the host's own scope, exactly as it would inside an agent + run. A client that only needs the rows no longer has to ask an agent for + them. A boundary violation is a tool result with `isError`, a refusal from + the host's scope is a JSON-RPC `-32003`, and neither the execution switch + nor the execution quota applies, because nothing generates. Set + `ActionAgent.mcp_schema_tools = false` to keep the tools reachable only + through agents. Closes #439. - **An agent knows who it is running for, so an authorization gem has something to decide against.** `ActiveAgent::Base#current_user` carries the caller, assigned by whatever authenticated the call diff --git a/actionagent/app/controllers/action_agent/api/mcp_controller.rb b/actionagent/app/controllers/action_agent/api/mcp_controller.rb index 7b6dc09e..c768792e 100644 --- a/actionagent/app/controllers/action_agent/api/mcp_controller.rb +++ b/actionagent/app/controllers/action_agent/api/mcp_controller.rb @@ -7,7 +7,11 @@ module Api # themselves as Resource Agents backed by their ActiveRecord state: # # - tools/list & tools/call: each agent is a callable tool (run_) - # that executes a synchronous generation run. + # that executes a synchronous generation run, and each of the host's + # schema tools (ActiveAgent::SchemaTools, the classes the dashboard + # discovers) is callable directly — find_, count_, + # get_ — as this key's caller, so a client reads the host's + # records under the same scope an agent run would (#439). # - resources/list & resources/read: each agent is an agent:// # resource whose content is its live scorecard (config + stats + memory # summary from the solid_agent datasets). @@ -143,7 +147,9 @@ def initialize_result protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {} }, serverInfo: { name: "activeagents", version: "1.0" }, - instructions: "Each tool runs one of this account's agents. Each agent:// resource returns the agent's live scorecard." + instructions: "Each run_ tool runs one of this account's agents; every other tool reads the host " \ + "application's records directly, as the caller this key authenticates. Each agent:// " \ + "resource returns the agent's live scorecard." } end @@ -174,11 +180,32 @@ def tools_list agent_tools end - { tools: tools } + { tools: tools + schema_tools_list } + end + + # The host's schema tools, offered as the same tool definitions an + # agent run receives — the parameter schema is the tool's own, so a + # client sees which columns it may filter on. Every generated tool of + # every discovered class is listed; the host chose what to declare, and + # ActionAgent.mcp_schema_tools switches the whole set off. + def schema_tools_list + return [] unless ActionAgent.mcp_schema_tools? + + ActionAgent.schema_tool_classes.flat_map do |klass| + klass.tool_definitions.map do |definition| + { + name: definition[:name], + description: definition[:description], + inputSchema: definition[:parameters] || definition[:input_schema] || { type: "object", properties: {} } + } + end + end end def tools_call name = params.dig(:params, :name).to_s + return schema_tool_call(name) if ActionAgent.mcp_schema_tools? && ActionAgent.schema_tool_class_for(name) + slug, action = name.delete_prefix("run_").split("__", 2) agent = key_agents.find_by(slug: slug) raise McpError.new("Unknown tool: #{name}", JSONRPC_INVALID_PARAMS) unless agent @@ -221,6 +248,48 @@ def tools_call end end + # Calls a schema tool directly, as this key's caller. No generation runs, + # so neither the execution switch nor the execution quota applies: this + # is a read of the host's records through the host's own scope. + # + # A boundary violation — an undeclared filter, an id the caller cannot + # see — comes back as a tool result with isError, the shape an agent + # run would hand its model, so a client can correct its call. A refusal + # raised by the host's scope (an authorization gem's error, or + # ActiveAgent::NotAuthorized) answers as a JSON-RPC error, as an + # agent's refusal does. + def schema_tool_call(name) + klass = ActionAgent.schema_tool_class_for(name) + result = call_schema_tool(klass, name) + response = { content: [ { type: "text", text: result.to_json } ], structuredContent: result } + response[:isError] = true if result.respond_to?(:key?) && (result.key?(:error) || result.key?("error")) + response + end + + def call_schema_tool(klass, name) + klass.call(name, actor: agent_actor, **schema_tool_arguments) + rescue StandardError => e + raise McpError.new(e.message, JSONRPC_FORBIDDEN) if authorization_error?(e) + + raise + end + + # The framework's refusal (the default in Base.authorization_errors), or + # one of the errors a host named with `denies_with` — matched on the + # class, as run_refused? matches a run's. + def authorization_error?(error) + ActiveAgent::Base.authorization_errors.any? { |klass| error.is_a?(klass) } + end + + # The call's arguments as keywords, minus any that name the caller: + # the actor is the key's identity, never something a client sends + # (AgentExecutionService::ACTOR_KEYWORDS, for the same reason). + def schema_tool_arguments + arguments = params.dig(:params, :arguments) + arguments = arguments.respond_to?(:to_unsafe_h) ? arguments.to_unsafe_h : arguments.to_h + arguments.to_h.symbolize_keys.except(*AgentExecutionService::ACTOR_KEYWORDS) + end + def resources_list { resources: key_agents.map do |agent| diff --git a/actionagent/lib/action_agent.rb b/actionagent/lib/action_agent.rb index 5bd83105..34a638a3 100644 --- a/actionagent/lib/action_agent.rb +++ b/actionagent/lib/action_agent.rb @@ -368,6 +368,15 @@ def deprecator # @return [Array, nil] attr_accessor :schema_tools + # Whether the MCP facade (POST /mcp) offers the host's schema tools + # directly — find_, count_, get_ — beside the + # run_ agent tools. Each call runs as the key's caller, through the + # host's own scope, exactly as it would inside an agent run. On by + # default: the host declared the tools; set it to false to keep them + # reachable only through agents. + # @return [Boolean] + attr_accessor :mcp_schema_tools + # Directory scanned for SchemaTools subclasses when {#schema_tools} is # unset. Relative to the host's root. Set to nil to disable discovery and # require an explicit declaration. @@ -388,6 +397,13 @@ def multi_tenant? @multi_tenant == true end + # Whether the MCP facade serves the host's schema tools directly. + # + # @return [Boolean] + def mcp_schema_tools? + @mcp_schema_tools != false + end + # Returns whether agent execution is permitted. # # @return [Boolean] @@ -542,6 +558,7 @@ def reset! @agent_actor_resolver = nil @schema_tools = nil @schema_tools_path = "app/agent_tools" + @mcp_schema_tools = nil end # Host-declared schema tool classes, resolved from names and filtered to diff --git a/actionagent/test/mcp_schema_tools_test.rb b/actionagent/test/mcp_schema_tools_test.rb new file mode 100644 index 00000000..d077d030 --- /dev/null +++ b/actionagent/test/mcp_schema_tools_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "test_helper" + +# The host's schema tools served through the MCP facade (#439): a client +# calls find_ itself, as the key's caller, rather than asking an +# agent to. One roster, two transports. +class McpSchemaToolsTest < ActionDispatch::IntegrationTest + class RecordTools + def self.model = Struct.new(:name).new("Record") + def self.tool_names = %w[find_records get_record] + def self.tool?(name) = tool_names.include?(name.to_s) + + def self.tool_definitions + [ + { name: "find_records", description: "Find records", parameters: { type: "object", properties: { status: { type: "string" } }, required: [] } }, + { name: "get_record", description: "One record", parameters: { type: "object", properties: { id: { type: "integer" } }, required: [ "id" ] } } + ] + end + + def self.call(name, actor: nil, **arguments) + return { error: "`#{arguments.keys.first}` is not a filterable attribute" } if arguments.key?(:colour) + raise ActiveAgent::NotAuthorized.new(action: name) if actor == :forbidden + + { called: name, actor: actor.inspect, arguments: arguments, results: [] } + end + end + + def setup + ActionAgent::Agent.delete_all + ActionAgent::ApiKey.delete_all + @agent = ActionAgent::Agent.create!(name: "Records", slug: "records", provider: "mock", model: "mock", status: :active) + @key = ActionAgent::ApiKey.create!(name: "Test key") + @previous_tools = ActionAgent.schema_tools + ActionAgent.schema_tools = [ RecordTools ] + end + + def teardown + ActionAgent.schema_tools = @previous_tools + ActionAgent.mcp_schema_tools = nil + ActionAgent.agent_actor_resolver = nil + end + + def rpc(method, params = {}) + post "/activeagents/mcp", + params: { jsonrpc: "2.0", id: 1, method: method, params: params }.to_json, + headers: { "Content-Type" => "application/json", "Authorization" => "Bearer #{@key.token}" } + JSON.parse(response.body) + end + + test "tools/list offers each schema tool beside the agents, with its own parameter schema" do + tools = rpc("tools/list").dig("result", "tools") + names = tools.map { |tool| tool["name"] } + + assert_includes names, "run_records" + assert_includes names, "find_records" + find = tools.find { |tool| tool["name"] == "find_records" } + assert_equal "Find records", find["description"] + assert_equal "object", find.dig("inputSchema", "type") + assert_includes find.dig("inputSchema", "properties").keys, "status" + end + + test "tools/call runs a schema tool as the caller the host resolves, with the client's arguments" do + ActionAgent.agent_actor_resolver = ->(_controller) { :alice } + + body = rpc("tools/call", { name: "find_records", arguments: { status: "held" } }) + + assert_nil body["error"] + result = body.dig("result", "structuredContent") + assert_equal "find_records", result["called"] + assert_equal ":alice", result["actor"] + assert_equal({ "status" => "held" }, result["arguments"]) + assert_equal result.to_json, body.dig("result", "content", 0, "text") + assert_nil body.dig("result", "isError") + end + + test "a client cannot name the caller through the arguments" do + ActionAgent.agent_actor_resolver = ->(_controller) { :alice } + + result = rpc("tools/call", { name: "find_records", arguments: { actor: "root", current_user: 1, status: "held" } }).dig("result", "structuredContent") + + assert_equal ":alice", result["actor"] + assert_equal({ "status" => "held" }, result["arguments"]) + end + + test "with no resolver and no owner model the call is unattributed, never widened" do + result = rpc("tools/call", { name: "find_records", arguments: {} }).dig("result", "structuredContent") + + assert_equal "nil", result["actor"] + end + + test "a boundary violation is a tool result the client can correct, not a transport error" do + body = rpc("tools/call", { name: "find_records", arguments: { colour: "red" } }) + + assert_nil body["error"] + assert_equal true, body.dig("result", "isError") + assert_match(/not a filterable attribute/, body.dig("result", "content", 0, "text")) + end + + test "a refusal from the host's scope answers as a JSON-RPC forbidden error" do + ActionAgent.agent_actor_resolver = ->(_controller) { :forbidden } + + body = rpc("tools/call", { name: "find_records", arguments: {} }) + + assert_equal(-32003, body.dig("error", "code")) + assert_nil body["result"] + end + + test "an unknown tool is still unknown" do + body = rpc("tools/call", { name: "find_nothing", arguments: {} }) + + assert_equal(-32602, body.dig("error", "code")) + end + + test "the host can keep its schema tools behind agents" do + ActionAgent.mcp_schema_tools = false + + names = rpc("tools/list").dig("result", "tools").map { |tool| tool["name"] } + assert_not_includes names, "find_records" + assert_includes names, "run_records" + + assert_equal(-32602, rpc("tools/call", { name: "find_records", arguments: {} }).dig("error", "code")) + end + + test "a direct read needs no execution switch and spends no execution quota" do + ActionAgent.execution_enabled = false + recorded = [] + ActionAgent.usage_recorder = ->(owner, kind) { recorded << kind } + + body = rpc("tools/call", { name: "get_record", arguments: { id: 1 } }) + + assert_nil body["error"] + assert_equal "get_record", body.dig("result", "structuredContent", "called") + assert_empty recorded + ensure + ActionAgent.execution_enabled = nil + ActionAgent.usage_recorder = nil + end +end diff --git a/docs/framework/dashboard.md b/docs/framework/dashboard.md index 5ead61a0..74243490 100644 --- a/docs/framework/dashboard.md +++ b/docs/framework/dashboard.md @@ -456,6 +456,37 @@ read or replace the suite; and `GET /api/evaluations/:id/runs/:run_id/report` for the HTML report, with `?theme=dark` or `?theme=light` to pin its palette. +## The MCP facade + +The dashboard is itself an MCP server: `POST /mcp` speaks Streamable +HTTP JSON-RPC, authenticated with a dashboard API key (Settings → API Keys) +as a Bearer token. Connect a client with: + +```json +{ "type": "http", "url": "https://example.com/activeagents/mcp", + "headers": { "Authorization": "Bearer aa_..." } } +``` + +`tools/list` offers two kinds of tool: + +| Tool | What a call does | +|---|---| +| `run_` (one per agent the key can reach) | Runs that agent with `{ message }` and returns its answer; a named action marked *expose as tool* is `run___` | +| `find_`, `count_`, `get_` (one set per discovered [schema tools](/actions/tools#bounded-reads-over-a-model-schema-tools) class) | Reads the host's records directly, with the tool's own parameter schema, so a client that only needs the rows does not have to ask an agent for them | + +Every call runs as **the key's caller** — the key's owner, or whatever +`ActionAgent.agent_actor_resolver` returns for the request — so a schema +tool's `scope` sees the same actor it would inside an agent run, and an +agent's own authorization callbacks decide against the same person. A +boundary violation (an undeclared filter, an id the caller cannot see) comes +back as a tool result with `isError`, the shape an agent's model would get; +a refusal raised by the host's scope or by an agent answers as a JSON-RPC +error (`-32003`), never as an empty, confident result. Direct reads run no +generation, so neither `execution_enabled` nor the execution quota applies to +them. Set `ActionAgent.mcp_schema_tools = false` to keep schema tools +reachable only through agents. `agent://` resources return each +agent's live scorecard. + ## Authentication **The dashboard has no authentication by default.** Anyone who can reach diff --git a/docs/work/schema-tools-follow-ups/branch.md b/docs/work/schema-tools-follow-ups/branch.md new file mode 100644 index 00000000..22ff3651 --- /dev/null +++ b/docs/work/schema-tools-follow-ups/branch.md @@ -0,0 +1,31 @@ +# Schema tools follow-ups — branches + +One batch of work on 2026-09-12, each item on its own branch and pull request, all +against `main` after the 1.5.2 release (#442) was merged back. + +| Branch | PR | Base | Scope | +|---|---|---|---| +| `claude/zealous-turing-4afxvn` | #443 | main | Carry the caller into agent runs (the actor seam); lint fixed, merged with main twice for the changelog | +| `fix/model-spec-provider-round-trip` | #444 | main | A persisted model selection re-runs under its recorded provider | +| `feat/evals-ungrounded-answer` | #445 | main | `ungrounded_answer` fault; wrong tool no longer credited; judge reads more notes (#433) | +| `feat/schema-tools-generator` | #446 | main | `rails g active_agent:schema_tools` and the first SchemaTools docs (#440) | +| `feat/schema-tools-registry` | #447 | main | `SchemaTools.define`, a registry per model, discovery reads it (#441) | +| `feat/delegation-actor` | #448 | #443 | A delegated run inherits its parent's caller | +| `feat/mcp-schema-tools` | #449 | #443 | The MCP facade serves the host's schema tools directly (#439) | + +The two stacked on #443 retarget to `main` once it merges. + +## Running the suite locally + +- `gemfiles/*.lock` are git-ignored, so each checkout resolves its own bundle. A stale local + lock pinned `solid_agent 0.1.1` on one machine; CI resolves 0.2.0. Under 0.1.1 the workbench + test `a run pinned to a conversation persists one user turn…` fails on `main` too — it needs + 0.2.0's provenance stamping — so it is not a regression of anything here. +- `bin/test` reads `.env.test`; with no keys present every OpenAI-backed test errors at client + init. Placeholder keys (`OPENAI_API_KEY=test-…`, and the Anthropic/OpenRouter equivalents) + let VCR cassettes replay. Run with `CI=1` so VCR refuses to hit the network. +- The RubyLLM provider tests and a handful of Anthropic integration tests are live-network + and fail without real keys; CI has them. Two of the CI failures seen today were + `ServiceUnavailable: Connection error` on those, re-run green. +- A stale `test/dummy/config/master.key` that cannot decrypt the tracked credentials fails the + dummy app's boot with `MessageEncryptor::InvalidMessage`; move it aside. diff --git a/docs/work/schema-tools-follow-ups/issues.md b/docs/work/schema-tools-follow-ups/issues.md new file mode 100644 index 00000000..754e8f5e --- /dev/null +++ b/docs/work/schema-tools-follow-ups/issues.md @@ -0,0 +1,28 @@ +# Schema tools follow-ups — issues + +## The list this batch set out to close + +| Item | Outcome | +|---|---| +| #443 caller carried into agent runs | Lint fixed (a blank line main had already removed), merged with main, changelog merged. Merges once CI is green. | +| #442 release 1.5.2 back to main | Merged. `main` says 1.5.2 for both gems. | +| #439 schema tools served over MCP | `Api::MCPController` lists every discovered schema tool with its own parameter schema and dispatches `tools/call` to `SchemaTools.call(name, actor: agent_actor)`. `ActionAgent.mcp_schema_tools = false` keeps them behind agents. | +| #440 generator | `rails g active_agent:schema_tools Model`: every column listed, commented out, with its type; secrets left off; `scope_by_policy` when the policy exists. | +| #441 runtime definitions and the descendants leak | `SchemaTools.define` registers one class per model; discovery reads runtime classes from the registry, never from `descendants`. Persistence of the declaration stays the host's. | +| #433 judge cannot detect fabrication | `ungrounded_answer` fault for a scenario with no tool expectation; `expected_tool_not_called` carries `ungrounded: true` and names the claim; `tools_succeeded` no longer credits a wrong tool; the judge reads 1,500 characters of notes. Where the scenario declares `tools:`, `main` already raised a fault — that half of the issue was stale. | + +## Found on the way + +| Finding | Where it went | +|---|---| +| `ModelSpec.parse_all` re-parsed a persisted spec from its label, so an OpenRouter `anthropic/…` model became Anthropic's once that SDK was installed | #444 | +| `Delegation::Runner` built the sub-agent without the parent's caller, so a delegated run was unattributed | #448 | +| `ActiveAgent::NotAuthorized` is defined in `concerns/authorization.rb`, loaded only with `Base`; the engine referencing it before any agent class loaded raised `NameError` | `autoload :NotAuthorized` in `lib/active_agent.rb`, in #449 | +| `McpAuthorizationTest`'s refusal case fails when run after `AgentAuthorizationTest` (order-dependent); passes alone and in CI | Not fixed; noted for #443 | +| `Docs::AgentsExamplesTest::QuickExampleTest` failed once on CI with `ActionNotFound: list_evaluations` for its `SupportAgent` — a constant shared with the dashboard assistant tests, order-dependent | Re-run green; not fixed | + +## Not done + +- Persisting schema-tool declarations (a table, a dashboard form) — the registry is the seam. +- The judge-limit accounting from #433's "adjacent limits". +- #441's option 3, an instance-based definition with no classes. diff --git a/docs/work/schema-tools-follow-ups/milestones.md b/docs/work/schema-tools-follow-ups/milestones.md new file mode 100644 index 00000000..dc8db565 --- /dev/null +++ b/docs/work/schema-tools-follow-ups/milestones.md @@ -0,0 +1,13 @@ +# Schema tools follow-ups — milestones + +| # | Milestone | Status | +|---|---|---| +| M1 | 1.5.2 release merged back to main (#442) | ✅ | +| M2 | #443 green and merged | ⏳ CI on the changelog merge | +| M3 | Persisted model selections re-run correctly (#444) | ✅ merged | +| M4 | Fabricated answers are a fault; wrong tools not credited (#445, #433) | ⏳ CI after the main merge | +| M5 | Generator and first SchemaTools docs (#446, #440) | ⏳ CI | +| M6 | Runtime definitions with a registry; descendants leak closed (#447, #441) | ⏳ CI after the main merge | +| M7 | Delegated runs inherit the caller (#448) | ⏳ stacked on #443 | +| M8 | Schema tools over MCP (#449, #439) | ⏳ stacked on #443 | +| M9 | A 1.5.3 release carrying all of it | not started — the release is the owner's call | diff --git a/docs/work/schema-tools-follow-ups/pull-request.md b/docs/work/schema-tools-follow-ups/pull-request.md new file mode 100644 index 00000000..63ce48a1 --- /dev/null +++ b/docs/work/schema-tools-follow-ups/pull-request.md @@ -0,0 +1,21 @@ +# Schema tools follow-ups — pull requests + +Each PR carries its own description and testing section; this is the map. + +- **#443** — the caller seam. Not authored in this batch; brought to green (lint) and kept + merged with main. Everything actor-related below builds on it. +- **#444** — `ModelSpec` keeps a persisted spec's provider. 137 evals/engine tests green. Merged. +- **#445** — `ungrounded_answer`, `expected_tool_not_called` names the fabrication, + `tools_succeeded` only for an expected tool, judge reads 1,500 characters of notes. + 156 tests green. +- **#446** — `active_agent:schema_tools` generator; SchemaTools documented in + `docs/actions/tools.md`. 65 tests green. +- **#447** — `SchemaTools.define` / registry / discovery. 59 tests green; full suite green + apart from the live-network RubyLLM tests. +- **#448** — delegation inherits the caller. 12 tests green. Stacked on #443. +- **#449** — schema tools over the MCP facade, `ActionAgent.mcp_schema_tools`, + `autoload :NotAuthorized`, and this work record. 30 MCP/authorization tests green. + Stacked on #443. + +Merge order: #443, then the three independent PRs as CI clears them (each takes a merge +from main for the changelog), then #448 and #449 retargeted to main. diff --git a/lib/active_agent.rb b/lib/active_agent.rb index 6b59e75e..44ac4c7f 100644 --- a/lib/active_agent.rb +++ b/lib/active_agent.rb @@ -92,6 +92,9 @@ module ActiveAgent # # These components are loaded on-demand when first referenced. autoload :Base + # The refusal an agent raises, and the default in Base.authorization_errors. + # Reachable before any agent class has loaded, so the dashboard can name it. + autoload :NotAuthorized, "active_agent/concerns/authorization" autoload :Callbacks, "active_agent/concerns/callbacks" autoload :Delegation, "active_agent/concerns/delegation" autoload :Streaming, "active_agent/concerns/streaming" From 83d6b26ed8583fe7cc80dfc3bb412eab6ce49457 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:29:46 -0700 Subject: [PATCH 2/2] docs(changelog): restore this branch's entry dropped in the last merge --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13076a03..d5c31e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The MCP facade serves the host's schema tools directly.** `tools/list` + at `POST /mcp` now offers every tool the dashboard's discovered + `ActiveAgent::SchemaTools` classes generate — `find_`, + `count_`, `get_` — beside the `run_` agents, each + with its own parameter schema, and `tools/call` runs one as the key's + caller through the host's own scope, exactly as it would inside an agent + run. A client that only needs the rows no longer has to ask an agent for + them. A boundary violation is a tool result with `isError`, a refusal from + the host's scope is a JSON-RPC `-32003`, and neither the execution switch + nor the execution quota applies, because nothing generates. Set + `ActionAgent.mcp_schema_tools = false` to keep the tools reachable only + through agents. Closes #439. + ### Fixed - **The caller can no longer be named by the model, or by the client.**