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