Skip to main content
This page covers a server built with the official PHP MCP library, 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/sdk 0.8.1 or later. On every pull request and every night, CI tests AgentCat with the newest patch of each mcp/sdk minor 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. null reads it from the AGENTCAT_PROJECT_ID environment variable. '' sends the events to no AgentCat project, only to your exporters.
  • Options. The third argument is an AgentCat\Options object with named arguments, for example new 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 the Mcp\Server that a tracked Builder built, logs [SESSION DEBUG] track() - Server already being tracked, skipping initialization and returns it.
Under PHP-FPM, each request runs your script, so each request builds and tracks a new Builder, and that costs no more than tracking one long-lived server. Within one process, 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 with StreamableHttpTransport, under PHP-FPM, FrankenPHP or Apache:
AgentCat runs your hooks and sends the events after the client has its answer. How it does that depends on the runtime: see PHP runtimes. Without the 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 plain mcp/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/call or tools/list handler that you add with addRequestHandler() before track() answers first. AgentCat never sees those requests and records nothing for them. It logs nothing either.
Tools that you add after 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 in track() 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 at pcntl_signal_dispatch() or at a tick.
  • A signal that you ignore stays ignored, when you ignore it in PHP with pcntl_signal(…, SIG_IGN) before track().
  • 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, before track():
  • 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 first track() of the process. A later track() with the switch logs WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored.
Without ext-pcntl, or on a host that disables its functions, AgentCat installs nothing and logs one line (not on Windows, which has no ext-pcntl). PHP runtimes covers stdio delivery, containers, the Docker images without ext-pcntl, and Windows.

Testing

AgentCat does nothing while your tests run: under PHPUnit or Pest, or when APP_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:
The fake records every event after your hooks, redaction and truncation, and sends nothing. It logs to its own 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() before track(). In a test environment, a server tracked before fake() is not tracked at all, and a failing assertion says so.
  • Restore the real client with AgentCat::setClient(null) in tearDown(): the fake is the shared client of the PHP process until then.
  • AGENTCAT_ENABLED=true turns AgentCat on in tests, with real delivery. A server tracked before fake() then keeps the client that tracked it. AGENTCAT_ENABLED=false turns 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): bool sends the waiting events and exporter requests and waits for them. It returns true when nothing is left to send, and false when 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(): void stops accepting events, then sends the waiting ones for at most 5 seconds. Events after it are dropped.