Contact

Features

MCP Client: External Tools in One Catalogue

nr-llm connects to external Model Context Protocol servers over HTTP and registers their tools next to the builtin ones, behind the same gate, approval and audit.

Why it exists

Many backend systems now offer their functions as an MCP server: ticket systems, documentation services, internal APIs. An agent run in TYPO3 should be able to use those tools. If every consumer extension opened its own MCP connection, each would need its own agent loop, its own approval rules and its own audit. ADR-116 therefore makes nr_llm the single tooling authority: builtin and MCP tools are collected and executed in one place, and consumers never contact an MCP server directly.

A remote tool is code nobody here can inspect. The server describes its own tools, and that description must not decide how they are authorised. So the operator declares, per server, which data class the tool output carries and whether calls need a human approval. Every imported tool counts as a non-idempotent write and requires an administrator, whatever the server's annotations say (ADR-116, ADR-134).

An MCP operation is several HTTP requests: the handshake, its confirmation, then the request that does the work. Before ADR-170 each request had its own timeout, so a slow server could keep a backend user waiting for a multiple of the limit an operator was shown. Now one budget covers the whole operation.

Operators also need to know whether a server answers without rewriting its catalogue. The client records the last successful contact and its latency on every completed operation, and the connection test performs only the handshake (ADR-154).

When to use it

  • An external system exposes an MCP endpoint over HTTP, and agent runs in TYPO3 should call its tools.
  • You build a backend AI feature and want builtin and MCP tools from one source: take them from ToolRegistry and drive runs through AgentRuntime instead of opening MCP connections yourself.
  • A deploy seeds MCP server records and must fetch their catalogues without a click: run nrllm:mcp:import with an identifier or --all.
  • The remote service must enforce each user's own rights: switch the server to delegated authentication so the call carries a token exchanged for the initiating actor.
  • You need to tell "the server is down" from "the tool was renamed": use Test connection and compare Last successful contact with Last import.

How it works

Servers are rows in tx_nrllm_mcp_server, maintained in the admin-only MCP Servers module. McpImportService fetches a server's catalogue on an explicit action (the module's AJAX route nrllm_mcp_import or the CLI command), validates each advertised tool and writes the accepted ones to tx_nrllm_mcp_tool. A tool's local name is mcp_<identifier>_<remote name>, its group is mcp_<identifier>, and its input schema is normalised by McpSchemaNormalizer or the tool is skipped with a reason. McpToolProvider is tagged nr_llm.tool_provider; it turns enabled servers with a declared data class into McpTool objects that ToolRegistry offers next to the builtins. It reads only the database, never the network.

McpClient owns the protocol: initialize, the notifications/initialized confirmation, then tools/list (paginated) or tools/call. It opens a fresh session per operation and one McpOperationDeadline per public method; every leg is granted what is left, rounded up and never below one second. The total is mcpOperationTimeout in the extension configuration, 20 seconds by default. McpHttpTransport speaks HTTP only. It sends Accept: application/json, text/event-stream, and reads an event-stream framed answer from a finite body of at most 2 MiB; it holds no stream open and answers no server-initiated request.

Each request carries an opaque string ID from a per-process random namespace. The transport accepts a reply only with jsonrpc 2.0, the exact request ID, exactly one result or error branch, and an object result. In an event stream, notifications are passed over, and an unrelated or second response is refused (ADR-227). On every completed operation McpHealthRecorder writes last_contact and last_latency_ms; a failed operation records nothing, and a failed write never fails the call.

Authentication is either legacy (an optional nr-vault credential as Bearer or a named header) or delegated (ADR-217). In delegated mode McpAuthenticationSessionFactory opens a credential session per operation for the initiating actor. The configured subject resolver maps the actor to a Vault subject credential, and McpTokenExchange performs an RFC 8693 exchange for the server's audience and scopes. The access token is stored under a short-lived Vault identifier, checked before every leg and deleted when the operation ends. Imports and connection tests use a separate discovery credential. The behaviour is pinned by AbstractMcpConformanceTestCase, which runs the same checks against a stateless and a session-issuing connection profile through a faked PSR-18 client.

Netresearch\NrLlm\Service\Tool\Mcp\McpClient

The MCP conversation: handshake, paginated tools/list, tools/call and the handshake-only ping(); opens one deadline per operation and records liveness once.

Netresearch\NrLlm\Service\Tool\Mcp\McpHttpTransport

One JSON-RPC round trip over HTTP: host gate before credentials, per-call vault client, 2 MiB read cap, event-stream unframing, request IDs and reply validation.

Netresearch\NrLlm\Service\Tool\Mcp\McpOperationDeadline

The budget of one operation, read through a monotonic clock; grants each leg the remaining whole seconds and refuses to send once it is spent.

Netresearch\NrLlm\Service\Tool\Mcp\McpImportService

The only writer of tx_nrllm_mcp_tool: refuses servers without data class or URL, skips tools that cannot be named or normalised, reconciles the catalogue.

Netresearch\NrLlm\Service\Tool\Mcp\McpToolProvider

Tool provider for ToolRegistry; builds McpTool objects from enabled servers with a declared data class and from live catalogue rows, without network I/O.

Netresearch\NrLlm\Service\Tool\Mcp\McpTool

A remote tool as the gate sees it: admin-only, disabled by default, NON_IDEMPOTENT_WRITE, operator-declared data class and approval flag.

Netresearch\NrLlm\Service\Tool\Mcp\McpHealthRecorder

Writes last_contact and last_latency_ms after a completed operation; swallows storage errors so a successful call is never lost.

Netresearch\NrLlm\Service\Tool\Mcp\Auth\McpAuthenticationSessionFactory

Opens the per-operation credential session for delegated servers: one bound to the initiating actor for tool calls, one with the discovery credential for imports and probes.

For developers

Configure the server in the MCP Servers module, import its catalogue, enable the tools you want, and set the operation budget and delegation profiles in the extension configuration. Consumers keep using ToolRegistry and AgentRuntime.

Import a catalogue from the CLI

A server record needs a URL and a declared data class before an import is accepted. The command calls the same McpImportService as the module button. Servers are addressed by identifier; --all imports every enabled server, and one refusing server does not stop the others. The command exits non-zero when a server refused.

bash
vendor/bin/typo3 nrllm:mcp:import deepwiki
vendor/bin/typo3 nrllm:mcp:import --all

What an MCP tool tells the gate

McpTool fixes the answers a remote server must not give about itself. The tool starts disabled, requires an administrator, and always declares a non-idempotent write. The data class is the operator's value from the server row. Because McpTool implements RemoteToolInterface, ToolCallPolicy refuses it above the trust-zone ceiling even in observe mode. Approval follows the server's Requires approval flag through RemoteApprovalInterface.

php
    public function isEnabledByDefault(): bool
    {
        // An imported tool is inert until an operator enables it. Import is an
        // act of configuration; it should not also be an act of granting.
        return false;
    }

    public function requiresAdmin(): bool
    {
        return true;
    }

    public function getGroup(): string
    {
        return 'mcp_' . $this->server->identifier;
    }

    public function getDataClass(): ToolDataClass
    {
        return $this->dataClass;
    }

    public function getEffect(): ToolEffect
    {
        return ToolEffect::NON_IDEMPOTENT_WRITE;
    }

One budget across the legs

McpClient opens one McpOperationDeadline per operation and passes it to every leg. Before each request McpHttpTransport checks isExhausted() and refuses with its own message that names the budget. Otherwise it gives the vault HTTP client legTimeoutSeconds(). The floor of one second exists because a timeout of zero would mean no timeout. Set the total with mcpOperationTimeout (default 20).

php
    /**
     * Whether the budget is gone and the next leg must not be sent.
     */
    public function isExhausted(): bool
    {
        return $this->remainingSeconds() <= 0.0;
    }

    /**
     * The timeout the next leg is granted: what remains, never below the floor.
     *
     * Rounded UP, so a fraction of a second left is a leg that may run for one
     * — the overrun is bounded by a second and the alternative is the unbounded
     * request the floor exists to prevent.
     */
    public function legTimeoutSeconds(): int
    {
        return max(self::MINIMUM_LEG_SECONDS, (int) ceil($this->remainingSeconds()));
    }

Configure delegated authentication

For a server in delegated mode, set the delegation profile, audience and scopes on the record, plus a discovery credential for imports and connection tests. The profile and the explicit actor grants live in the installation configuration. A backend user without an enabled grant is refused; administrator status creates no mapping. Credential identifiers are nr-vault references, never tokens.

php
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['nr_llm']
    ['mcpDelegationProfiles']['company'] = [
        'tokenEndpoint' => 'https://identity.example.org/oauth/token',
        'clientId' => 'nr-llm',
        'allowedAudiences' => ['mcp-api'],
        'allowedScopes' => ['records.read', 'records.write'],
        'backendUsers' => [
            7 => [
                'enabled' => true,
                'credentialIdentifier' => $subjectCredentialUuid,
                'allowedAudiences' => ['mcp-api'],
                'allowedScopes' => ['records.read'],
            ],
        ],
    ];

Gotchas

A server without a data class supplies nothing

The data_class column has no default. McpImportService refuses the import with "The server has no declared data class", and McpServerRepository::findUsable() filters such a server out, so it offers no tools. A connection test still works, because a handshake returns no tool output to classify.

Imported tools are off and admin-only

An import adds catalogue rows; it does not grant anything. Each tool starts disabled and is switched on in the Tools module, like a builtin. McpTool::requiresAdmin() is always true, so ToolCallPolicy denies the tool to a non-admin backend user and to a run without one.

Every MCP tool is a write that asks first

getEffect() is NON_IDEMPOTENT_WRITE for every imported tool, a pure search included, so a call is never replayed on a retry. requires_approval defaults to 1, and McpServerRecord::approvalRequired() treats anything except an explicit 0 as "approval required". Untick it per server once you know what its tools do.

The budget is shared, including across catalogue pages

A handshake slower than 5 seconds leaves the work request less than 15 seconds, and a large catalogue walks all its pages under one budget. Both can now be refused. Raising mcpOperationTimeout is the fix. A backend tool call runs synchronously inside the AJAX request, so a budget above PHP's or the proxy's limit ends in a 500 or 504, not in a failed tool result (ADR-170).

Loose servers are refused, not tolerated

A reply that echoes another request ID, omits jsonrpc 2.0, returns an array as result, carries both result and error, or puts two responses in one event stream is refused as malformed. So is a body over 2 MiB. A refused reply records no contact and leaves the stored catalogue unchanged.