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.
Features
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.
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).
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\McpClientThe 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\McpHttpTransportOne 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\McpOperationDeadlineThe 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\McpImportServiceThe 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\McpToolProviderTool 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\McpToolA 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\McpHealthRecorderWrites 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\McpAuthenticationSessionFactoryOpens 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.
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.
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.
vendor/bin/typo3 nrllm:mcp:import deepwiki
vendor/bin/typo3 nrllm:mcp:import --all
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.
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;
}
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).
/**
* 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()));
}
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.
$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'],
],
],
];
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.
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.
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.
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).
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.