Skip to main content
The Symfony bundle tracks every symfony/mcp-bundle server of your application with no code: you register the bundle and set your project ID.

Requirements

  • PHP 8.3, 8.4 or 8.5.
  • symfony/mcp-bundle 0.13 or 0.14, with Symfony 7.3 or 8.x. On every pull request and every night, CI tests AgentCat with the newest patch of each symfony/mcp-bundle minor release from 0.13 up.
  • ext-curl, to send the events.
symfony/mcp-bundle 0.14 requires mcp/sdk 0.8, so a Symfony application stays on mcp/sdk 0.8 until the bundle releases a version for mcp/sdk 0.9.

Setup

Install the package:
Register the bundle in config/bundles.php:
Then set your project ID in .env.local, or in the environment of your server:
That is all: every server of the mcp.servers configuration is tracked. AgentCat adds a session_id and a context parameter to each tool, removes them from the arguments before your tool runs, and adds the get_more_tools tool. It runs your hooks and sends the events on kernel.terminate, after the response. A config/packages/agentcat.yaml file is optional. It holds the settings below, under the agentcat key.

Configuration reference

php bin/console config:dump-reference agentcat prints every key with its default:
A value of the wrong type logs one warning line, at the first server that AgentCat tracks, and AgentCat uses the default.

Write %env(default::…)% for every AgentCat value

Take each AgentCat value from the environment with the default:: processor, as the defaults above do:
A bare %env(NAME)% whose variable is not set makes the whole agentcat configuration unreadable. AgentCat then tracks no server at all: every server answers without AgentCat, and AgentCat logs one line for each server. With default::, an unset variable is null, which means the default.

exclude_servers and projects

Both use the names of the mcp.servers entries of your symfony/mcp-bundle configuration:
A server that exclude_servers names in the configuration itself is left out when the container compiles: AgentCat never touches it. A name that comes from an environment variable is read when the server is built. A null project is project_id.

Hooks are invokable services

A hook is the service ID of an invokable service, written in the configuration (not in an environment variable). AgentCat gets the service when the hook runs, and calls it with the same argument as the PHP hook: an AgentCat\ToolCall for identify, resolve_session_id, event_tags and event_properties, a string for redact_sensitive_information, and an AgentCat\Event for redact_event.
With autowiring, the class name is the service ID. A missing service never breaks the container: AgentCat logs one line when the hook runs, and the tool call is not affected. Read the request from the ToolCall, not from the RequestStack. AgentCat runs the hooks after the response, on kernel.terminate or in its shutdown function, where the RequestStack is empty. $call->headers holds the headers of the call, with lower-case names.

The log file

AgentCat writes its own lines to %kernel.logs_dir%/agentcat.log (for example var/log/agentcat.log), not to the mcp logger of the bundle. Under PHP-FPM, each request builds the container and tracks the servers again, so the setup lines repeat on every request; in their own file, they stay out of your application’s log and its error trackers.
  • To route them elsewhere, set logger to the service ID of a PSR-3 logger, for example monolog.logger.mcp. An ID that is not a PSR-3 logger logs WARN: AgentCat option "agentcat.logger" must be the ID of a PSR-3 logger service, got "<id>"; the default is used to the default file.
  • To give the mcp channel a file of its own, with symfony/mcp-bundle’s lines (and AgentCat’s, with logger: monolog.logger.mcp), add a Monolog handler for that channel:
  • Lines written before any server is built go to ~/agentcat.log, for example the line of an AgentCat::publishCustomEvent() call in a request that serves no MCP call.

When AgentCat tracks a server

AgentCat tracks a server when the container builds its Builder: at an MCP request, in bin/console mcp:server, or in the debug tools. It never tracks at boot, so a console command or a worker that builds no MCP server gets no AgentCat setup, no diagnostics and no signal handlers. bin/console list builds no server. In debug mode, the profiler builds every server’s Builder at the end of each request, and bin/console debug:mcp builds them all too. AgentCat then tracks them: you see the setup lines, and with diagnostics on, one diagnostics request, also for dev pages that serve no MCP call.

The order of request handlers

mcp/sdk answers each request with the first handler that supports it. AgentCat’s compiler pass runs after the bundle’s own passes (at priority -100, before optimization), and it adds AgentCat’s handlers before the request handlers that you tag mcp.request_handler. So a tools/call or tools/list handler of yours with that tag comes after AgentCat’s: AgentCat delegates to it, your handler answers, and AgentCat records one event per call. A handler that your own compiler pass adds with an addRequestHandler() method call on a builder stays ahead of AgentCat’s handlers. For tools/call or tools/list, it answers first, and AgentCat records nothing for those requests. Tag the handler mcp.request_handler instead.

After the response

AgentCat delivers on kernel.terminate, in a listener that runs last (priority -2048):
  • In Symfony Runtime workers (RoadRunner, Swoole, FrankenPHP worker mode), it waits at most 1 second after each request, so the worker is soon free. Events still waiting go out at the next request of the worker, or when the worker stops.
  • Under PHP-FPM, FrankenPHP’s classic mode and the other per-request runtimes, it only starts the delivery there. Its shutdown function finishes it after the response, within 5 seconds.
AgentCat reads the headers of each call from the main request of the RequestStack, so a worker records the request it serves. bin/console mcp:server (stdio) has no request and records none.

Responses on Apache mod_php, php-cgi and php -S

There, PHP sends a response only when the script ends, and kernel.terminate runs before the client has it. The bundle gives the plain JSON answers of a tracked server their Content-Length, so the client has the whole answer before kernel.terminate and AgentCat’s delivery. PHP runtimes has the rules.

Testing

AgentCat is off while your tests run (PHPUnit, Pest, or APP_ENV=test). To assert on the events of a functional test, call AgentCat::fake() before the kernel builds a server, and restore the real client with AgentCat::setClient(null) in tearDown(). The mcp/sdk page says what each assertion of the fake counts.