Skip to content

feat: laravel/mcp adapter, Laravel bridge and runtime delivery - #6

Merged
naji247 merged 30 commits into
feat/plan-a-foundationfrom
feat/plan-e1-laravel
Oct 22, 2026
Merged

naji247 merged 30 commits into
feat/plan-a-foundationfrom
feat/plan-e1-laravel

Conversation

@naji247

@naji247 naji247 commented Oct 19, 2026

Copy link
Copy Markdown
Member

Sixth part of the PHP AgentCat SDK: the laravel/mcp adapter, the Laravel bridge and runtime delivery. Stacked on #5; review #1 to #5 first.

What is in it

  • Laravel with no code: after composer require agentcat/sdk and AGENTCAT_PROJECT_ID in .env, every laravel/mcp 1.x server of the application is tracked. The auto-discovered AgentCat\Laravel\AgentCatServiceProvider tracks each server when the container builds it. Booting the provider tracks nothing, so an artisan command or a queue worker that builds no MCP server gets no shutdown function, no signal handlers and no diagnostics.
  • The laravel/mcp adapter gives the same events, handles, mint-back and hooks as the mcp/sdk adapter. One library-neutral conformance suite runs on both. It points tools/list, tools/call and initialize at AgentCat's method objects through per-server container keys. A tool exception keeps its own class, message and frames in the event, and the client still gets laravel/mcp's own text. In a streamed answer, the notifications pass unchanged and the event comes from the final result. A customer's own CallTool binding, subclass or extend() decorator still answers.
  • config/agentcat.php (php artisan vendor:publish --tag=agentcat-config): enabled, project_id, exclude (Laravel Boost by default), projects (server class => project ID), log_channel, api_url, the TypeScript options in snake_case, hooks as invokable class names, exporters, and the diagnostics keys. Every AgentCat environment name goes through this file, so it all works under php artisan config:cache, and the DISABLE_DIAGNOSTICS opt-out holds there too. A value of a wrong type logs one line and never throws.
  • php artisan agentcat:install writes the project ID into .env.
  • Delivery after the response: Laravel's terminating() waits at most 1 s for delivery in a long-running worker (Octane, FrankenPHP worker mode) and makes one non-blocking pass elsewhere. A worker stop delivers what is left. Every waiting event runs its hooks inside its own request, so a hook that reads the request (for example the logged-in user) never records the next request's user.
  • The bridge's own log lines go to storage/logs/agentcat.log by default; log_channel routes them to another channel.
  • Runtime delivery on every SAPI: the last shutdown pass closes an open native session, gives the drain its own time limit, and, on SAPIs without fastcgi_finish_request() (mod_php, php -S), sends the output buffers first. A tracked laravel/mcp JSON answer gets its Content-Length there, so the client does not wait for delivery. The signal drain does not install under Swoole or OpenSwoole, and FrankenPHP worker mode is detected.
  • Log lines name laravel-mcp= when laravel/mcp is installed, next to mcp= for mcp/sdk.
  • laravel/mcp is an optional dependency (^1.0, conflict <1.0). Contract tests pin every protected member AgentCat reads, on the lowest (1.0.0) and the highest (1.0.2, whose src/ equals main at afcddcf) dependency sets.

Testing

2912 tests pass (composer check; two expected skips). The covered mutation score is 98 % on the full set (5239 mutants) and 98 % on the lines changed since #5. The mcp-sdk-main job passes with 649 tests against mcp/sdk main at 82e19d1. PHP 8.5 with the highest (PHPUnit 13.4.3) and the lowest (PHPUnit 12.5.40) dependency sets passed locally in Docker: both PHPStan configs and 2908 tests each. End-to-end tests run a real Laravel application over HTTP and stdio against a local collector, and the request-end tests ran on PHP 8.3 and 8.5 with pcntl.

…PHPUnit's buffer

PHPUnit 13.4 reports a test as risky when it closes PHPUnit's own output
buffer, even when the test opens a new one in its place. The default
RequestEnd::closeOutputBuffers() keeps no level, so the in-process test had
to close that buffer, and failOnRisky failed the PHP 8.4 and 8.5 highest
runs.

A PHP process with output_buffering on now runs the close, so the bottom
buffer is PHP's own, as in production. The test checks that the buffered
text goes out at the close (before a direct STDOUT write), that PHP's
buffer and the script's were open, and that no level is left.
…-member contract tests

laravel/mcp ^1.0 is a dev dependency, a suggestion and a conflict below 1.0
(D51), with laravel/framework, orchestra/testbench and larastan for the
tests and the analysis. The protected members AgentCat reads
(Server::$methods, Server::$transport, HttpTransport::$request) are in the
manifest, with contract tests, and LaravelTestCase keeps every Laravel write
in a directory of its own under the test HOME.

symfony/translation gets the dev floor ^6.4.3 || ^7.0.3 || ^8.0. The lowest
set reaches v6.4.0 through nesbot/carbon, whatever the laravel/framework or
orchestra/testbench floor; that version declares an implicitly nullable
parameter in a file the autoloader loads, so PHP 8.4 and 8.5 print a
deprecation in every process.
Track laravel/mcp servers through a second library of the registry. The
adapter points a server's tools/list and initialize entries, through the
public addMethod(), at AgentCat's own method objects, bound in the
container under per-server keys; they delegate to the classes the entries
named before. A listing gets the injected parameters and get_more_tools on
every page; streams, JSON-RPC errors, exceptions and answers without a
tool list pass unchanged. initialize records the client of a legacy
handshake for the server.

JsonTree holds the JSON tree rules both adapters share; mcp/sdk's Trees
delegates to it, and McpSdkAdapter::fingerprint(), unused since the
registry, is gone. The laravel/mcp harnesses (a new server per HTTP
request, one server over stdio) and the tracking fixture share their
recording side with the mcp/sdk fixture.
…ce provider

The provider merges and publishes config/agentcat.php (snake_case keys over the Options names, AGENTCAT_* variables, hooks as invokable class names resolved from the current container when they run) and tracks each resolved laravel/mcp server from an afterResolving callback, with the exclude list and the per-server project map. A null exporter entry is skipped without a line, and a header that is not a string is named.
…switch of every client

A null projects entry falls back to agentcat.project_id, and agentcat.enabled sets a process-wide switch that TestMode::sdkEnabled() reads before AGENTCAT_ENABLED, so the shared client honors the configuration under config:cache; a fake still records. The tracker asks Laravel for its log channel only while no logger is chosen, and an exporters map of null entries counts as no exporters.
…e-only registration and the --config no-overwrite rule
…each runs in its own request

An Octane worker serves the next request in the same process. With five deliveries in flight, a waiting event's hooks ran at a later request's pump or wait, so a hook that reads the container (the authenticated user, an injected request) recorded the later request's actor. flush(), the request-end wait, now runs the pipeline of every waiting event whatever the free slots; the payloads wait in order for the transport and go to the endpoint of the moment. pump() and shutdown() still run an event's hooks only when it gets a slot.
…p for a cached configuration

With php artisan config:cache Laravel does not load .env, so the names that config/agentcat.php did not map were lost: DISABLE_DIAGNOSTICS (a privacy opt-out that then failed open), AGENTCAT_DIAGNOSTICS_ENDPOINT and _TOKEN, MCPCAT_API_URL, and, for the shared client, the project ID, the API URL and the diagnostics opt-out; diagnostics had no deployment.environment. The file now maps them (diagnostics_endpoint, diagnostics_token, api_url falls back to MCPCAT_API_URL, disable_diagnostics lists both opt-out names and off wins), and the provider hands the configured values and app.env to every client at boot, as TestMode::configure() does for the switch.
…default

D60. laravel/mcp builds a server per HTTP request, so AgentCat's setup lines, and an ERROR line for a tool that declares its own session_id, repeated per request in the application's default channel and reached its error trackers. Without log_channel, the bridge now builds a single channel at storage/logs/agentcat.log through Laravel's logging, the analogue of TypeScript's ~/mcpcat.log; log_channel still routes the lines into any application channel.
…d value once

A string exclude (App\Mcp\InternalServer::class instead of a list) excluded nothing, so the server was tracked, and a wrong type of projects, project_id, log_channel or the diagnostics keys fell back without a line. A string exclude is now a list of one class; any other wrong type logs one WARN line, once per process, and the default is used (exclude: Laravel Boost; projects: none; an entry of a wrong type is ignored).
The first Diagnostics::init() of the process decides, and a test of another class that tracks with a telemetry client can make it first: the cached-configuration tests that need diagnostics on failed under some random orders (seed 1792379705).
… bad value by its key

'enable_tracing' => env('AGENTCAT_ENABLE_TRACING') with AGENTCAT_ENABLE_TRACING=0 gives the string "0", which logged 'option "enableTracing" must be bool' and left tracing on, against the customer's intent. Every bool key of config/agentcat.php now takes true, 1, yes, on / false, 0, no, off (trimmed, any case) like enabled and disable_diagnostics, a blank value is unset, and a bad value of any key logs one line that names it as written, agentcat.<key>, before the default is used.
…server, so a customer's decorator runs

AgentCat answered tools/call with its CapturingCallTool unless CallTool was bound, so an extend() decorator of CallTool, for example an authorization check, and the resolving() callbacks of CallTool were skipped on every tracked server. The container now builds CallTool at each call, as laravel/mcp does: when it builds exactly CallTool, AgentCat answers with CapturingCallTool and keeps the tool's exception; anything else answers itself.
…ss to the events waiting at its start

N1: flush(0.0), the console request-end wait and AgentCat::flush(0), ran the hooks of the whole backlog with no time limit before the 5 s shutdown deadline started. Only a flush with a window (a positive timeout, or none) now prepares the waiting events; a 0 s flush runs only the hooks of the events it starts, and the shutdown function of the same request or process runs the others.

N2: a hook that queues an event whose hook queues the next one (a redactEvent hook that publishes a custom event) kept a flush from ever returning, which would hang an Octane worker in terminate(). Each prepare pass now takes only the events that waited when it began; later passes take the new ones, and the flush deadline ends the loop.
Base automatically changed from feat/plan-d-exporters to feat/plan-a-foundation October 22, 2026 22:56
@naji247
naji247 merged commit eeee880 into feat/plan-a-foundation Oct 22, 2026
11 of 12 checks passed
@naji247
naji247 deleted the feat/plan-e1-laravel branch October 22, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants