mcp/sdk, without a framework bridge. For a Laravel or Symfony application, see Laravel and Symfony: there you write no track() call.
Requirements
- PHP 8.3, 8.4 or 8.5.
mcp/sdk0.8.1 or later. On every pull request and every night, CI tests AgentCat with the newest patch of eachmcp/sdkminor release from 0.8.1 up.- ext-curl, to send the events. ext-pcntl is recommended for stdio servers: without it, the events that wait when the process gets SIGTERM or SIGINT are lost (see Signals).
Track the Builder before build()
Pass the Mcp\Server\Builder to AgentCat::track() after you add your tools and before you call build():
- The project ID.
nullreads it from theAGENTCAT_PROJECT_IDenvironment variable.''sends the events to no AgentCat project, only to your exporters. - Options. The third argument is an
AgentCat\Optionsobject with named arguments, for examplenew Options(identify: …, enableAgentTracking: true). The SDK pages show each option with a PHP tab. - It never throws. When AgentCat cannot track the server, it logs why and returns the Builder unchanged. The server then answers as it would without AgentCat.
- A Builder that is already built is not tracked. AgentCat logs
Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: the mcp/sdk Builder was already built. …. - Tracking again changes nothing.
track()on the same Builder, or on theMcp\Serverthat a tracked Builder built, logs[SESSION DEBUG] track() - Server already being tracked, skipping initializationand returns it.
track() for each server is cheap: the event queue, the exporters and the diagnostics are set up once and shared.
HTTP servers
An HTTP server tracks the Builder in the same way. This script answers one request withStreamableHttpTransport, under PHP-FPM, FrankenPHP or Apache:
Content-Length line, a client on Apache mod_php, php-cgi or php -S waits until AgentCat has delivered, up to 5 seconds.
CLI-hosted HTTP servers record no headers
AgentCat reads the HTTP request of a plainmcp/sdk call from PHP’s request globals ($_SERVER). An HTTP server that runs as one long CLI process (ReactPHP, AMPHP, a stream_socket_server() loop) has no request there. Its events carry no request headers and no URL, and ToolCall::$headers is empty in your hooks. The Laravel and Symfony bridges read the request of their framework instead, also in worker runtimes.
The order of request handlers
AgentCat::track() adds AgentCat’s own tools/list and tools/call handlers to the Builder. mcp/sdk answers each request with the first handler that supports it, in the order you added them. So the order of your calls matters:
- A handler that you add after
track()comes after AgentCat’s. AgentCat delegates to it: your handler answers, and AgentCat records one event per call. - A
tools/callortools/listhandler that you add withaddRequestHandler()beforetrack()answers first. AgentCat never sees those requests and records nothing for them. It logs nothing either.
track() with addTool() are tracked too. A setReferenceHandler() call after track() keeps the events, but AgentCat no longer sees the exception of a failing tool: the event’s error then has only the message of the JSON-RPC error.
Stateless endpoints and buildStateless()
Builder::buildStateless() returns an Mcp\Server\Stateless\StatelessProtocol, not an Mcp\Server. Track the Builder before you call it, as usual. For a custom event, pass the Builder (or an Mcp\Server built from it) to AgentCat::publishCustomEvent(), not the StatelessProtocol. AgentCat cannot map that object to its Builder and throws:
Custom events from a tool
A tool can publish a custom event for its server. Give the tool the Builder:publishCustomEvent() queues the event and returns nothing. AgentCat sends it with the other events. The project ID is required, but for a tracked server the event goes to the project the server was tracked with.
Signals
A stdio server runs until its client closes the input or a signal stops it. With ext-pcntl, AgentCat installs handlers for SIGTERM and SIGINT when it gets its first event, or already intrack() while diagnostics are on. At one of these signals, AgentCat sends the waiting events (for at most 5 seconds). Then it calls the handler that the signal had before, or it ends the process as the signal would have.
- Async signals for the whole process. When AgentCat installs a handler, it calls
pcntl_async_signals(true). From then on, every PHP signal handler of the process runs between two opcodes, not atpcntl_signal_dispatch()or at a tick. - A signal that you ignore stays ignored, when you ignore it in PHP with
pcntl_signal(…, SIG_IGN)beforetrack(). - An ignore that the process inherited is not visible to PHP. A parent process (a shell,
nohup, a supervisor) can start PHP with SIGINT or SIGTERM ignored. PHP cannot see that, so AgentCat installs its handler for the signal, and the signal then ends the process after the delivery. To keep ignoring it, ignore it again in PHP, beforetrack():
- Turn the handlers off with
disableSignalHandlers: true. A signal then ends the process as it did before, without the last delivery. The switch is process-wide and applies only until AgentCat installs its handlers, so set it on the firsttrack()of the process. A latertrack()with the switch logsWARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored.
Testing
AgentCat does nothing while your tests run: under PHPUnit or Pest, or whenAPP_ENV is testing or test. track() then tracks nothing, and publishCustomEvent() checks the project ID and returns. To assert on the events of a test, call AgentCat::fake() first, before the test tracks its server:
logs() list, not to ~/agentcat.log. Each method counts different events:
events(), logs() and the assertions first run the hooks that still wait. The assertions use PHPUnit when it is loaded, and throw AssertionError otherwise.
- Call
fake()beforetrack(). In a test environment, a server tracked beforefake()is not tracked at all, and a failing assertion says so. - Restore the real client with
AgentCat::setClient(null)intearDown(): the fake is the shared client of the PHP process until then. AGENTCAT_ENABLED=trueturns AgentCat on in tests, with real delivery. A server tracked beforefake()then keeps the client that tracked it.AGENTCAT_ENABLED=falseturns AgentCat off everywhere; the fake still records.
Rarely needed: flush() and shutdown()
AgentCat delivers on its own, between requests and when the process ends, so most servers never call these.
AgentCat::flush(?float $timeoutSeconds = null): boolsends the waiting events and exporter requests and waits for them. It returnstruewhen nothing is left to send, andfalsewhen the timeout ended first. It does not wait for AgentCat’s own diagnostics. A raw worker loop calls it after each request: see PHP runtimes.AgentCat::shutdown(): voidstops accepting events, then sends the waiting ones for at most 5 seconds. Events after it are dropped.