diff --git a/.gitattributes b/.gitattributes
index 366cc9f..a5a3a9e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,9 +4,11 @@
/.gitignore export-ignore
/.editorconfig export-ignore
/phpstan.neon.dist export-ignore
+/phpstan.no-bundle.neon export-ignore
/.php-cs-fixer.dist.php export-ignore
/rector.php export-ignore
/phpunit.xml.dist export-ignore
/infection.json5 export-ignore
/docs export-ignore
+/recipe export-ignore
/composer-dependency-analyser.php export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4bbbabe..5d76c4b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -146,15 +146,41 @@ jobs:
- uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
with:
dependency-versions: highest
+ - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1
+ run: composer remove --dev --no-update symfony/mcp-bundle
- name: Install mcp/sdk main at the commit this release was verified against
run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0"
- - run: vendor/bin/phpstan analyse --no-progress --error-format=github
+ - run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon
- run: vendor/bin/phpunit --testsuite Contract,Integration,E2E
+ runtime:
+ name: Runtime delivery (Docker, PHP 8.3)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
+ with:
+ php-version: '8.3'
+ coverage: none
+ - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
+ with:
+ dependency-versions: highest
+ - name: Build the CLI and Swoole images and pull the others
+ run: |
+ docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker
+ docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole
+ for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm unit:1.34.2-php8.3; do docker pull "$image"; done
+ - run: vendor/bin/phpunit --testsuite Runtime --group runtime
+ env:
+ AGENTCAT_RUNTIME_TESTS: '1'
+
all-green:
name: All green
if: always()
- needs: [static, tests, coverage, mutation, mcp-sdk-main]
+ needs: [static, tests, coverage, mutation, mcp-sdk-main, runtime]
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0
diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php
index 53e13a6..046bf9b 100644
--- a/composer-dependency-analyser.php
+++ b/composer-dependency-analyser.php
@@ -18,8 +18,8 @@
->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/McpSdk', [ErrorType::SHADOW_DEPENDENCY])
// Psr\EventDispatcher\EventDispatcherInterface arrives with mcp/sdk; the test double stands in for a customer's dispatcher.
->ignoreErrorsOnPackageAndPath('psr/event-dispatcher', __DIR__ . '/tests/Support/McpSdk/RecordingDispatcher.php', [ErrorType::SHADOW_DEPENDENCY])
- // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixture builds its request from the globals with it, like a customer.
- ->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/Fixtures', [ErrorType::SHADOW_DEPENDENCY])
+ // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixtures build their request from the globals with it, like a customer.
+ ->ignoreErrorsOnPackageAndPaths('php-http/discovery', [__DIR__ . '/tests/E2E/Fixtures', __DIR__ . '/tests/Runtime/Fixtures'], [ErrorType::SHADOW_DEPENDENCY])
// laravel/mcp and Laravel are optional (01 AC-90): dev dependencies used only by the Laravel adapter and bridge.
->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD])
->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD])
@@ -28,7 +28,19 @@
// False positives: the analyser reads AgentCat's own `Env::get()` and `new Event(` as Laravel's global env() and
// event() helpers. PHPat keeps Laravel out of these files (LayeringTest).
->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Internal/Support/BridgeConfig.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD])
+ // Symfony is optional (01 AC-90): dev dependencies used only by the Symfony bundle.
+ ->ignoreErrorsOnPackagesAndPaths(['symfony/config', 'symfony/dependency-injection', 'symfony/http-foundation', 'symfony/http-kernel'], [__DIR__ . '/src/Symfony'], [ErrorType::DEV_DEPENDENCY_IN_PROD])
+ // The bundle configures the Mcp\Server\Builder services of symfony/mcp-bundle, which requires mcp/sdk.
+ ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD])
+ // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it.
+ ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY])
+ // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle adds its kernel.response listener to the kernel's dispatcher, and the bundle tests read the listeners from it.
+ ->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Support/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY])
// Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it.
->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY])
// Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel.
- ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]);
+ ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY])
+ // Symfony\Component\Console arrives with symfony/framework-bundle; a bundle test runs a console command as an application does.
+ ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/Integration/Symfony/BundleTest.php', [ErrorType::SHADOW_DEPENDENCY])
+ // Symfony\Component\Routing arrives with symfony/framework-bundle (and symfony/mcp-bundle); the test kernel imports the bundle's MCP routes with it, the kernel without symfony/mcp-bundle adds its own route, and a contract test reads the routes.
+ ->ignoreErrorsOnPackageAndPaths('symfony/routing', [__DIR__ . '/tests/Support/Symfony/TestKernel.php', __DIR__ . '/tests/Support/Symfony/WithoutMcpBundleKernel.php', __DIR__ . '/tests/Contract/Symfony/BundleServicesTest.php'], [ErrorType::SHADOW_DEPENDENCY]);
diff --git a/composer.json b/composer.json
index bac6b5f..54172b0 100644
--- a/composer.json
+++ b/composer.json
@@ -57,10 +57,21 @@
"rector/rector": "2.7.0",
"shipmonk/composer-dependency-analyser": "^1.8.4",
"spaze/phpstan-disallowed-calls": "^4.15",
+ "symfony/cache-contracts": "^2.5.3 || ^3.4.2",
+ "symfony/clock": "^6.4.3 || ^7.0.3 || ^8.0",
+ "symfony/config": "^7.3.4 || ^8.0",
+ "symfony/dependency-injection": "^7.3.4 || ^8.0",
+ "symfony/event-dispatcher-contracts": "^2.5.3 || ^3.4.2",
+ "symfony/framework-bundle": "^7.3 || ^8.0",
+ "symfony/http-foundation": "^7.3 || ^8.0",
+ "symfony/http-kernel": "^7.3 || ^8.0",
+ "symfony/mcp-bundle": "^0.13 || ^0.14",
"symfony/polyfill-uuid": "^1.20",
"symfony/process": "^7.4 || ^8.1",
"symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0",
+ "symfony/translation-contracts": "^2.5.3 || ^3.4.2",
"symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0",
+ "symfony/yaml": "^7.3 || ^8.0",
"tomasvotruba/type-coverage": "^2.5.1"
},
"conflict": {
@@ -71,7 +82,8 @@
"ext-pcntl": "Delivers the queued events when a CLI server (an MCP stdio server) gets SIGTERM or SIGINT",
"ext-posix": "Ends the process with the signal after that delivery, and finds the home directory for agentcat.log when HOME is not set",
"laravel/mcp": "Tracks every laravel/mcp server of a Laravel application, through the auto-discovered service provider",
- "mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)"
+ "mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)",
+ "symfony/mcp-bundle": "Tracks every symfony/mcp-bundle server through AgentCat\\Symfony\\AgentCatBundle"
},
"minimum-stability": "stable",
"autoload": {
diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon
new file mode 100644
index 0000000..fda9eab
--- /dev/null
+++ b/phpstan.no-bundle.neon
@@ -0,0 +1,11 @@
+# PHPStan for the mcp/sdk main leg, which removes symfony/mcp-bundle (it requires mcp/sdk ^0.8.1).
+includes:
+ - phpstan.neon.dist
+parameters:
+ tmpDir: .cache/phpstan-no-bundle
+ excludePaths:
+ - tests/Support/Symfony
+ - tests/Integration/Symfony
+ - tests/Contract/Symfony
+ - tests/Integration/Conformance/SymfonyConformanceTest.php
+ - tests/Runtime/Fixtures/symfony-front.php
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 38cd911..4cd36c8 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -26,6 +26,9 @@
tests/E2E
+
+ tests/Runtime
+ tests/Architecture
@@ -35,6 +38,7 @@
network
+ runtime
diff --git a/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml
new file mode 100644
index 0000000..102d4e9
--- /dev/null
+++ b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml
@@ -0,0 +1,7 @@
+# AgentCat tracks every symfony/mcp-bundle server of this application.
+# Set AGENTCAT_PROJECT_ID (proj_…) in .env.local. Reference: bin/console config:dump-reference agentcat
+agentcat:
+ project_id: '%env(default::AGENTCAT_PROJECT_ID)%'
+ # exclude_servers: [internal]
+ # projects:
+ # billing: '%env(default::AGENTCAT_BILLING_PROJECT_ID)%'
diff --git a/recipe/agentcat/sdk/1.0/manifest.json b/recipe/agentcat/sdk/1.0/manifest.json
new file mode 100644
index 0000000..915e872
--- /dev/null
+++ b/recipe/agentcat/sdk/1.0/manifest.json
@@ -0,0 +1,11 @@
+{
+ "bundles": {
+ "AgentCat\\Symfony\\AgentCatBundle": ["all"]
+ },
+ "copy-from-recipe": {
+ "config/": "%CONFIG_DIR%/"
+ },
+ "env": {
+ "AGENTCAT_PROJECT_ID": ""
+ }
+}
diff --git a/recipe/agentcat/sdk/1.0/post-install.txt b/recipe/agentcat/sdk/1.0/post-install.txt
new file mode 100644
index 0000000..d7f8261
--- /dev/null
+++ b/recipe/agentcat/sdk/1.0/post-install.txt
@@ -0,0 +1,2 @@
+ * Set AGENTCAT_PROJECT_ID> in .env.local> to your AgentCat project ID (proj_…).
+ * AgentCat is off while your tests run; call AgentCat::fake()> in a test to record its events.
diff --git a/src/Client.php b/src/Client.php
index adc7384..82d23ae 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -9,6 +9,7 @@
use AgentCat\Internal\Delivery\EventQueue;
use AgentCat\Internal\Diagnostics\Diagnostics;
use AgentCat\Internal\Engine\CallInterceptor;
+use AgentCat\Internal\Engine\HttpContext;
use AgentCat\Internal\Engine\Libraries;
use AgentCat\Internal\Engine\Library;
use AgentCat\Internal\Engine\Publisher;
@@ -45,11 +46,15 @@
private bool $telemetry;
+ /** @var (\Closure(): ?HttpContext)|null the HTTP request of a call, for every server this client tracks */
+ private ?\Closure $httpContext;
+
/**
* Creates a client that sends through the process-wide event queue. Call it without arguments. A
* dependency-injection container that autowires it gets the same client.
*
- * @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate.
+ * @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate, and
+ * a framework bridge gives the HTTP request of a call.
*/
public function __construct(?ClientSeams $seams = null)
{
@@ -57,6 +62,21 @@ public function __construct(?ClientSeams $seams = null)
$this->interceptor = new CallInterceptor($this->publisher);
$this->enabled = $seams->enabled ?? TestMode::sdkEnabled(...);
$this->telemetry = $seams->telemetry ?? true;
+ $this->httpContext = $seams?->httpContext;
+ }
+
+ /**
+ * Internal: this client with the HTTP request of a call from `$httpContext`, for every server the copy tracks. The
+ * copy keeps the event queue, the enabled gate and the telemetry setting, so the copy of a fake's client records
+ * into the fake. The Symfony bundle gives a fake's client its `RequestStack` this way (D41).
+ *
+ * @internal
+ *
+ * @param \Closure(): ?HttpContext $httpContext
+ */
+ public function withHttpContext(\Closure $httpContext): self
+ {
+ return new self(ClientSeams::of($this->publisher->queue(), $this->enabled, $this->telemetry, $httpContext));
}
/**
@@ -129,7 +149,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio
} elseif ($projectId !== '' && preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) {
Log::write(sprintf('WARN: Project ID "%s" does not match proj_ followed by 27 letters or digits; the AgentCat API drops its events', $projectId));
}
- $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher);
+ $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher, $this->httpContext);
$library->install($server, $state, $this->publisher, $this->interceptor);
TrackedServers::add($server, $state);
Log::write(sprintf(
diff --git a/src/Internal/ClientSeams.php b/src/Internal/ClientSeams.php
index 1515014..08756ff 100644
--- a/src/Internal/ClientSeams.php
+++ b/src/Internal/ClientSeams.php
@@ -5,29 +5,38 @@
namespace AgentCat\Internal;
use AgentCat\Internal\Delivery\EventQueue;
+use AgentCat\Internal\Engine\HttpContext;
/**
- * What tests and `AgentCat::fake()` replace in a `Client`: its event queue, its enabled gate and whether it sets up telemetry. The constructor is
- * private, so a dependency-injection container cannot build one, and an autowired `Client` always gets the
- * process-wide queue and the default gate.
+ * What tests, `AgentCat::fake()` and the framework bridges replace in a `Client`: its event queue, its enabled gate,
+ * whether it sets up telemetry, and where the HTTP request of a call comes from. The constructor is private, so a
+ * dependency-injection container cannot build one, and an autowired `Client` always gets the process-wide queue, the
+ * default gate and the SAPI globals.
*
* @internal
*/
final readonly class ClientSeams
{
- /** @param (\Closure(): bool)|null $enabled */
+ /**
+ * @param (\Closure(): bool)|null $enabled
+ * @param (\Closure(): ?HttpContext)|null $httpContext
+ */
private function __construct(
public EventQueue $queue,
public ?\Closure $enabled,
public bool $telemetry,
+ public ?\Closure $httpContext,
) {}
/**
- * @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44)
- * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`)
+ * @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44)
+ * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`)
+ * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework (the Symfony
+ * bundle's `RequestStack`), for every server the client tracks;
+ * null reads the SAPI globals (plan E2 header conflict 5)
*/
- public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true): self
+ public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true, ?\Closure $httpContext = null): self
{
- return new self($queue, $enabled, $telemetry);
+ return new self($queue, $enabled, $telemetry, $httpContext);
}
}
diff --git a/src/Internal/Delivery/ContentLength.php b/src/Internal/Delivery/ContentLength.php
new file mode 100644
index 0000000..ba293ca
--- /dev/null
+++ b/src/Internal/Delivery/ContentLength.php
@@ -0,0 +1,66 @@
+headersSent = $headersSent ?? static fn(): bool => headers_sent();
+ }
+
+ /**
+ * The length in bytes to give an answer, or null when it goes as it is: an answer to a HEAD request (its length
+ * would be the GET answer's, RFC 9110 §8.6, but `Response::prepare()` has emptied the body), an answer without a body
+ * (informational, 204, 304, as `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length`
+ * or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in
+ * an output buffer, which go out first and would make the length short).
+ *
+ * @param string $method the request's method (`Request::getMethod()`, upper case)
+ * @param array $headers the answer's headers by name, in any case (`HeaderBag::all()`)
+ * @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`)
+ */
+ public function of(string $method, int $status, array $headers, string|false $content): ?string
+ {
+ if ($content === false || $method === 'HEAD' || $status < 200 || $status === 204 || $status === 304) {
+ return null;
+ }
+ $headers = array_change_key_case($headers);
+ if (array_key_exists('content-length', $headers) || array_key_exists('transfer-encoding', $headers)) {
+ return null;
+ }
+ if (($this->headersSent)() || $this->outputWaits()) {
+ return null; // stray output goes out before the answer: a length of the answer alone would cut its end
+ }
+
+ return (string) strlen($content);
+ }
+
+ /** Some output buffer level holds bytes, which PHP sends before the answer. */
+ private function outputWaits(): bool
+ {
+ foreach (ob_get_status(true) as $status) {
+ $used = is_array($status) ? $status['buffer_used'] ?? null : null;
+ if (is_int($used) && $used > 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php
index 0f026a5..5db4ea7 100644
--- a/src/Internal/Delivery/EventQueue.php
+++ b/src/Internal/Delivery/EventQueue.php
@@ -219,8 +219,9 @@ public function add(QueuedEvent $event): void
}
/**
- * Non-blocking: runs the pipeline for waiting events while fewer than 5 are in delivery, pumps the transport,
- * ticks the companions and pumps the side channel. A re-entrant call (from a hook or an exporter) does nothing.
+ * Non-blocking: starts the events queued when it began while fewer than 5 are in delivery (the pipeline of a
+ * waiting one runs then), pumps the transport, ticks the companions and pumps the side channel. An event a hook
+ * queues meanwhile waits for the next pump. A re-entrant call (from a hook or an exporter) does nothing.
*
* `pump()` is non-blocking only when libcurl resolves host names asynchronously (threaded resolver or
* c-ares). With a synchronous resolver, starting a transfer can block for the DNS lookup.
@@ -409,14 +410,19 @@ private function wait(float $until): void
}
/**
- * One pass: starts events while fewer than 5 are in delivery, oldest first (those through the pipeline, then the
- * waiting ones, whose pipeline runs now), and pumps the transport, then ticks each companion (unless `$tick` is
- * false) and pumps the side channel. Never throws.
+ * One pass: starts the events queued when it began while fewer than 5 are in delivery, oldest first (those through
+ * the pipeline, then the waiting ones, whose pipeline runs now), and pumps the transport, then ticks each companion
+ * (unless `$tick` is false) and pumps the side channel. Never throws.
+ *
+ * An event a hook queues meanwhile waits for the next pass: an event that starts no transfer (telemetry only, an
+ * encode failure, dropped by a hook) leaves the slots free, so a hook that keeps queueing such events cannot hold
+ * the pass. A signal drain during a hook (`finalShutdown()`) delivers the whole queue: the pass stops when the
+ * queue is empty.
*/
private function pumpOnce(bool $tick = true): void
{
try {
- while ($this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY) {
+ for ($left = $this->queued(); $left > 0 && $this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY; --$left) {
$ready = $this->ready->isEmpty() ? $this->prepare($this->waiting->dequeue()) : $this->ready->dequeue();
if ($ready instanceof ReadyEvent) {
$this->send($ready);
@@ -456,12 +462,12 @@ private function finishCompanions(): void
/**
* The pipeline of the events that waited when the pass began, whatever the free slots (`flush()`): the payloads wait
- * in order. An event a hook queues meanwhile waits for the next pass. A hook can only add waiting events (the cap
- * drops a prepared one first, and a waiting one only for a new one), so `$left` never outruns the queue.
+ * in order. An event a hook queues meanwhile waits for the next pass. A signal drain during a hook
+ * (`finalShutdown()`) delivers the whole queue: the pass stops when no event waits.
*/
private function prepareWaiting(): void
{
- for ($left = $this->waiting->count(); $left > 0; --$left) {
+ for ($left = $this->waiting->count(); $left > 0 && !$this->waiting->isEmpty(); --$left) {
$ready = $this->prepare($this->waiting->dequeue());
if ($ready instanceof ReadyEvent) {
$this->ready->enqueue($ready);
diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php
index 3031a46..e861bfb 100644
--- a/src/Internal/Delivery/FinishRequest.php
+++ b/src/Internal/Delivery/FinishRequest.php
@@ -14,8 +14,9 @@ final class FinishRequest
{
/**
* PHP-FPM `fastcgi_finish_request`, LiteSpeed `litespeed_finish_request`, FrankenPHP (classic mode)
- * `frankenphp_finish_request` or its `fastcgi_finish_request` alias; null elsewhere. FrankenPHP worker mode
- * (`isFrankenPhpWorker()`) gets null: its shutdown functions do not run per request.
+ * `frankenphp_finish_request` or its `fastcgi_finish_request` alias, NGINX Unit (`isUnit()`)
+ * `fastcgi_finish_request`; null elsewhere. FrankenPHP worker mode (`isFrankenPhpWorker()`) gets null: its shutdown
+ * functions do not run per request.
*
* @param array $server `$_SERVER`
* @param \Closure(string): bool $functionExists
@@ -27,6 +28,7 @@ public static function functionFor(string $sapi, array $server, \Closure $functi
$sapi === 'fpm-fcgi' => 'fastcgi_finish_request',
$sapi === 'litespeed' => 'litespeed_finish_request',
$sapi === 'frankenphp' && !self::isFrankenPhpWorker($server, $getenv) => $functionExists('frankenphp_finish_request') ? 'frankenphp_finish_request' : 'fastcgi_finish_request',
+ $sapi === 'cli-server' && self::isUnit($server) => 'fastcgi_finish_request',
default => null,
};
@@ -44,6 +46,19 @@ public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = nul
return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false;
}
+ /**
+ * NGINX Unit: its PHP module reports the SAPI of `php -S` (`cli-server`), and Unit names itself in
+ * `SERVER_SOFTWARE` (`Unit/`), where `php -S` names PHP's development server.
+ *
+ * @param array $server `$_SERVER`
+ */
+ private static function isUnit(array $server): bool
+ {
+ $software = $server['SERVER_SOFTWARE'] ?? null;
+
+ return is_string($software) && str_starts_with($software, 'Unit/');
+ }
+
/**
* The process outlives the request: the command line (Octane on Swoole or RoadRunner, a Symfony Runtime worker, a
* stdio server) or FrankenPHP worker mode.
diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php
index ce14251..8c5313e 100644
--- a/src/Internal/Diagnostics/Diagnostics.php
+++ b/src/Internal/Diagnostics/Diagnostics.php
@@ -5,6 +5,7 @@
namespace AgentCat\Internal\Diagnostics;
use AgentCat\Internal\Delivery\EventQueue;
+use AgentCat\Internal\Delivery\FinishRequest;
use AgentCat\Internal\Delivery\QueueCompanion;
use AgentCat\Internal\Logging\Log;
use AgentCat\Internal\SdkVersion;
@@ -16,11 +17,13 @@
/**
* TypeScript diagnostics: every SDK log line (D07), as OTLP/JSON logs to AgentCat's collector. The first `init()` of
* the process decides. Lines are buffered (1 000 at most, with at most 1 MiB of bodies; the oldest are dropped) and
- * posted as a background request (`flush()` does not wait for it; 250 ms connect and 1 s total timeouts, D49) at the
- * first queue pass (`pump()`, `flush()`, `shutdown()`) once the oldest of them is 2 s old, and once more when the queue
- * shuts down, after the events (D47: under PHP-FPM one post per request, after the response). Lines logged in the last
- * shutdown pass, after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log line would
- * feed the buffer it reports on.
+ * posted as a background request (`flush()` does not wait for it; 250 ms connect and 1 s total timeouts, D49) when the
+ * queue shuts down, after the events. A process that outlives its requests (`FinishRequest::outlivesRequest()`: the
+ * command line, FrankenPHP worker mode) also posts them at the first queue pass (`pump()`, `flush()`, `shutdown()`)
+ * once the oldest of them is 2 s old, as TypeScript does; a per-request runtime (PHP-FPM, mod_php, `php -S`) posts only
+ * at shutdown, once per request, after the response, however long the request and its drain take (D47). Lines logged
+ * in the last shutdown pass, after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log
+ * line would feed the buffer it reports on.
*
* @internal
*/
@@ -62,6 +65,7 @@ final class Diagnostics implements QueueCompanion
* @param list $recordAttributes
* @param \Closure(): float $clock monotonic seconds
* @param \Closure(): int $wallMillis wall-clock milliseconds
+ * @param bool $window whether queue passes post after 2 s
*/
private function __construct(
private readonly EventQueue $queue,
@@ -69,6 +73,7 @@ private function __construct(
private readonly array $recordAttributes,
private readonly \Closure $clock,
private readonly \Closure $wallMillis,
+ private readonly bool $window,
) {}
/**
@@ -79,8 +84,9 @@ private function __construct(
* @param (\Closure(): bool)|null $inTestEnvironment default `TestMode::inTestEnvironment()` (P5)
* @param (\Closure(): float)|null $clock tests: monotonic seconds
* @param (\Closure(): int)|null $wallMillis tests: wall-clock milliseconds
+ * @param string|null $sapi tests: the SAPI, which decides the 2 s window; default `PHP_SAPI`
*/
- public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null): void
+ public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null, ?string $sapi = null): void
{
try {
if (self::$initialized) {
@@ -102,6 +108,7 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue
DiagnosticsAttributes::record(),
$clock ?? static fn(): float => hrtime(true) / 1e9,
$wallMillis ?? static fn(): int => (int) (microtime(true) * 1000), // whole milliseconds, like Date.now()
+ FinishRequest::outlivesRequest($sapi ?? PHP_SAPI, $_SERVER),
);
self::$active = $diagnostics;
$queue->attach($diagnostics);
@@ -125,12 +132,15 @@ public static function reset(): void
Log::setDiagnosticsSink(null);
}
- /** Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes). */
+ /**
+ * Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes), in a process
+ * that outlives its requests only: a per-request runtime posts once, at shutdown (D47).
+ */
#[\Override]
public function tick(): void
{
$firstAt = $this->firstAt;
- if ($firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) {
+ if ($this->window && $firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) {
$this->flush();
}
}
diff --git a/src/Internal/Engine/HttpContext.php b/src/Internal/Engine/HttpContext.php
index ceb7b40..a9fabc6 100644
--- a/src/Internal/Engine/HttpContext.php
+++ b/src/Internal/Engine/HttpContext.php
@@ -5,12 +5,20 @@
namespace AgentCat\Internal\Engine;
/**
- * The HTTP request of the current PHP request, read from `$_SERVER`. Null on the command line (stdio servers).
+ * The HTTP request of an MCP call: read from `$_SERVER` (null on the command line, stdio servers), or from the request
+ * object a framework bridge hands over, so a worker that serves many requests in one process records each one's own.
*
* @internal
*/
final readonly class HttpContext
{
+ /**
+ * The headers that Symfony puts in a request's header bag and no client sent. `ServerBag` makes up the `php-auth-*`
+ * headers from the `Authorization` header or the SAPI's `PHP_AUTH_*` variables; two hold the credentials in clear
+ * text, and the `Authorization` header stays (D06). `HttpKernel::handle()` adds `x-php-ob-level`.
+ */
+ private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest', 'x-php-ob-level'];
+
/**
* @param array $headers lower-case dashed names, values verbatim (D06)
*/
@@ -52,6 +60,33 @@ public static function fromServer(string $sapi, array $server): ?self
return new self($method, self::url($server), $headers);
}
+ /**
+ * The request object of a framework (Symfony's `Request`, which Laravel's extends), from its plain values, with the
+ * URL and the headers as the client sent them, like the SAPI reading of `fromServer()`:
+ * - the URL from the scheme `HTTPS` gives, the `Host` header and the raw request URI, through `url()`: Laravel's
+ * `fullUrl()` sorts the query and drops a trailing slash, and Symfony's `getUri()` sorts the query and refuses
+ * an invalid host. The `Host` header, not the server variable: a request that a worker builds from PSR-7 has
+ * only the header;
+ * - the header names as the header bag keeps them (lower case); the values of a repeated header joined with `, `,
+ * like a SAPI joins them, a null value as `''`; the headers that Symfony makes up left out: `php-auth-*` from the
+ * credentials (`ServerBag`), and `x-php-ob-level`, which `HttpKernel::handle()` adds to each request it handles.
+ *
+ * @param array> $headers the request's header bag (`HeaderBag::all()`)
+ * @param mixed $https the `HTTPS` server variable
+ * @param mixed $requestUri the `REQUEST_URI` server variable
+ */
+ public static function fromRequest(string $method, array $headers, mixed $https, ?string $host, mixed $requestUri): self
+ {
+ $values = [];
+ foreach ($headers as $name => $list) {
+ if (!in_array($name, self::MADE_UP_HEADERS, true)) {
+ $values[$name] = implode(', ', $list);
+ }
+ }
+
+ return new self($method, self::url(['HTTPS' => $https, 'HTTP_HOST' => $host, 'REQUEST_URI' => $requestUri]), $values);
+ }
+
/**
* The projection TypeScript v1 calls `requestInfo` (D34): `{headers, url?}`. `method` stays internal.
*/
@@ -72,7 +107,7 @@ public function toRequestInfo(): \stdClass
*
* @param array $server
*/
- public static function url(array $server): ?string
+ private static function url(array $server): ?string
{
$host = $server['HTTP_HOST'] ?? null;
if (!is_string($host) || $host === '') {
diff --git a/src/Internal/Engine/TrackedServer.php b/src/Internal/Engine/TrackedServer.php
index 35f5b90..2bc7bd3 100644
--- a/src/Internal/Engine/TrackedServer.php
+++ b/src/Internal/Engine/TrackedServer.php
@@ -26,12 +26,16 @@ final class TrackedServer
* @param string $projectId '' in telemetry-only mode
* @param \Closure(): array{0: ?string, 1: ?string} $serverInfo server name and version, read when an event is published
* @param Publisher $publisher the publisher of the client that tracked the server
+ * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework; null reads the
+ * SAPI globals. The laravel/mcp adapter reads the server's own
+ * transport instead (plan E1 header conflict 9)
*/
public function __construct(
public readonly string $projectId,
public readonly Options $options,
private readonly \Closure $serverInfo,
private readonly Publisher $publisher,
+ public readonly ?\Closure $httpContext = null,
) {
$this->injection = new InjectionState();
}
diff --git a/src/Internal/Support/BridgeConfig.php b/src/Internal/Support/BridgeConfig.php
index 37aafd6..ea0db95 100644
--- a/src/Internal/Support/BridgeConfig.php
+++ b/src/Internal/Support/BridgeConfig.php
@@ -7,8 +7,9 @@
/**
* The values of a framework bridge's configuration that every client of the process reads before the environment
* (plan E1 header conflict 12): with a cached configuration (`php artisan config:cache`), Laravel does not load the
- * `.env` file, so `Env::get()` cannot see what the configuration got from it. The bridge sets them when it boots. The
- * last call wins; they are process-wide, like the environment. `TestMode::configure()` does the same for the switch.
+ * `.env` file, so `Env::get()` cannot see what the configuration got from it. The bridge sets them when it boots
+ * (`fromBridge()`). The last call wins; they are process-wide, like the environment. `TestMode::configure()` does the
+ * same for the switch.
*
* @internal
*/
@@ -36,6 +37,29 @@ public static function configure(array $values, bool $diagnosticsOff): void
self::$diagnosticsOff = $diagnosticsOff;
}
+ /**
+ * Hands a framework bridge's `agentcat` configuration to every client of the process when the bridge boots (plan E1
+ * header conflict 12, final ruling I2): the switch, from the D44 words of `enabled` (unset or unknown: no switch,
+ * `AGENTCAT_ENABLED` decides), every name AgentCat reads before the environment, and the application's environment
+ * for diagnostics. A value that is no string is unset here. Nothing is logged: the bridge's tracker logs the bad
+ * values when it tracks a server.
+ *
+ * @param array $agentcat the bridge's `agentcat` configuration
+ * @param mixed $appEnvironment the application's environment: Laravel's `app.env`, Symfony's
+ * `kernel.environment`
+ */
+ public static function fromBridge(array $agentcat, mixed $appEnvironment): void
+ {
+ TestMode::configure(TestMode::word($agentcat['enabled'] ?? null));
+ self::configure([
+ 'AGENTCAT_PROJECT_ID' => self::string($agentcat['project_id'] ?? null),
+ 'AGENTCAT_API_URL' => self::string($agentcat['api_url'] ?? null),
+ 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => self::string($agentcat['diagnostics_endpoint'] ?? null),
+ 'AGENTCAT_DIAGNOSTICS_TOKEN' => self::string($agentcat['diagnostics_token'] ?? null),
+ 'APP_ENV' => self::string($appEnvironment),
+ ], ConfigOptions::diagnosticsOff($agentcat['disable_diagnostics'] ?? null));
+ }
+
/** The configured value of an environment name, else `Env::get()`. */
public static function env(string $name): ?string
{
@@ -48,6 +72,11 @@ public static function diagnosticsOff(): bool
return self::$diagnosticsOff;
}
+ private static function string(mixed $value): ?string
+ {
+ return is_string($value) ? $value : null;
+ }
+
/** Tests only: forgets the configured values. */
public static function reset(): void
{
diff --git a/src/Laravel/Internal/BridgeKeys.php b/src/Internal/Support/BridgeKeys.php
similarity index 52%
rename from src/Laravel/Internal/BridgeKeys.php
rename to src/Internal/Support/BridgeKeys.php
index 80f56a0..19b5403 100644
--- a/src/Laravel/Internal/BridgeKeys.php
+++ b/src/Internal/Support/BridgeKeys.php
@@ -2,20 +2,18 @@
declare(strict_types=1);
-namespace AgentCat\Laravel\Internal;
+namespace AgentCat\Internal\Support;
/**
- * The keys of config/agentcat.php that the bridge reads itself (not options), read with their types (Review Focus
- * 3): a value of a wrong type logs one line through `$warn`, and the default is used, so a typo never goes unseen. A
- * string `exclude` is a list of one class. Null is an unset value (`env()` of an unset variable): the default, no line.
+ * The keys of a framework bridge's `agentcat` configuration that the bridge reads itself (not options), read with their
+ * types (plan E1 Review Focus 3): a value of a wrong type logs one line through `$warn`, and the default is used, so a
+ * typo never goes unseen. Null is an unset value (`env()` of an unset variable, `%env(default::…)%` in Symfony): the
+ * default, no line. The lines name the key as the customer wrote it, `agentcat.`.
*
* @internal
*/
final readonly class BridgeKeys
{
- /** config/agentcat.php's `exclude`: the default of a value of a wrong type too. */
- public const array DEFAULT_EXCLUDE = ['Laravel\Boost\Mcp\Boost'];
-
private const string WRONG_TYPE = 'WARN: AgentCat option "agentcat.%s" must be %s, got %s; the default is used';
private const string WRONG_ENTRY = 'WARN: AgentCat option "agentcat.%s.%s" must be %s, got %s; the entry is ignored';
@@ -29,7 +27,7 @@ public function __construct(
private \Closure $warn,
) {}
- /** `project_id`, `log_channel`, `diagnostics_endpoint` or `diagnostics_token`: a string, or null. */
+ /** A string, or null: a project ID, a log channel, a service ID, the diagnostics endpoint or token. */
public function string(string $key): ?string
{
$value = $this->config[$key] ?? null;
@@ -42,35 +40,39 @@ public function string(string $key): ?string
}
/**
- * The server classes AgentCat never tracks.
+ * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one; a null entry is
+ * unset.
+ *
+ * @param list $default the value when the key is unset, and when it is of a wrong type
*
* @return list
*/
- public function exclude(): array
+ public function strings(string $key, array $default): array
{
- $value = $this->config['exclude'] ?? self::DEFAULT_EXCLUDE;
+ $value = $this->config[$key] ?? $default;
if (is_string($value)) {
return [$value];
}
if (!is_array($value)) {
- $this->wrongType('exclude', 'array or string', $value);
+ $this->wrongType($key, 'array or string', $value);
- return self::DEFAULT_EXCLUDE;
+ return $default;
}
- $classes = [];
- foreach ($value as $index => $class) {
- if (is_string($class)) {
- $classes[] = $class;
- } elseif ($class !== null) {
- ($this->warn)(sprintf(self::WRONG_ENTRY, 'exclude', $index, 'string', get_debug_type($class)));
+ $strings = [];
+ foreach ($value as $index => $entry) {
+ if (is_string($entry)) {
+ $strings[] = $entry;
+ } elseif ($entry !== null) {
+ ($this->warn)(sprintf(self::WRONG_ENTRY, $key, $index, 'string', get_debug_type($entry)));
}
}
- return $classes;
+ return $strings;
}
/**
- * Server class => project ID; a null project is the configured `project_id`.
+ * `projects`: a server (its class in Laravel, its name in Symfony) => project ID; a null project is the configured
+ * `project_id`.
*
* @return array
*/
@@ -83,11 +85,11 @@ public function projects(): array
return [];
}
$projects = [];
- foreach ($value as $class => $project) {
+ foreach ($value as $server => $project) {
if ($project === null || is_string($project)) {
- $projects[$class] = $project;
+ $projects[$server] = $project;
} else {
- ($this->warn)(sprintf(self::WRONG_ENTRY, 'projects', $class, 'string or null', get_debug_type($project)));
+ ($this->warn)(sprintf(self::WRONG_ENTRY, 'projects', $server, 'string or null', get_debug_type($project)));
}
}
diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php
index 74961d3..ff8700a 100644
--- a/src/Laravel/AgentCatServiceProvider.php
+++ b/src/Laravel/AgentCatServiceProvider.php
@@ -7,8 +7,6 @@
use AgentCat\AgentCat;
use AgentCat\Client;
use AgentCat\Internal\Support\BridgeConfig;
-use AgentCat\Internal\Support\ConfigOptions;
-use AgentCat\Internal\Support\TestMode;
use AgentCat\Laravel\Console\InstallCommand;
use AgentCat\Laravel\Internal\AfterRequest;
use AgentCat\Laravel\Internal\AnswerLength;
@@ -39,19 +37,11 @@ public function boot(): void
if ($this->app->runningInConsole()) {
$this->commands([InstallCommand::class]);
}
- // `agentcat.enabled` decides for every client, the shared one too, before AGENTCAT_ENABLED, which a cached
- // configuration hides (header conflict 12). Unset or unknown: no switch. A fake still records.
+ // `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before
+ // the environment, which a cached configuration hides (header conflict 12). A fake still records.
$config = $this->app->make('config');
- TestMode::configure(TestMode::word($config->get('agentcat.enabled')));
- // The same for every other name AgentCat reads, and diagnostics get the application's environment. The
- // tracker logs the bad values; nothing is logged here.
- BridgeConfig::configure([
- 'AGENTCAT_PROJECT_ID' => $this->string($config->get('agentcat.project_id')),
- 'AGENTCAT_API_URL' => $this->string($config->get('agentcat.api_url')),
- 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->string($config->get('agentcat.diagnostics_endpoint')),
- 'AGENTCAT_DIAGNOSTICS_TOKEN' => $this->string($config->get('agentcat.diagnostics_token')),
- 'APP_ENV' => $this->string($config->get('app.env')),
- ], ConfigOptions::diagnosticsOff($config->get('agentcat.disable_diagnostics')));
+ $agentcat = $config->get('agentcat');
+ BridgeConfig::fromBridge(is_array($agentcat) ? $agentcat : [], $config->get('app.env'));
if (class_exists(Server::class)) {
$tracker = $this->app->make(ServerTracker::class); // resolved once per worker, never tracks here
$this->app->afterResolving(Server::class, static function (Server $server, Container $app) use ($tracker): void {
@@ -61,9 +51,4 @@ public function boot(): void
}
AfterRequest::register($this->app); // callbacks only: nothing runs at boot
}
-
- private function string(mixed $value): ?string
- {
- return is_string($value) ? $value : null;
- }
}
diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php
index 14558cb..053c438 100644
--- a/src/Laravel/Internal/AnswerLength.php
+++ b/src/Laravel/Internal/AnswerLength.php
@@ -4,6 +4,7 @@
namespace AgentCat\Laravel\Internal;
+use AgentCat\Internal\Delivery\ContentLength;
use AgentCat\Internal\Delivery\FinishRequest;
use AgentCat\LaravelMcp\LaravelMcpAdapter;
use Illuminate\Contracts\Foundation\Application;
@@ -14,10 +15,8 @@
* D59: laravel/mcp sends its answers without `Content-Length`. On a SAPI that sends a response only when the script
* ends (mod_php, php-cgi, `php -S`: `FinishRequest::responseEndsWithTheScript()`), the client of such an answer then
* waits for AgentCat's hooks and delivery. So there, in Laravel's `RequestHandled`, the plain answer of a tracked
- * server gets its length, and with D57 the client has all of it before the shutdown function delivers. A streamed
- * answer, one with a `Content-Length` or `Transfer-Encoding` of its own, and one after stray output (headers that went
- * out already, or bytes waiting in an output buffer, which would go out first and make the length short) stay as
- * laravel/mcp sends them. PHP-FPM, LiteSpeed, FrankenPHP and Octane get no listener.
+ * server gets its length, by `ContentLength`'s rule, and with D57 the client has all of it before the shutdown function
+ * delivers. PHP-FPM, LiteSpeed, FrankenPHP and Octane get no listener.
*
* The tracker marks the request that a tracked server answers with a request attribute: the mark lives and dies with
* the request, so a worker that serves many requests carries none of it to the next one.
@@ -29,13 +28,12 @@
/** The request attribute: a laravel/mcp server that AgentCat tracks answers this request. */
public const string TRACKED = 'agentcat.tracked_mcp_server';
- /** @var \Closure(): bool */
- private \Closure $headersSent;
+ private ContentLength $length;
/** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */
public function __construct(?\Closure $headersSent = null)
{
- $this->headersSent = $headersSent ?? static fn(): bool => headers_sent();
+ $this->length = new ContentLength($headersSent);
}
/**
@@ -60,32 +58,16 @@ public static function mark(Server $server): void
public function handle(RequestHandled $handled): void
{
try {
- $headers = $handled->response->headers;
- if ($handled->request->attributes->get(self::TRACKED) !== true || $headers->has('Content-Length') || $headers->has('Transfer-Encoding')) {
+ if ($handled->request->attributes->get(self::TRACKED) !== true) {
return;
}
- if (($this->headersSent)() || $this->outputWaits()) {
- return; // stray output goes out before the answer: a length of the answer alone would cut its end
- }
- $content = $handled->response->getContent(); // false for a streamed answer (StreamedResponse)
- if (is_string($content)) {
- $headers->set('Content-Length', (string) strlen($content));
+ $response = $handled->response;
+ $length = $this->length->of($handled->request->getMethod(), $response->getStatusCode(), $response->headers->all(), $response->getContent());
+ if ($length !== null) {
+ $response->headers->set('Content-Length', $length);
}
} catch (\Throwable) {
// the answer goes out as it is
}
}
-
- /** Some output buffer level holds bytes, which PHP sends before the answer. */
- private function outputWaits(): bool
- {
- foreach (ob_get_status(true) as $status) {
- $used = is_array($status) ? $status['buffer_used'] ?? null : null;
- if (is_int($used) && $used > 0) {
- return true;
- }
- }
-
- return false;
- }
}
diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php
index f888065..c012c9d 100644
--- a/src/Laravel/Internal/ServerTracker.php
+++ b/src/Laravel/Internal/ServerTracker.php
@@ -11,6 +11,7 @@
use AgentCat\Internal\Engine\TrackedServer;
use AgentCat\Internal\Engine\TrackedServers;
use AgentCat\Internal\Logging\Log;
+use AgentCat\Internal\Support\BridgeKeys;
use AgentCat\Internal\Support\ConfigOptions;
use AgentCat\Internal\Support\EnabledGate;
use AgentCat\Internal\Support\ErrorText;
@@ -35,6 +36,9 @@ final class ServerTracker
/** @var list The keys of config/agentcat.php that the bridge reads itself; the others are options. */
public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude', 'projects', 'log_channel', 'diagnostics_endpoint', 'diagnostics_token'];
+ /** config/agentcat.php's `exclude`: the default of a value of a wrong type too. */
+ public const array DEFAULT_EXCLUDE = ['Laravel\Boost\Mcp\Boost'];
+
private ?Client $client = null;
private ?Options $options = null;
@@ -51,7 +55,7 @@ public function track(Server $server, Container $app): void
$keys = new BridgeKeys($config, static function (string $line) use ($bad): void {
$bad->append($line);
});
- if ($this->matches($server, $keys->exclude()) !== null) {
+ if ($this->matches($server, $keys->strings('exclude', self::DEFAULT_EXCLUDE)) !== null) {
return; // an excluded server touches neither the log nor diagnostics
}
$this->adoptLogger($app, $keys->string('log_channel'));
@@ -68,6 +72,7 @@ public function track(Server $server, Container $app): void
AnswerLength::mark($server); // D59; a server that AgentCat did not track (off, or failed) keeps its answer
}
} catch (\Throwable $e) {
+ TrackedServers::markIncomplete(); // as Client::track(): publishCustomEvent() for the server then logs (D32)
Log::write('Warning: Failed to track server - ' . ErrorText::of($e));
}
}
diff --git a/src/LaravelMcp/Internal/LaravelHttpContext.php b/src/LaravelMcp/Internal/LaravelHttpContext.php
index 5ea1a65..e5768b9 100644
--- a/src/LaravelMcp/Internal/LaravelHttpContext.php
+++ b/src/LaravelMcp/Internal/LaravelHttpContext.php
@@ -15,31 +15,9 @@
*/
final class LaravelHttpContext
{
- /**
- * The headers that Symfony's `ServerBag` makes up from the `Authorization` header or the SAPI's `PHP_AUTH_*`
- * variables. No client sends them, and two hold the credentials in clear text: the `Authorization` header stays
- * (D06).
- */
- private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest'];
-
- /**
- * The URL and the headers as the client sent them, like the SAPI reading of `HttpContext::fromServer()`:
- * - the URL from the scheme, the `Host` header and the raw request URI (Laravel's `fullUrl()` sorts the query
- * and drops a trailing slash);
- * - header names in lower case, as Symfony's `HeaderBag` keeps them; the values of a repeated header joined
- * with `, `, like a SAPI joins them.
- */
+ /** The URL and the headers as the client sent them, by the rules of `HttpContext::fromRequest()`. */
public static function of(Request $request): HttpContext
{
- $headers = [];
- foreach ($request->headers->all() as $name => $values) {
- if (!in_array($name, self::MADE_UP_HEADERS, true)) {
- $headers[$name] = implode(', ', $values); // a null value is ''
- }
- }
- // The Host header, not the server variable: a request that a worker builds from PSR-7 has only the header.
- $url = HttpContext::url(['HTTPS' => $request->server->get('HTTPS'), 'HTTP_HOST' => $request->headers->get('host'), 'REQUEST_URI' => $request->server->get('REQUEST_URI')]);
-
- return new HttpContext($request->getMethod(), $url, $headers);
+ return HttpContext::fromRequest($request->getMethod(), $request->headers->all(), $request->server->get('HTTPS'), $request->headers->get('host'), $request->server->get('REQUEST_URI'));
}
}
diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php
index fb66bd1..08d7862 100644
--- a/src/McpSdk/McpSdkAdapter.php
+++ b/src/McpSdk/McpSdkAdapter.php
@@ -184,7 +184,7 @@ public static function install(Builder $builder, TrackedServer $server, Publishe
$builder->setReferenceHandler(new CapturingReferenceHandler($inner, $stash));
$list = new ListToolsInterceptor($server, $delegates, $publisher);
$builder->addRequestHandler($list);
- $builder->addRequestHandler(new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, $stash, new RequestReader()));
+ $builder->addRequestHandler(new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, $stash, new RequestReader($server->httpContext)));
}
/**
diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php
new file mode 100644
index 0000000..0db02a2
--- /dev/null
+++ b/src/Symfony/AgentCatBundle.php
@@ -0,0 +1,165 @@
+ true, 'enable_tracing' => true, 'enable_tool_call_context' => true];
+
+ /** The configuration's root key is `agentcat` (D55), not the `agent_cat` that Symfony derives from the class name. */
+ public function __construct()
+ {
+ $this->extensionAlias = 'agentcat'; // set here: #[\Override] on a redeclared property needs PHP 8.5
+ }
+
+ #[\Override]
+ public function configure(DefinitionConfigurator $definition): void
+ {
+ $children = $this->parent($definition->rootNode())->children();
+ $children->scalarNode('enabled')->defaultValue('%env(default::AGENTCAT_ENABLED)%')->info('On or off for every MCP server and every AgentCat call: true or false. Unset: on, except while your tests run.')->end();
+ $children->scalarNode('project_id')->defaultValue('%env(default::AGENTCAT_PROJECT_ID)%')->info('Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run.')->end();
+ $children->variableNode('exclude_servers')->defaultValue([])->info('Servers AgentCat never tracks: names of mcp.servers entries; a string is one name. Names written here are read when the container compiles, names from environment variables when each server is built.')->end();
+ $projects = $children->arrayNode('projects')->info('Another project for some servers: mcp.servers name => project ID. A null project is project_id.')->useAttributeAsKey('server')->defaultValue([]);
+ $projects->scalarPrototype()->end(); // symfony/config 7.3 types the prototype's end() as any parent
+ $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp, written here, not in an environment variable. Unset: their own file, agentcat.log in the log directory.')->end();
+ $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL, the TypeScript SDK\'s name, is read too.')->end();
+ foreach (self::BOOLS as $key => $default) {
+ $children->scalarNode($key)->defaultValue($default)->end();
+ }
+ $children->scalarNode('custom_context_description')->defaultNull()->end();
+ $children->scalarNode('enable_agent_tracking')->defaultFalse()->end();
+ foreach (self::HOOKS as $hook) {
+ $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, written here, not in an environment variable. The service is resolved when the hook runs.')->end();
+ }
+ $children->variableNode('exporters')->defaultNull()->info('Your own telemetry tools: name => {type: otlp|datadog|sentry|posthog, …}. A ~ entry is skipped.')->end();
+ $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS, the TypeScript SDK\'s name, is read too.')->end();
+ $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end();
+ $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end();
+ $children->scalarNode('disable_signal_handlers')->defaultFalse()->end();
+ }
+
+ /** @param array $config */
+ #[\Override]
+ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ // The names written in the configuration: the pass removes those servers when the container compiles. The
+ // tracker reads the configuration's names again when it builds a server, from environment variables too.
+ $excluded = $config['exclude_servers'] ?? [];
+ $names = [];
+ foreach (is_array($excluded) ? $excluded : [$excluded] as $name) {
+ if (is_string($name) && !$this->variable($builder, $name)) {
+ $names[] = $name;
+ }
+ }
+ $builder->setParameter('agentcat.exclude_servers', $names);
+ // The services the configuration names by ID: the hooks and the logger. The container knows a service by the ID
+ // written here; an ID from an environment variable is known only at runtime, when the tracker says so.
+ $services = [];
+ $variables = [];
+ foreach ([...self::HOOKS, 'logger'] as $key) {
+ $id = $config[$key] ?? null;
+ if (is_string($id) && $this->variable($builder, $id)) {
+ $variables[] = $key;
+ } elseif (is_string($id) && $id !== '') {
+ // A missing service is left out of the locator, and the container compiles: the hook guard logs it when
+ // the hook runs, the tracker when it chooses the logger (D32).
+ $services[$id] = new Reference($id, ContainerInterface::IGNORE_ON_INVALID_REFERENCE);
+ }
+ }
+ // The configuration as a service: its environment variables resolve when AgentCat reads it, never at compile.
+ $builder->register('agentcat.config', \ArrayObject::class)->setArguments([$config])->setPublic(true);
+ $builder->register('agentcat.hooks', ServiceLocator::class)->setArguments([$services])->addTag('container.service_locator'); // and the logger
+ $builder->register('agentcat.server_tracker', ServerTracker::class)->setArguments([
+ new ServiceClosureArgument(new Reference('agentcat.config')),
+ new Reference('agentcat.hooks'),
+ new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE),
+ '%kernel.logs_dir%/agentcat.log',
+ '%agentcat.servers%', // every server name, which TrackBuildersPass writes
+ $variables,
+ ]);
+ // D58: the wait for delivery after each response, last of kernel.terminate (header conflict 7).
+ $builder->register('agentcat.terminate_listener', TerminateListener::class)
+ ->addTag('kernel.event_listener', ['event' => KernelEvents::TERMINATE, 'priority' => TerminateListener::PRIORITY]);
+ // D62: boot() makes it a kernel.response listener where responses end with the script.
+ $builder->register(AnswerLength::SERVICE, AnswerLength::class)->setArguments([new Reference('agentcat.server_tracker')])->setPublic(true);
+ }
+
+ /**
+ * The tree's root, of the default type array. symfony/config 7.3 types `rootNode()` as any node, and 7.4 as a
+ * generic array node, so the root is narrowed here, from a native type.
+ */
+ private function parent(NodeDefinition $root): ParentNodeDefinitionInterface
+ {
+ if (!$root instanceof ParentNodeDefinitionInterface) {
+ throw new \LogicException('The agentcat configuration tree has no array root.');
+ }
+
+ return $root;
+ }
+
+ /** Whether a value of the configuration holds an environment variable, which resolves at runtime only. */
+ private function variable(ContainerBuilder $builder, string $value): bool
+ {
+ $variables = [];
+ $builder->resolveEnvPlaceholders($value, null, $variables);
+
+ return $variables !== [];
+ }
+
+ #[\Override]
+ public function build(ContainerBuilder $container): void
+ {
+ $container->addCompilerPass(new TrackBuildersPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100);
+ }
+
+ /**
+ * `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before the
+ * environment (plan E1 header conflict 12): a literal, a parameter or a secret of the configuration is not in the
+ * environment. Diagnostics get the kernel's environment. A fake still records. The tracker logs the bad values;
+ * nothing is logged here, and boot tracks nothing. Where this process's SAPI sends a response only when the script
+ * ends, the answers of tracked servers get their length (D62).
+ */
+ #[\Override]
+ public function boot(): void
+ {
+ if (!$this->container instanceof ContainerInterface) {
+ return; // Symfony gives a bundle its container before it boots it
+ }
+ $config = [];
+ try {
+ $service = $this->container->get('agentcat.config');
+ $config = $service instanceof \ArrayObject ? $service->getArrayCopy() : [];
+ } catch (\Throwable) {
+ // A variable without a default is not set: the tracker logs it when it tracks a server (D32).
+ }
+ BridgeConfig::fromBridge($config, $this->container->getParameter('kernel.environment'));
+ AnswerLength::register($this->container);
+ }
+}
diff --git a/src/Symfony/Internal/AnswerLength.php b/src/Symfony/Internal/AnswerLength.php
new file mode 100644
index 0000000..6b59667
--- /dev/null
+++ b/src/Symfony/Internal/AnswerLength.php
@@ -0,0 +1,88 @@
+.controller::handle`; the tracker
+ * knows which servers it tracked. A server that AgentCat did not track (off, excluded, or failed) keeps its answer.
+ *
+ * @internal
+ */
+final readonly class AnswerLength
+{
+ /** The listener's service, which `AgentCatBundle::loadExtension()` defines. */
+ public const string SERVICE = 'agentcat.answer_length';
+
+ /** Last of `kernel.response`, after Symfony's own listeners (`Response::prepare()` at 0, the session's at -1000). */
+ public const int PRIORITY = -2048;
+
+ /** The controller of a symfony/mcp-bundle server's route: the server's name in it. */
+ private const string CONTROLLER = '/^mcp\.server\.(.+)\.controller::handle$/s';
+
+ private ContentLength $length;
+
+ /** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */
+ public function __construct(
+ private ServerTracker $tracker,
+ ?\Closure $headersSent = null,
+ ) {
+ $this->length = new ContentLength($headersSent);
+ }
+
+ /**
+ * Listens for `kernel.response` when responses end with the script: at boot, where this process's SAPI is known (a
+ * container compiled by `cache:warmup` on the command line serves mod_php too). Nothing runs here, and nothing
+ * throws (D32).
+ *
+ * @param bool|null $responseEndsWithTheScript null: this process's SAPI decides
+ */
+ public static function register(ContainerInterface $container, ?bool $responseEndsWithTheScript = null): void
+ {
+ try {
+ if (!($responseEndsWithTheScript ?? FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, static fn(string $name): bool => function_exists($name)))) {
+ return;
+ }
+ $dispatcher = $container->get('event_dispatcher');
+ $listener = $container->get(self::SERVICE);
+ if ($dispatcher instanceof EventDispatcherInterface && $listener instanceof self) {
+ $dispatcher->addListener(KernelEvents::RESPONSE, $listener, self::PRIORITY);
+ }
+ } catch (\Throwable) {
+ // no listener: the answers go as symfony/mcp-bundle sends them
+ }
+ }
+
+ /** Gives the plain answer of a tracked server's route its length in bytes. Never throws (D32). */
+ public function __invoke(ResponseEvent $event): void
+ {
+ try {
+ $controller = $event->getRequest()->attributes->get('_controller');
+ if (!$event->isMainRequest() || !is_string($controller) || preg_match(self::CONTROLLER, $controller, $server) !== 1 || !$this->tracker->tracked($server[1])) {
+ return;
+ }
+ $response = $event->getResponse();
+ $length = $this->length->of($event->getRequest()->getMethod(), $response->getStatusCode(), $response->headers->all(), $response->getContent());
+ if ($length !== null) {
+ $response->headers->set('Content-Length', $length);
+ }
+ } catch (\Throwable) {
+ // the answer goes out as it is
+ }
+ }
+}
diff --git a/src/Symfony/Internal/BuilderConfigurator.php b/src/Symfony/Internal/BuilderConfigurator.php
new file mode 100644
index 0000000..1938da6
--- /dev/null
+++ b/src/Symfony/Internal/BuilderConfigurator.php
@@ -0,0 +1,45 @@
+ $requestHandlers the first argument of each `addRequestHandlers` call the pass removed: the
+ * tagged request handlers
+ * @param mixed $previous the configurator the builder had, a callable once the container resolves it
+ */
+ public function __construct(
+ private string $server,
+ private ServerTracker $tracker,
+ private array $requestHandlers,
+ private mixed $previous = null,
+ ) {}
+
+ public function configure(Builder $builder): void
+ {
+ if (is_callable($this->previous)) {
+ ($this->previous)($builder);
+ }
+ $this->tracker->track($builder, $this->server); // never throws: the customer's handlers are added in any case
+ foreach ($this->requestHandlers as $handlers) {
+ if (is_iterable($handlers)) {
+ /** @var iterable> $handlers the services tagged mcp.request_handler */
+ $builder->addRequestHandlers($handlers);
+ }
+ }
+ }
+}
diff --git a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php
new file mode 100644
index 0000000..62b121f
--- /dev/null
+++ b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php
@@ -0,0 +1,74 @@
+hasParameter('agentcat.exclude_servers') ? $container->getParameter('agentcat.exclude_servers') : [];
+ $excluded = is_array($excluded) ? $excluded : [];
+ $builders = [];
+ foreach ($container->findTaggedServiceIds('mcp.server.builder') as $id => $tags) {
+ $attributes = $tags[0] ?? null;
+ $server = is_array($attributes) ? ($attributes['server'] ?? null) : null;
+ if (is_string($server)) {
+ $builders[] = [$id, $server];
+ }
+ }
+ $servers = array_column($builders, 1);
+ $container->setParameter('agentcat.servers', $servers);
+ foreach ($builders as [$id, $server]) {
+ if (!ServerNames::excluded($server, $excluded, $servers)) {
+ $this->track($container, $id, $server);
+ }
+ }
+ }
+
+ private function track(ContainerBuilder $container, string $id, string $server): void
+ {
+ $builder = $container->getDefinition($id);
+ $handlers = [];
+ $calls = [];
+ foreach ($builder->getMethodCalls() as $call) {
+ $method = is_array($call) ? ($call[0] ?? null) : null;
+ $arguments = is_array($call) ? ($call[1] ?? null) : null;
+ if (is_string($method) && strtolower($method) === 'addrequesthandlers' && is_array($arguments) && count($arguments) === 1 && ($arguments[0] ?? null) instanceof TaggedIteratorArgument) {
+ $handlers[] = $arguments[0]; // added after AgentCat's own handlers (header conflict 1)
+ } else {
+ $calls[] = $call; // a call of another shape too: its handlers then come first (header conflict 3)
+ }
+ }
+ $builder->setMethodCalls($calls);
+ $configurator = 'agentcat.builder_configurator.' . $server;
+ $container->register($configurator, BuilderConfigurator::class)
+ ->setArguments([$server, new Reference('agentcat.server_tracker'), $handlers, $builder->getConfigurator()]);
+ $builder->setConfigurator([new Reference($configurator), 'configure']);
+ }
+}
diff --git a/src/Symfony/Internal/LogFile.php b/src/Symfony/Internal/LogFile.php
new file mode 100644
index 0000000..91b4602
--- /dev/null
+++ b/src/Symfony/Internal/LogFile.php
@@ -0,0 +1,36 @@
+file = new FileLogger($path);
+ }
+
+ /** @param mixed[] $context */
+ #[\Override]
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ $directory = dirname($this->path);
+ if (!is_dir($directory)) {
+ @mkdir($directory, recursive: true); // 0777 less the umask, as Symfony makes its directories
+ }
+ $this->file->log($level, $message, $context);
+ }
+}
diff --git a/src/Symfony/Internal/RequestStackContext.php b/src/Symfony/Internal/RequestStackContext.php
new file mode 100644
index 0000000..a095d43
--- /dev/null
+++ b/src/Symfony/Internal/RequestStackContext.php
@@ -0,0 +1,44 @@
+getMainRequest();
+
+ return $request instanceof Request ? self::read($request) : null;
+ };
+ }
+
+ private static function read(Request $request): ?HttpContext
+ {
+ try {
+ return HttpContext::fromRequest($request->getMethod(), $request->headers->all(), $request->server->get('HTTPS'), $request->headers->get('host'), $request->server->get('REQUEST_URI'));
+ } catch (\Throwable) {
+ return null; // the client controls the request: this is no drift
+ }
+ }
+}
diff --git a/src/Symfony/Internal/ServerNames.php b/src/Symfony/Internal/ServerNames.php
new file mode 100644
index 0000000..32cec98
--- /dev/null
+++ b/src/Symfony/Internal/ServerNames.php
@@ -0,0 +1,44 @@
+ $names the names in `exclude_servers`; an entry that is no string names nothing
+ * @param list $servers the name of every server, as symfony/mcp-bundle tags its builder
+ */
+ public static function excluded(string $server, array $names, array $servers): bool
+ {
+ foreach ($names as $name) {
+ if (is_string($name) && self::server($name, $servers) === $server) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * The server a name selects: the name itself when it is a server, or when symfony/config keeps it as written.
+ *
+ * @param list $servers
+ */
+ private static function server(string $name, array $servers): string
+ {
+ if (in_array($name, $servers, true) || str_contains($name, '_')) {
+ return $name;
+ }
+
+ return str_replace('-', '_', $name);
+ }
+}
diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php
new file mode 100644
index 0000000..bfa918d
--- /dev/null
+++ b/src/Symfony/Internal/ServerTracker.php
@@ -0,0 +1,195 @@
+ The keys of the agentcat configuration that the bundle reads itself; the others are options. */
+ public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude_servers', 'projects', 'logger', 'diagnostics_endpoint', 'diagnostics_token'];
+
+ private const string NO_LOGGER = 'WARN: AgentCat option "agentcat.logger" must be the ID of a PSR-3 logger service, got "%s"; the default is used';
+
+ private const string LITERAL_ID = 'WARN: AgentCat option "agentcat.%s" must be a literal service ID, not an environment variable; the default is used';
+
+ private ?Client $client = null;
+
+ private ?Options $options = null;
+
+ /**
+ * The bundle's keys, read once, and the configuration of the options.
+ *
+ * @var array{excluded: list, project: ?string, projects: array, logger: ?string, options: array}|null
+ */
+ private ?array $keys = null;
+
+ /** @var array the lines of bad values, written at the first server that AgentCat tracks */
+ private array $lines = [];
+
+ /** @var array the names of the servers that AgentCat tracks (D62) */
+ private array $tracked = [];
+
+ /**
+ * @param \Closure(): mixed $config the processed agentcat configuration, an `\ArrayObject`, read when called
+ * @param ContainerInterface $services the services the configuration names, by ID: the hooks and the logger (a
+ * missing one is left out)
+ * @param string $logFile the file of AgentCat's lines without a `logger` (D61)
+ * @param list $servers the name of every server (`agentcat.servers`), for the names of
+ * `exclude_servers`
+ * @param list $variables the keys that name a service through an environment variable: the
+ * container knows a service by the ID written in the configuration only
+ */
+ public function __construct(
+ private readonly \Closure $config,
+ private readonly ContainerInterface $services,
+ private readonly ?RequestStack $requests,
+ private readonly string $logFile,
+ private readonly array $servers = [],
+ private readonly array $variables = [],
+ ) {}
+
+ public function track(Builder $builder, string $server): void
+ {
+ try {
+ $config = ($this->config)();
+ $config = $config instanceof \ArrayObject ? $config->getArrayCopy() : [];
+ $keys = $this->keys;
+ if ($keys === null) {
+ [$keys, $this->lines] = $this->keys($config);
+ $this->keys = $keys;
+ }
+ if (ServerNames::excluded($server, $keys['excluded'], $this->servers)) {
+ return; // named through an environment variable: it touches neither the log nor diagnostics
+ }
+ $this->adoptLogger($keys['logger']);
+ foreach ($this->lines as $line) {
+ Log::write($line);
+ }
+ $this->lines = [];
+ $client = AgentCat::client();
+ $context = $this->requests instanceof RequestStack ? RequestStackContext::of($this->requests) : null;
+ // A fake records every server (header conflict 6), with the request of each call, as production sends it
+ // (D41); the shared client is never replaced.
+ if (!FakeLog::of($client) instanceof FakeLog) {
+ $client = $this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null), httpContext: $context));
+ } elseif ($context instanceof \Closure) {
+ $client = $client->withHttpContext($context);
+ }
+ $this->options ??= ConfigOptions::options($keys['options'], self::BRIDGE_KEYS, $this->services->get(...));
+ // A null entry (`%env(default::…)%` of an unset variable) is the configured project; without one, the
+ // client reads AGENTCAT_PROJECT_ID (D36).
+ $client->track($builder, $keys['projects'][$server] ?? $keys['project'], $this->options);
+ if (TrackedServers::get($builder) instanceof TrackedServer) {
+ $this->tracked[$server] = true; // a server that AgentCat did not track (off, or failed) keeps its answers
+ }
+ } catch (\Throwable $e) {
+ TrackedServers::markIncomplete(); // as Client::track(): publishCustomEvent() for the server then logs (D32)
+ $this->adoptLogger(null);
+ Log::write('Warning: Failed to track server - ' . ErrorText::of($e));
+ }
+ }
+
+ /** Whether AgentCat tracks the server of this name, in this container (D62: its answers get their length). */
+ public function tracked(string $server): bool
+ {
+ return $this->tracked[$server] ?? false;
+ }
+
+ /**
+ * The bundle's own keys, with their types, the configuration of the options, and the line of each bad value. A
+ * service ID from an environment variable is unset, with a line: the locator holds the IDs written in the
+ * configuration only.
+ *
+ * @param array $config
+ *
+ * @return array{array{excluded: list, project: ?string, projects: array, logger: ?string, options: array}, array}
+ */
+ private function keys(array $config): array
+ {
+ /** @var \ArrayObject $lines */
+ $lines = new \ArrayObject();
+ foreach ($this->variables as $key) {
+ if (($config[$key] ?? '') !== '') {
+ $lines->append(sprintf(self::LITERAL_ID, $key));
+ }
+ $config[$key] = null;
+ }
+ $read = new BridgeKeys($config, static function (string $line) use ($lines): void {
+ $lines->append($line);
+ });
+ $keys = [
+ 'excluded' => $read->strings('exclude_servers', []),
+ 'project' => $read->string('project_id'),
+ 'projects' => $read->projects(),
+ 'logger' => $read->string('logger'),
+ 'options' => $config,
+ ];
+ $read->string('diagnostics_endpoint'); // read at boot (BridgeConfig); here for the line of a bad value
+ $read->string('diagnostics_token');
+
+ return [$keys, $lines->getArrayCopy()];
+ }
+
+ /**
+ * D61: AgentCat's lines go to the configured logger service, else to their own file,
+ * `%kernel.logs_dir%/agentcat.log` (TypeScript writes `~/mcpcat.log`), not to the Builder's `mcp` logger (D30, D38):
+ * under PHP-FPM every request builds the container and tracks the Builder again, so the setup lines repeat per
+ * request, and they stay out of the application's log and its error trackers. The first adopted logger of the
+ * process wins, and a fake's diversion keeps priority; after a fake, the next server routes the lines again.
+ */
+ private function adoptLogger(?string $id): void
+ {
+ if (Log::hasLogger()) {
+ return;
+ }
+ $named = $id !== null && $id !== '';
+ $logger = null;
+ if ($named) {
+ try {
+ $logger = $this->services->get($id);
+ } catch (\Throwable) {
+ // not a service: the line below says so
+ }
+ }
+ if ($logger instanceof LoggerInterface) {
+ Log::adopt($logger);
+
+ return;
+ }
+ Log::adopt(new LogFile($this->logFile)); // its directory is made at its first line
+ if ($named) {
+ Log::write(sprintf(self::NO_LOGGER, $id));
+ }
+ }
+}
diff --git a/src/Symfony/Internal/TerminateListener.php b/src/Symfony/Internal/TerminateListener.php
new file mode 100644
index 0000000..c06c901
--- /dev/null
+++ b/src/Symfony/Internal/TerminateListener.php
@@ -0,0 +1,30 @@
+flush(RequestEndWait::seconds(console: false)); // kernel.terminate never runs for a command
+ } catch (\Throwable) {
+ // delivery never breaks the kernel's terminate phase
+ }
+ }
+}
diff --git a/tests/Architecture/LayeringTest.php b/tests/Architecture/LayeringTest.php
index f44b791..f1ad014 100644
--- a/tests/Architecture/LayeringTest.php
+++ b/tests/Architecture/LayeringTest.php
@@ -31,10 +31,10 @@ public function onlyTheAdapterDependsOnMcpSdk(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('AgentCat'))
- ->excluding(Selector::inNamespace('AgentCat\McpSdk'), Selector::inNamespace('AgentCat\Tests'))
+ ->excluding(Selector::inNamespace('AgentCat\McpSdk'), Selector::inNamespace('AgentCat\Symfony'), Selector::inNamespace('AgentCat\Tests'))
->shouldNot()->dependOn()
->classes(Selector::inNamespace('Mcp'))
- ->because('mcp/sdk is optional: only AgentCat\McpSdk may reference it (01 AC-90)');
+ ->because('mcp/sdk is optional: only AgentCat\McpSdk and the Symfony bundle, which configures Mcp\Server\Builder services, may reference it (01 AC-90)');
}
#[TestRule]
@@ -58,6 +58,34 @@ public function theLaravelAdapterNeedsNoMcpSdk(): Rule
->because('a Laravel application without mcp/sdk must load every Laravel class');
}
+ #[TestRule]
+ public function onlyTheSymfonyBundleDependsOnSymfonyFrameworkParts(): Rule
+ {
+ return PHPat::rule()
+ ->classes(Selector::inNamespace('AgentCat'))
+ ->excluding(Selector::inNamespace('AgentCat\Symfony'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Tests'))
+ ->shouldNot()->dependOn()
+ ->classes(
+ Selector::inNamespace('Symfony\Component\HttpKernel'),
+ Selector::inNamespace('Symfony\Component\DependencyInjection'),
+ Selector::inNamespace('Symfony\Component\Config'),
+ Selector::inNamespace('Symfony\Component\HttpFoundation'),
+ Selector::inNamespace('Symfony\Component\EventDispatcher'),
+ Selector::inNamespace('Symfony\Bundle'),
+ )
+ ->because('Symfony is optional (01 AC-90)');
+ }
+
+ #[TestRule]
+ public function theSymfonyBundleNamesNoBundleClass(): Rule
+ {
+ return PHPat::rule()
+ ->classes(Selector::inNamespace('AgentCat\Symfony'))
+ ->shouldNot()->dependOn()
+ ->classes(Selector::inNamespace('Symfony\AI'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\McpSdk'))
+ ->because('the bundle reads symfony/mcp-bundle service IDs and tags only, so it loads without the bundle, and reaches the mcp/sdk adapter only through Internal\Engine\Libraries');
+ }
+
#[TestRule]
public function theMcpSdkAdapterNeedsNoLaravel(): Rule
{
diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php
index fd359e0..b152414 100644
--- a/tests/Contract/ManifestTest.php
+++ b/tests/Contract/ManifestTest.php
@@ -31,6 +31,10 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void
'Mcp\Server\Builder::$parts',
'Mcp\Server\Builder::$referenceHandler',
'Mcp\Server\Protocol::$requestHandlers',
+ 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)',
+ 'symfony/mcp-bundle: builder without a configurator',
+ 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle',
+ 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }',
],
PrivateMemberManifest::members(),
);
@@ -74,5 +78,6 @@ public function testNoMcpLibraryIsARuntimeRequirement(): void
$this->assertSame('<0.8.1', $composer->conflict->{'mcp/sdk'});
$this->assertObjectHasProperty('laravel/mcp', $composer->suggest);
$this->assertSame('<1.0', $composer->conflict->{'laravel/mcp'});
+ $this->assertObjectHasProperty('symfony/mcp-bundle', $composer->suggest);
}
}
diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php
index 02c4605..464974a 100644
--- a/tests/Contract/PrivateMemberManifest.php
+++ b/tests/Contract/PrivateMemberManifest.php
@@ -12,7 +12,8 @@
/**
* The manifest of library internals AgentCat depends on, for Plan F's snapshot diff over `members()`: every
- * #[TouchesPrivateMember] in src//Internal/Access, and every library class AgentCat extends.
+ * #[TouchesPrivateMember] in src//Internal/Access, every library class AgentCat extends, and the
+ * symfony/mcp-bundle service definitions AgentCat's bundle reads at compile time.
*/
final class PrivateMemberManifest
{
@@ -27,6 +28,18 @@ final class PrivateMemberManifest
ListToolsResult::class => '__construct(array $tools, ?string $nextCursor = null)',
];
+ /**
+ * The symfony/mcp-bundle service IDs, tags and method calls that AgentCat's bundle finds each server builder by, and
+ * the route controller it finds each server's answers by, as strings: the bundle never names a class of
+ * symfony/mcp-bundle. `tests/Contract/Symfony` checks each one.
+ */
+ public const array BUNDLE_SERVICES = [
+ 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)',
+ 'symfony/mcp-bundle: builder without a configurator',
+ 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle',
+ 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }',
+ ];
+
/** @return list */
public static function accessorClasses(): array
{
@@ -83,8 +96,8 @@ public static function extendedClasses(): array
}
/**
- * The private members AgentCat reads (`Class::$member`) and the methods of the classes it extends that it depends
- * on (`Class::__construct(...)`, `Class::handle(...): …`), sorted.
+ * The private members AgentCat reads (`Class::$member`), the methods of the classes it extends that it depends on
+ * (`Class::__construct(...)`, `Class::handle(...): …`) and the bundle service definitions it reads, sorted.
*
* @return list
*/
@@ -97,6 +110,7 @@ public static function members(): array
foreach (self::EXTENDED as $class => $method) {
$members[] = $class . '::' . $method;
}
+ array_push($members, ...self::BUNDLE_SERVICES);
$members = array_values(array_unique($members));
sort($members);
diff --git a/tests/Contract/Symfony/BundleServicesTest.php b/tests/Contract/Symfony/BundleServicesTest.php
new file mode 100644
index 0000000..d2cb792
--- /dev/null
+++ b/tests/Contract/Symfony/BundleServicesTest.php
@@ -0,0 +1,82 @@
+kernel(withAgentCat: false, probe: $probe);
+
+ $builder = $probe->builders['conformance'] ?? null;
+ $this->assertIsArray($builder, 'no service tagged mcp.server.builder with server: conformance');
+ $this->assertSame('mcp.server.conformance.builder', $builder['id']);
+ $this->assertEquals([Server::class, 'builder'], $builder['factory']);
+ $this->assertNull($builder['configurator']);
+ $calls = array_values(array_filter($builder['calls'], static fn(array $call): bool => $call[0] === 'addRequestHandlers'));
+ $this->assertCount(1, $calls);
+ $this->assertCount(1, $calls[0][1], 'TrackBuildersPass moves the call only when the tagged handlers are its single argument');
+ $argument = $calls[0][1][0] ?? null;
+ $this->assertInstanceOf(TaggedIteratorArgument::class, $argument);
+ $this->assertSame('mcp.request_handler', $argument->getTag());
+ }
+
+ #[MaximumDuration(1000)]
+ public function testTheServerIsBuiltFromItsBuilder(): void
+ {
+ $kernel = $this->kernel(withAgentCat: false);
+
+ $this->assertInstanceOf(Server::class, $kernel->getContainer()->get('mcp.server.conformance'));
+ }
+
+ /**
+ * D62: the bundle's `kernel.response` listener finds a server's answers by the controller of its route, which
+ * symfony/mcp-bundle's route loader names after the server.
+ */
+ #[MaximumDuration(1000)]
+ public function testEachHttpServersRouteNamesTheServersController(): void
+ {
+ $kernel = $this->kernel(withAgentCat: false);
+ $router = $kernel->getContainer()->get('router');
+ $this->assertInstanceOf(RouterInterface::class, $router);
+
+ $routes = array_filter($router->getRouteCollection()->all(), static fn(Route $route): bool => $route->getPath() === '/mcp/conformance');
+
+ $this->assertSame(['mcp.server.conformance.controller::handle'], array_values(array_map(static fn(Route $route): mixed => $route->getDefault('_controller'), $routes)));
+ $this->assertTrue($kernel->getContainer()->has('mcp.server.conformance.controller'));
+ }
+
+ public function testTheManifestNamesTheBundleInternals(): void
+ {
+ $bundle = array_values(array_filter(PrivateMemberManifest::members(), static fn(string $member): bool => str_starts_with($member, 'symfony/mcp-bundle: ')));
+
+ $this->assertSame([
+ 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)',
+ 'symfony/mcp-bundle: builder without a configurator',
+ 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle',
+ 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }',
+ ], $bundle);
+ }
+}
diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php
index b45757b..0a3f9ef 100644
--- a/tests/E2E/HttpEndToEndTest.php
+++ b/tests/E2E/HttpEndToEndTest.php
@@ -293,7 +293,7 @@ public function testOddHeadersReachTheEventAsStrings(): void
/**
* D47 (open question Q3): under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end,
* after its response. Each POST is tied to its request by the session in its `Successfully sent event` line: each
- * call mints its own session. A slow runner can add a POST without that line, from the 2 s window.
+ * call mints its own session.
*/
#[Group('ac-06-62')]
#[MaximumDuration(15000)]
@@ -315,11 +315,12 @@ public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void
$this->assertCount(2, $answeredAt, 'each call minted its own session');
$sent = $this->diagnosticsWithASentEvent($diagnostics, 2);
$this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request');
+ $this->assertCount(2, $this->collector()->requests($diagnostics), 'and no other: the 2 s window is for long-running processes only');
foreach ($answeredAt as $sessionId => $receivedAt) {
- $own = array_values(array_filter($sent, fn(\stdClass $request): bool => str_contains($this->body($request), ' | session ' . $sessionId . ' | ')));
+ $own = array_values(array_filter($sent, static fn(\stdClass $request): bool => str_contains(MockCollector::body($request), ' | session ' . $sessionId . ' | ')));
$this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId);
$this->assertSame('/v1/logs', $own[0]->path);
- $this->assertSame(1, substr_count($this->body($own[0]), 'Successfully sent event '), 'one line each');
+ $this->assertSame(1, substr_count(MockCollector::body($own[0]), 'Successfully sent event '), 'one line each');
$start = $own[0]->start;
$this->assertIsFloat($start);
$this->assertGreaterThan($receivedAt, $start, 'the diagnostics POST of ' . $sessionId . ' ran after its response');
@@ -389,27 +390,7 @@ private function event(float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS
*/
private function diagnosticsWithASentEvent(string $scenario, int $count): array
{
- $deadline = microtime(true) + self::DIAGNOSTICS_DEADLINE_SECONDS;
- do {
- $sent = array_values(array_filter(
- $this->collector()->requests($scenario),
- fn(\stdClass $request): bool => str_contains($this->body($request), 'Successfully sent event '),
- ));
- if (count($sent) >= $count) {
- break;
- }
- usleep(20_000);
- } while (microtime(true) < $deadline);
-
- return $sent;
- }
-
- private function body(\stdClass $request): string
- {
- $body = $request->body ?? null;
- $this->assertIsString($body);
-
- return $body;
+ return MockCollector::withASentEvent($this->collector()->waitFor($scenario, static fn(array $requests): bool => count(MockCollector::withASentEvent($requests)) >= $count, self::DIAGNOSTICS_DEADLINE_SECONDS));
}
/** How often the server's agentcat.log says that a request rebuilt the registries. */
diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php
index 5695146..9065ca4 100644
--- a/tests/E2E/StdioEndToEndTest.php
+++ b/tests/E2E/StdioEndToEndTest.php
@@ -266,7 +266,7 @@ public function testStdinEofSendsTheDiagnosticsOnce(): void
$requests = $this->collector()->requests($diagnostics);
$this->assertCount(1, $requests, sprintf('one diagnostics POST; the server lived %.3f s', $lifetime));
$this->assertSame('/v1/logs', $requests[0]->path);
- $body = $this->body($requests[0]);
+ $body = MockCollector::body($requests[0]);
$this->assertStringContainsString('"stringValue":"php"', $body);
$this->assertStringContainsString('AgentCat setup complete | project proj_', $body);
$this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain');
@@ -423,15 +423,6 @@ private function eventWithMessage(string $msg, float $deadlineSeconds): ?\stdCla
return null;
}
- /** The body of a request the collector recorded. */
- private function body(\stdClass $request): string
- {
- $body = $request->body ?? null;
- $this->assertIsString($body);
-
- return $body;
- }
-
/** The server's agentcat.log. */
private function log(): string
{
diff --git a/tests/E2E/Support/StdioMcpClient.php b/tests/E2E/Support/StdioMcpClient.php
index a6a3227..d89d799 100644
--- a/tests/E2E/Support/StdioMcpClient.php
+++ b/tests/E2E/Support/StdioMcpClient.php
@@ -10,11 +10,11 @@
use Symfony\Component\Process\Process;
/**
- * A raw JSON-RPC client for an MCP stdio server: `php