SIGN IN SIGN UP

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

* Run MCP tools on the server

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.

* Stop registering MCP tools in the browser

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.

* Let a run answer after it calls a tool, and name the call for a reader

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.

* Say which tool answers were refused, and in words about the tool

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.
D
David McKay committed
f29ba9ca33f0ee39e06000b76ebbf5bdefa7ab17
Parent: 9864e07
Committed by GitHub <noreply@github.com> on 8/20/2026, 4:12:23 PM