ADR-116: Central tooling authority — nr_llm owns builtin + MCP tools
- Status
-
Accepted
- Date
-
2026-07-22
- Authors
-
Netresearch DTT GmbH
Context
Two tooling systems exist in the stack today, and only one of them is nr_llm.
nr_llm is already the tooling authority for its own agent runs. It ships 45
builtin tools under Classes/Service/Tool/Builtin/ (among them
FetchLogsTool, GetLastExceptionTool, ListDeprecationsTool,
GetSystemStatusTool, SiteRagQueryTool and GetPageContentTool),
collects them in ToolRegistry (Classes/Service/Tool/ToolRegistry.php),
and executes them through ToolLoopService (ADR-038) behind
the tool gate (ADR-093) and the global availability state
(ADR-039). AgentRuntime (ADR-101,
Classes/Service/Agent/) is the public application service that owns the
run lifecycle — approval (ADR-084), guardrails
(ADR-085) and context-window bounding
(ADR-107) — with the interface
run / enqueue / runQueued / approve / submitInput /
cancel / events / status.
The separate nr_mcp_agent extension is a second, parallel tool stack
that bypasses all of the above. It is itself an MCP client
(Classes/Mcp/McpToolProvider, Classes/Mcp/McpConnection — a stdio
JSON-RPC client over proc_open — and McpServerRepository over the
tx_nrmcpagent_mcp_server table, whose transport field offers stdio
and sse). It re-implements its own agent loop in
ChatService::runAgentLoop and sources tools only from external MCP
servers (e.g. the suggested hn/typo3-mcp-server dependency), mapping the
MCP wire shape onto nr_llm's ToolSpec. It uses nr_llm merely as a
completion provider (ProviderInterface), never its ToolRegistry or
AgentRuntime.
The result is two agent loops with two independently maintained sets of fail-closed, approval, guardrail and context rules, and two disjoint tool sources (builtin-only in nr_llm, MCP-only in nr_mcp_agent) that no consumer can obtain together.
Decision
nr_llm is the single tooling authority for the AI stack. All tooling — builtin and MCP — is aggregated and executed there; consumers never reach an MCP server directly.
- Add an MCP client to nr_llm. nr_llm gains the ability to connect to
external MCP servers over HTTP, list their tools, and register
those tools into
ToolRegistryalongside the builtin tools. MCP tools then flow through the exact same path as builtins:ToolRegistry→ToolLoopService/AgentRuntime, subject to the same tool gate (ADR-093), the same availability state (ADR-039), the same approval (ADR-084), guardrail (ADR-085) and context-window (ADR-107) enforcement. - Consumers obtain all tooling exclusively via nr_llm. A consumer (for
example a backend AI-chat module) takes its tools from
ToolRegistryand drives runs throughAgentRuntime. It never opens an MCP connection itself. MCP servers are wired only through nr_llm. - One loop, one trust boundary. There is a single agent loop
(
AgentRuntimeoverToolLoopService). Builtin and MCP tools share the one tool-data trust zone (ADR-094) and the one gate, so a tool's origin does not change how it is authorised, approved or audited.
Consequences
- The MCP-client capability moves out of nr_mcp_agent and into nr_llm:
the connection, tool-listing and schema-normalisation logic that today lives
in
McpToolProvider/McpConnectionbecomes an nr_llm concern, and the server-configuration storage (transport, command/arguments, url/auth token) moves with it. ToolRegistrybecomes the aggregation point for builtin and MCP tools; the allow-list, availability toggle and gate apply uniformly regardless of where a tool came from.AgentRuntimeis the single agent loop.nr_mcp_agentdeletesChatService::runAgentLoopand stops assembling its own lifecycle; its divergent fail-closed / approval behaviour disappears with it.nr_mcp_agentis reduced to a thin backend chat UI — module, toolbar and conversation store — drivingAgentRuntime. With MCP gone it is arguably mis-named: a rename (candidatenr_llm_chat) or an outright fold-in to nr_llm are both on the table (see follow-up).hn/typo3-mcp-serverbecomes an MCP server that nr_llm connects to, not a per-consumer composer dependency; any number of external MCP servers attach the same way.- New public surface lands in nr_llm (MCP client configuration + registration). It is a minor-release growth path and will carry its own ADR when the implementation is designed; the public-service count authority (ADR-101) is updated then, not here.
Transports: HTTP only
This ADR first named "stdio / http / sse". That list was wrong in two ways and is corrected here, because it would otherwise be read as a build order.
sse is not a peer of http. It is a response framing for an HTTP
connection, not a separate transport to select. Offering both as values of one
field invites a configuration that cannot be satisfied — which is what the
transport field in nr_mcp_agent's own table already shows, where
stdio and sse are the only values and plain http does not exist.
stdio is out of scope, and not merely for performance. It means spawning a
process on the TYPO3 host from a request, with the command line taken from an
operator-editable record. Every control in the tool stack classifies a tool's
OUTPUT (ADR-094) or authorises its CALLER — nothing classifies
what a tool may do to the host it runs on. "One registry, one gate" is a true
statement about offering, approving and auditing a tool; it says nothing about
launching a process, and it must not be read as covering one. If a stdio
transport is ever wanted, it belongs on the CLI and queue paths behind an
explicit allow-list of executables, decided in its own ADR.
The client therefore speaks HTTP, and only HTTP.
What an MCP tool resolves to, and why it needs a declaration
An MCP tool arrives without a group, and the two resolvers answer that differently — one fail-closed, one fail-open. Both answers are wrong for MCP, in opposite directions, and an implementation that does not address both is not "the same gate as a builtin".
Data class fails closed to unusable. A tool whose group is unknown resolves
to SECRET_ADJACENT, which only a LOCAL trust zone permits. Against every
hosted provider such a tool is withheld. So MCP tools are not merely
"classified strictly" by default — they do not run at all outside a local
model.
Effect fails open. A registered tool that declares nothing resolves to
READ_ONLY, which is correct for the builtins (all of them read) and wrong
for an MCP server, where writes are ordinary. An undeclared remote write would
lose the write fence and the fail-closed audit of ADR-111.
The implementation therefore requires a per-server data-class declaration by the operator, and must treat an externally-sourced tool as a write unless declared otherwise — the inverse of the builtin default. Neither is a weakening: the first replaces "denied everywhere" with a stated ceiling, the second replaces a guess with the strict answer.
Why any of this is nr_llm's problem at all, given that the MCP server authorises its own resources: three things the server cannot see. Its output travels onward to an LLM provider we chose, under a data-protection obligation that is ours. Its authorisation is against one operator credential, so without a gate here every backend user inherits the full rights of that credential. And the write fence is our own retry bookkeeping, about our queue, which the server knows nothing about. Securing the resource stays the server's job; where its answers flow and in whose name we ask are ours.
Migration and follow-up
Implementation is separate follow-up work; this ADR records the target only.
- Build the MCP client in nr_llm: the HTTP transport, the
tools/listhandshake, inputSchema-to-provider-schema normalisation (the concernMcpToolProvideralready solves), and registration of the resulting tools intoToolRegistry. - Move the MCP server configuration model (
transport,command/arguments,url/auth_token) into nr_llm. - Repoint
nr_mcp_agent'sChatServiceontoAgentRuntimeand delete itsClasses/Mcp/client andrunAgentLoop. - Decide rename versus fold-in for
nr_mcp_agentas a discrete step. - Reconcile streaming and context-window parity for MCP-sourced tools per the scope note in ADR-107.