> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcat.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP: mcp/sdk

> Track a plain mcp/sdk server in PHP: stdio and HTTP servers, the order of request handlers, signals, custom events and tests.

This page covers a server built with the official PHP MCP library, [`mcp/sdk`](https://github.com/modelcontextprotocol/php-sdk), without a framework bridge. For a Laravel or Symfony application, see [Laravel](/sdk/php/laravel.md) and [Symfony](/sdk/php/symfony.md): 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](#signals)).

```bash theme={null}
composer require agentcat/sdk mcp/sdk
```

## Track the Builder before `build()`

Pass the `Mcp\Server\Builder` to `AgentCat::track()` after you add your tools and before you call `build()`:

```php theme={null}
<?php

declare(strict_types=1);

use AgentCat\AgentCat;
use Mcp\Server;
use Mcp\Server\Transport\StdioTransport;

require __DIR__ . '/vendor/autoload.php';

$builder = Server::builder()
    ->setServerInfo('echo-mcp', '0.1.0')
    ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message');

// Track the server with AgentCat
AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');

$builder->build()->run(new StdioTransport());
```

* **The project ID.** `null` reads it from the `AGENTCAT_PROJECT_ID` environment variable. `''` sends the events to no AgentCat project, only to your [exporters](/integrations.md).
* **Options.** The third argument is an `AgentCat\Options` object with named arguments, for example `new Options(identify: …, enableAgentTracking: true)`. The [SDK pages](/sdk/identifying-users.md) 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:

```php theme={null}
<?php

declare(strict_types=1);

use AgentCat\AgentCat;
use Http\Discovery\Psr17Factory;
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;

require __DIR__ . '/vendor/autoload.php';

$builder = Server::builder()
    ->setServerInfo('echo-mcp', '0.1.0')
    ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message');

AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');

$factory = new Psr17Factory();
$response = $builder->build()->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory));

$body = (string) $response->getBody();
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
    foreach ($values as $value) {
        header($name . ': ' . $value, false);
    }
}
// Apache mod_php, php-cgi and php -S: with its length, the client has the whole answer before AgentCat delivers.
header('Content-Length: ' . strlen($body));
echo $body;
```

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](/sdk/php/runtimes.md). 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](/sdk/php/laravel.md) and [Symfony](/sdk/php/symfony.md) 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.

```php theme={null}
use AgentCat\AgentCat;
use App\Mcp\AuditedToolCallHandler;

// Right: track first, then add your own tools/call handler. AgentCat delegates to it.
AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');
$builder->addRequestHandler(new AuditedToolCallHandler());
```

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:

```text theme={null}
InvalidArgumentException: Server is not tracked: pass the Mcp\Server\Builder (or the Mcp\Server built from it) to AgentCat::publishCustomEvent(); Builder::buildStateless() returns a StatelessProtocol, which AgentCat cannot map to its Builder
```

## Custom events from a tool

A tool can publish a [custom event](/sdk/custom-events.md) for its server. Give the tool the Builder:

```php theme={null}
use AgentCat\AgentCat;
use AgentCat\CustomEvent;
use Mcp\Server;

$builder = Server::builder()->setServerInfo('billing-mcp', '1.0.0');
$builder->addTool(static function (string $plan) use ($builder): string {
    AgentCat::publishCustomEvent($builder, 'proj_YOUR_PROJECT_ID', new CustomEvent(
        resourceName: 'subscription_upgraded',
        parameters: ['plan' => $plan],
    ));

    return 'Upgraded to ' . $plan;
}, 'upgrade', description: 'Upgrade the subscription');

AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');
```

`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()`:

```php theme={null}
use AgentCat\AgentCat;

// Keep ignoring SIGINT: PHP cannot see an ignore inherited from the parent process.
pcntl_signal(SIGINT, SIG_IGN);

AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');
```

* **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`.

```php theme={null}
use AgentCat\AgentCat;
use AgentCat\Options;

AgentCat::track($builder, 'proj_YOUR_PROJECT_ID', new Options(disableSignalHandlers: true));
```

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](/sdk/php/runtimes.md#the-cli-and-containers) 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:

```php theme={null}
<?php

declare(strict_types=1);

use AgentCat\AgentCat;
use AgentCat\Event;
use Mcp\Server;
use Mcp\Server\Transport\InMemoryTransport;
use PHPUnit\Framework\TestCase;

final class EchoToolTest extends TestCase
{
    protected function tearDown(): void
    {
        AgentCat::setClient(null); // the real client again, which stays off in tests
    }

    public function testTheEchoCallIsTracked(): void
    {
        $fake = AgentCat::fake(); // first: a server tracked before fake() is not tracked at all

        $builder = Server::builder()
            ->setServerInfo('echo-mcp', '0.1.0')
            ->addTool(static fn(string $msg): string => $msg, 'echo', description: 'Echo a message');
        AgentCat::track($builder, 'proj_YOUR_PROJECT_ID');

        $builder->build()->run(new InMemoryTransport([
            '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}',
            '{"jsonrpc":"2.0","method":"notifications/initialized"}',
            '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi"}}}',
        ]));

        $fake->assertTrackedCount(1);
        $fake->assertTracked(static fn(Event $event): bool => $event->resourceName === 'echo');
    }
}
```

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:

| Method | What it checks |
| - | - |
| `assertTracked(?Closure $filter = null)` | At least one tool call (`mcp:tools/call`) was recorded, and one matches the filter when you give one. Custom events do not count. |
| `assertTrackedCount(int $count)` | Exactly `$count` tool calls were recorded. Custom events do not count. |
| `assertNothingTracked()` | No event of any type was recorded, custom events included. |
| `assertCustomEventPublished(?Closure $filter = null)` | At least one custom event (`agentcat:custom`) was recorded, and one matches the filter. |
| `events()` | Every recorded event, in order (`list<AgentCat\Event>`). |
| `logs()` | The lines AgentCat logged while the fake was the shared client, hook errors included. |

`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](/sdk/php/runtimes.md).
* `AgentCat::shutdown(): void` stops accepting events, then sends the waiting ones for at most 5 seconds. Events after it are dropped.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.