Skip to content

Run a Bot's tools on the server, not in somebody's browser - #31

Merged
davidmckayv merged 4 commits into
mainfrom
feat/server-side-tools
Aug 20, 2026
Merged

Run a Bot's tools on the server, not in somebody's browser#31
davidmckayv merged 4 commits into
mainfrom
feat/server-side-tools

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

What this changes

Every MCP tool a Bot could call was registered in the browser with useFrontendTool and executed by posting back to /api/plugins/call. A Bot could therefore do nothing at all unless a person had a tab open on it. That rules out an embedded widget, any surface that is not our own app, and every run nobody is watching.

The loop now runs in the runtime. server/src/plugins/tools.ts hands each Bot the tools it holds a grant for, and the 209-line frontend registration is deleted.

Governance does not move with it. callTool is still the only path to a vendor: it checks the grant, evaluates the policy, writes the audit row, and only then calls out. Passing mcpServers to the agent instead would have let it reach a vendor directly and walk around all three, which is why the tools are handed over as executables rather than as server addresses.

Lifted from CopilotKit/openkai, which did this first, as four cherry-picks rather than a hand-port so the authorship and the reasoning survive.

Where it runs

  • New state that outlives a request? None. grantedTools is built per run from the store and holds nothing between calls.
  • What happens on the second replica? Nothing changes. The tool list is read from Postgres on every run, so a grant an administrator adds or revokes applies to the next run on any process, and a run served by one replica does not depend on another having seen it.
  • Anything serialised? No. Nothing here is exclusive.
  • Anything fanned out to a browser? No. This removes a browser dependency rather than adding one.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through grant, then policy, then audit, then the vendor.
  • Refusals write mcp.call_rejected; successes write mcp.call_succeeded.
  • Nothing new is trusted from the client. The Bot and the actor come from the run, not from a request body.

Known regression, closing next

Only built-in Bots get tools in this PR. A remote AG-UI Bot runs its own loop and has to call back in, and there is no authenticated way for it to do that yet: /api/agent-tools/call does not exist. So a remote Bot has no MCP tools between this PR and the next one, where the per-agent callback token lands. Named here rather than discovered.

Three defects found by driving it

The last commit fixes these, each with a test:

  1. Every refused tool call read This deployment's policy does not allow that: the file is blocked by the rule .... A tool call carries the browser fields present and empty, deliberately, so a rule written about a page evaluates to false rather than being unevaluable; describeRefusal branched on the file field existing rather than on it having a path. Pre-existing on main, not introduced here.
  2. Tool results arrive JSON-encoded, so a refusal rendered with its escaping on screen: \"mcp.server == \\\"notes\\\"\".
  3. There was no refusal marker, so a blocked call was drawn as an ordinary result: the Bot said it had been blocked while the line above it looked like a successful call. The integration test asserted only that the reason mentioned "policy", which is exactly why nobody noticed.

Proof

Driven in Chrome against a real MCP server (bun run mock:knowledge, added here), registered through /admin/plugins, granted to the Knowledge Bot.

  • Allowed: the Bot called search_notes, answered with the policy and a working link, and the tool line read "Search notes" rather than the identifier the model was offered.
  • Refused, with mcp.server == "notes" on the deny list: the Bot was stopped, the line read Blocked, the reason named the rule, and the Bot told the person it had been restricted rather than inventing an answer.
  • Audit row for the allowed call: mcp.call_succeeded, notes/search_notes, bot knowledge, actor dev-local-user, effect write, {"allowed": true, "rule": "true", "source": "allow", "carriedOut": true}.

format:check, lint, typecheck clean. 665 pass, 5 skip, 0 fail across 77 files.

The loop ran in the browser: every granted MCP tool was registered with
`useFrontendTool` and its handler posted back to `/api/plugins/call`.
That made a browser a hard requirement for a Bot to do anything, which
rules out an embedded widget, an unattended run, and any surface that is
not our own app.

`BuiltInAgent` takes `tools` directly, so the agent is handed what the
Bot may call and executes it itself.

Governance does not move with it. The tools are NOT raw `mcpServers`,
which would let the agent reach a vendor directly and walk around
everything: each definition executes through `pluginStore.callTool`,
which checks the grant, evaluates the policy and writes the audit row
before anything leaves the process. A refusal comes back as the tool's
result rather than as a thrown error, so the run continues and the
person is told what was blocked.

Tested against a real MCP server (`@copilotkit/aimock`) rather than a
stubbed fetch: a stub passes whether or not we understood the protocol.
They execute in the runtime now, so a second registration in the surface
would offer the model every tool twice. The result rendering survives:
`forDisplay` moves to a shared module and the transcript's fallback
draws it, which is no longer the exception but the ordinary path for
every MCP call.
Two things found by driving it in a browser, neither of which any gate
would have caught.

A run stopped after one step. The AI SDK sets no `stopWhen` unless
`maxSteps` is given, so a Bot called its tool, the result arrived, and
the run ended before the model could say what it found: the person saw
their own question and nothing else, while the audit trail recorded a
successful call. Only set when there are tools, and capped at eight.

The tool line read `mcp__notes__search_notes`. That prefix exists so a
tool name is unique across every server a Bot holds, which is not the
reader's problem. It now reads "Search notes", with the server beside
it, and the server dropped when the action already names it. The
server's own words stay behind the disclosure.

Also here: a mock knowledge MCP server standing in for a customer's
Notion, so slice 1 can answer with nothing real connected. It is
`@copilotkit/aimock`, so a Bot talks to the same protocol implementation
the tests do.
Three defects on the path the server-side tool loop now takes, found by driving it.

A refused call was described from the browser fields. Those are present on an MCP context and
deliberately empty, so that a rule written about a page evaluates to false against a tool call
rather than being unevaluable, and `describeRefusal` branched on the file field being there rather
than on it having a path. Every refused tool call therefore read "the file  is blocked by the rule",
naming a workspace it never went near and a path that was not there. It now names the tool and the
server, and the file branch asks for a path.

A tool returns a string and the runtime carries it in a message as JSON, so what reached the screen
was that string encoded: the whole thing in quotes, every quote inside it escaped. A refusal read
`"... blocked by the rule \"mcp.server == \\"notes\\"\"."`. That also broke more than the look,
because the refusal marker is matched against the start of the text and an encoded string starts
with a quote.

And there was no marker to match. Refusals came back as the policy message alone, so the transcript
had only the wording to go on, and drew a refusal as an ordinary result: the Bot said it had been
blocked while the line above it looked like a successful call. `REFUSAL_MARKER` now leads a refused
answer. The model is told it too, which is right for the model. The reader is not, because the line
already says "Blocked" and three sayings of the same thing in one sentence is two too many.

The marker is written out at both ends rather than shared, like a status code, because it crosses a
network. The integration test asserts it rather than only that the reason mentions policy, which is
what let it be missing.
@davidmckayv
davidmckayv merged commit f29ba9c into main Aug 20, 2026
3 checks passed
@davidmckayv
davidmckayv deleted the feat/server-side-tools branch August 20, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants