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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mount>/mcp` now offers every tool the dashboard's discovered
`ActiveAgent::SchemaTools` classes generate — `find_<records>`,
`count_<records>`, `get_<record>` — beside the `run_<slug>` 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.
- **A delegated run inherits its parent's caller.** `delegate_to` hands the
sub-agent the parent's `current_user` before its action runs, so its own
`before_action` callbacks and any scope its tools read through decide
Expand Down
75 changes: 72 additions & 3 deletions actionagent/app/controllers/action_agent/api/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_<slug>)
# 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_<records>, count_<records>,
# get_<record> — 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://<slug>
# resource whose content is its live scorecard (config + stats + memory
# summary from the solid_agent datasets).
Expand Down Expand Up @@ -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://<slug> resource returns the agent's live scorecard."
instructions: "Each run_<slug> 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://<slug> " \
"resource returns the agent's live scorecard."
}
end

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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|
Expand Down
17 changes: 17 additions & 0 deletions actionagent/lib/action_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,15 @@ def deprecator
# @return [Array<Class, String>, nil]
attr_accessor :schema_tools

# Whether the MCP facade (POST <mount>/mcp) offers the host's schema tools
# directly — find_<records>, count_<records>, get_<record> — beside the
# run_<slug> 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. Classes built at runtime with
Expand All @@ -391,6 +400,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]
Expand Down Expand Up @@ -545,6 +561,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
Expand Down
139 changes: 139 additions & 0 deletions actionagent/test/mcp_schema_tools_test.rb
Original file line number Diff line number Diff line change
@@ -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_<records> 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
31 changes: 31 additions & 0 deletions docs/framework/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,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 <mount>/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_<slug>` (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_<slug>__<action>` |
| `find_<records>`, `count_<records>`, `get_<record>` (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://<slug>` resources return each
agent's live scorecard.

## Authentication

**The dashboard has no authentication by default.** Anyone who can reach
Expand Down
31 changes: 31 additions & 0 deletions docs/work/schema-tools-follow-ups/branch.md
Original file line number Diff line number Diff line change
@@ -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.
Loading