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