From 1e68e770064df110687ebea6b4eae1ca9ef62659 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 15:33:42 +0000 Subject: [PATCH 01/30] feat: release the session, the time limit and the output buffers before the shutdown delivery --- composer-dependency-analyser.php | 4 +- src/Internal/Delivery/EventQueue.php | 7 +- src/Internal/Delivery/FinishRequest.php | 22 +- src/Internal/Delivery/RequestEnd.php | 127 +++++++ src/Internal/Delivery/ShutdownFlusher.php | 22 +- src/Internal/Delivery/SignalDrain.php | 26 +- tests/E2E/BufferedOutputTest.php | 121 +++++++ tests/E2E/Fixtures/buffered-server.php | 30 ++ tests/E2E/Support/BuiltInServer.php | 25 +- .../Internal/Delivery/FinishRequestTest.php | 40 +++ .../Unit/Internal/Delivery/RequestEndTest.php | 332 ++++++++++++++++++ .../Internal/Delivery/ShutdownFlusherTest.php | 42 +++ .../Internal/Delivery/SignalDrainTest.php | 20 ++ 13 files changed, 785 insertions(+), 33 deletions(-) create mode 100644 src/Internal/Delivery/RequestEnd.php create mode 100644 tests/E2E/BufferedOutputTest.php create mode 100644 tests/E2E/Fixtures/buffered-server.php create mode 100644 tests/Unit/Internal/Delivery/RequestEndTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 4d2ca7a..426fe2b 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -6,8 +6,8 @@ use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return (new Configuration()) - // ext-pcntl and ext-posix are optional: FileLogger and SignalDrain guard their use with function_exists(). - ->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix'], [ErrorType::SHADOW_DEPENDENCY]) + // ext-pcntl, ext-posix and ext-session are optional: FileLogger, SignalDrain and RequestEnd guard their use with function_exists(). + ->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix', 'ext-session'], [ErrorType::SHADOW_DEPENDENCY]) // Test fixture functions, loaded with require_once by ExceptionCaptureTest (not autoloadable). ->ignoreUnknownFunctionsRegex('~^AgentCat\\\\Tests\\\\Fixtures\\\\Errors\\\\~') // mcp/sdk is optional (01 AC-90): a dev dependency and a suggestion, used only by the adapter. diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index b3223d7..5f09e83 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -94,13 +94,14 @@ public function __construct( } /** - * The queue of this process; its first event registers the shutdown delivery and installs the signal drain - * (CLI servers with ext-pcntl). + * The queue of this process; its first event registers the shutdown delivery (which ends the request first on a + * web SAPI, `RequestEnd`) and installs the signal drain (CLI servers with ext-pcntl). */ public static function global(): self { if (!self::$global instanceof self) { - $flusher = new ShutdownFlusher(static fn(): self => self::global(), null, FinishRequest::detect()); + $finish = FinishRequest::detect(); + $flusher = new ShutdownFlusher(static fn(): self => self::global(), null, $finish, RequestEnd::detect($finish instanceof \Closure)); $signals = new SignalDrain(static fn(): self => self::global()); self::$global = new self(onEnqueue: static function () use ($flusher, $signals): void { $flusher->ensureRegistered(); diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index 0f7ced6..2abe2c5 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -15,23 +15,35 @@ 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 - * gets null: its shutdown functions do not run per request. + * (`isFrankenPhpWorker()`) gets null: its shutdown functions do not run per request. * - * @param array $server `$_SERVER` - * @param \Closure(string): bool $functionExists + * @param array $server `$_SERVER` + * @param \Closure(string): bool $functionExists + * @param (\Closure(string): (string|false))|null $getenv default `getenv()` */ - public static function functionFor(string $sapi, array $server, \Closure $functionExists): ?string + public static function functionFor(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): ?string { $function = match (true) { $sapi === 'fpm-fcgi' => 'fastcgi_finish_request', $sapi === 'litespeed' => 'litespeed_finish_request', - $sapi === 'frankenphp' && !isset($server['FRANKENPHP_WORKER']) => $functionExists('frankenphp_finish_request') ? 'frankenphp_finish_request' : 'fastcgi_finish_request', + $sapi === 'frankenphp' && !self::isFrankenPhpWorker($server, $getenv) => $functionExists('frankenphp_finish_request') ? 'frankenphp_finish_request' : 'fastcgi_finish_request', default => null, }; return $function !== null && $functionExists($function) ? $function : null; } + /** + * FrankenPHP worker mode: `$_SERVER['FRANKENPHP_WORKER']` is set, or the `FRANKENPHP_WORKER` environment variable. + * + * @param array $server `$_SERVER` + * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + */ + public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = null): bool + { + return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false; + } + /** @return (\Closure(): void)|null the finish call of this SAPI, if it has one */ public static function detect(): ?\Closure { diff --git a/src/Internal/Delivery/RequestEnd.php b/src/Internal/Delivery/RequestEnd.php new file mode 100644 index 0000000..1f19b02 --- /dev/null +++ b/src/Internal/Delivery/RequestEnd.php @@ -0,0 +1,127 @@ + $session && session_status() === PHP_SESSION_ACTIVE, + static function (): void { + session_write_close(); + }, + static fn(): int => $limit ? (int) ini_get('max_execution_time') : 0, + static function (int $seconds): void { + set_time_limit($seconds); + }, + static function () use ($abort): void { + if ($abort) { + ignore_user_abort(true); // the client may leave once it has the body + } + self::closeOutputBuffers(); + }, + ); + } + + public function prepare(float $budgetSeconds): void + { + if ($this->sapi === 'cli' || $this->sapi === 'phpdbg') { + return; // stdio servers: stdout is the protocol, and there is no request to end + } + + try { + if (($this->sessionActive)()) { + ($this->closeSession)(); + } + } catch (\Throwable) { + // the next step still runs + } + + try { + if (($this->timeLimit)() > 0) { + ($this->setTimeLimit)($this->timeLimitFor($budgetSeconds)); + } + } catch (\Throwable) { + // the next step still runs + } + if ($this->finishes) { + return; // the finish call sends the response + } + + try { + ($this->closeOutput)(); + } catch (\Throwable) { + // delivery matters more than the buffers + } + } + + /** + * Sends and closes the removable output buffers above `$keep` (Symfony `Response::closeOutputBuffers()`), from the + * top down to the first one that may not be removed or flushed, then flushes the SAPI. + */ + public static function closeOutputBuffers(int $keep = 0): void + { + $required = PHP_OUTPUT_HANDLER_REMOVABLE | PHP_OUTPUT_HANDLER_FLUSHABLE; + for ($level = ob_get_level(); $level > $keep; --$level) { // at most one pass per buffer + $flags = ob_get_status()['flags'] ?? null; // the buffer on top + if (!is_int($flags) || ($flags & $required) !== $required || !ob_end_flush()) { + break; + } + } + flush(); + } + + /** + * The budget in whole seconds, rounded up, plus one. A negative budget counts as none; one that is not finite, or + * that `(int)` cannot convert (it warns on PHP 8.5), counts as the shutdown timeout. + */ + private function timeLimitFor(float $budgetSeconds): int + { + $budget = is_finite($budgetSeconds) && $budgetSeconds < PHP_INT_MAX ? max(0.0, $budgetSeconds) : EventQueue::SHUTDOWN_TIMEOUT_SECONDS; + + return (int) ceil($budget) + 1; + } +} diff --git a/src/Internal/Delivery/ShutdownFlusher.php b/src/Internal/Delivery/ShutdownFlusher.php index 013a8d2..1f0afc7 100644 --- a/src/Internal/Delivery/ShutdownFlusher.php +++ b/src/Internal/Delivery/ShutdownFlusher.php @@ -9,9 +9,10 @@ * * The first event (or `EventQueue::arm()`) registers `onShutdown()` as a shutdown function. It registers `onFinalShutdown()` again, so * that the flush runs after every shutdown function the application registered later (OpenTelemetry's - * `ShutdownHandler` pattern). The final pass first sends the HTTP response to the client when the SAPI can - * (`fastcgi_finish_request()` and friends) and the queue has work (events, exporter requests or diagnostics lines), - * then shuts the queue down within its drain budget. + * `ShutdownHandler` pattern). When the queue has work (events, exporter requests or diagnostics lines), the final + * pass first ends the request (`RequestEnd`: the session, the time limit and, without a finish call, the output + * buffers) and sends the HTTP response to the client when the SAPI can (`fastcgi_finish_request()` and friends), then + * shuts the queue down within its drain budget. * * @internal */ @@ -26,11 +27,13 @@ final class ShutdownFlusher * @param \Closure(): EventQueue $queue the queue to deliver, looked up at shutdown * @param (\Closure(\Closure(): void): void)|null $register default `register_shutdown_function(...)` * @param (\Closure(): void)|null $finishRequest sends the response to the client (`FinishRequest::detect()`) + * @param RequestEnd|null $requestEnd runs before the finish call when the queue has work */ public function __construct( private readonly \Closure $queue, ?\Closure $register = null, private readonly ?\Closure $finishRequest = null, + private readonly ?RequestEnd $requestEnd = null, ) { $this->register = $register ?? register_shutdown_function(...); } @@ -60,11 +63,14 @@ public function onFinalShutdown(): void { try { $queue = ($this->queue)(); - if ($this->finishRequest instanceof \Closure && $queue->hasWork()) { - try { - ($this->finishRequest)(); // the client gets its response before the events are sent - } catch (\Throwable) { - // keep going: delivery matters more than the finish call + if ($queue->hasWork()) { + $this->requestEnd?->prepare(EventQueue::SHUTDOWN_TIMEOUT_SECONDS); // never throws + if ($this->finishRequest instanceof \Closure) { + try { + ($this->finishRequest)(); // the client gets its response before the events are sent + } catch (\Throwable) { + // keep going: delivery matters more than the finish call + } } } $queue->finalShutdown(); diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php index ef52a88..038d45b 100644 --- a/src/Internal/Delivery/SignalDrain.php +++ b/src/Internal/Delivery/SignalDrain.php @@ -9,11 +9,12 @@ * a handler, these signals end PHP at once: no shutdown function runs, and the waiting events are lost. * * The global queue calls `install()` when it gets an event, so nothing happens at autoload. It installs once, only - * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. A signal that the application - * ignores in PHP (`pcntl_signal(…, SIG_IGN)`) stays ignored; an ignore inherited from the parent process is not - * visible to PHP, so AgentCat installs its drain for that signal. When it installs a handler, `install()` also turns - * on `pcntl_async_signals(true)`, which is process wide: from then on every PHP signal handler of the process runs - * between two opcodes, not at `pcntl_signal_dispatch()` or a tick. + * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. It installs nothing when ext-swoole + * or ext-openswoole is loaded. A signal that the application ignores in PHP (`pcntl_signal(…, SIG_IGN)`) stays + * ignored; an ignore inherited from the parent process is not visible to PHP, so AgentCat installs its drain for that + * signal. When it installs a handler, `install()` also turns on `pcntl_async_signals(true)`, which is process wide: + * from then on every PHP signal handler of the process runs between two opcodes, not at `pcntl_signal_dispatch()` or + * a tick. * * Each handler runs once (TypeScript `process.once`). It first gives the signal back to the earlier handler, so a * second Ctrl-C during the drain acts as if AgentCat were not there. Then it shuts the queue down @@ -31,6 +32,9 @@ final class SignalDrain /** @var \Closure(string): bool */ private readonly \Closure $functionExists; + /** @var \Closure(string): bool */ + private readonly \Closure $extensionLoaded; + private bool $installed = false; private bool $disabled = false; @@ -40,16 +44,19 @@ final class SignalDrain * @param (\Closure(int): void)|null $raise raises the signal with the default disposition: default * `pcntl_signal($signal, SIG_DFL)`, then `posix_kill()` to this * process, or `exit(128 + $signal)` without ext-posix - * @param (\Closure(string): bool)|null $functionExists default `function_exists(...)` + * @param (\Closure(string): bool)|null $functionExists default `function_exists(...)` + * @param (\Closure(string): bool)|null $extensionLoaded default `extension_loaded(...)` */ public function __construct( private readonly \Closure $queue, ?\Closure $raise = null, ?\Closure $functionExists = null, private readonly string $sapi = PHP_SAPI, + ?\Closure $extensionLoaded = null, ) { $this->raise = $raise ?? $this->raiseWithDefaultDisposition(...); $this->functionExists = $functionExists ?? function_exists(...); + $this->extensionLoaded = $extensionLoaded ?? extension_loaded(...); } /** Never installs after this. False when the handlers are installed already (then nothing changes). */ @@ -63,12 +70,17 @@ public function disable(): bool return true; } - /** Installs the handlers once. Does nothing outside the CLI SAPI or without ext-pcntl. */ + /** Installs the handlers once. Does nothing outside the CLI SAPI, under Swoole or OpenSwoole, or without ext-pcntl. */ public function install(): void { if ($this->installed || $this->disabled || $this->sapi !== 'cli') { return; } + foreach (['swoole', 'openswoole'] as $extension) { + if (($this->extensionLoaded)($extension)) { + return; // a coroutine server stops its workers with C-level handlers PHP cannot see or chain + } + } foreach (['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals'] as $function) { if (!($this->functionExists)($function)) { return; diff --git a/tests/E2E/BufferedOutputTest.php b/tests/E2E/BufferedOutputTest.php new file mode 100644 index 0000000..95c12f3 --- /dev/null +++ b/tests/E2E/BufferedOutputTest.php @@ -0,0 +1,121 @@ + 'true', + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), // a request without X-E2E-Collector stays local + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'HOME' => self::$dir, + ], ['output_buffering' => '4096']); + } catch (\Throwable $e) { + self::tearDownAfterClass(); // PHPUnit skips it when this method fails + + throw $e; + } + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$server?->stop(); + self::$server = null; + self::$collector?->stop(); + self::$collector = null; + self::remove(self::$dir); + } + + #[Group('ac-03-1')] + #[Group('ac-02-48')] + #[MaximumDuration(10000)] + public function testABufferedAnswerReachesTheClientBeforeTheDelivery(): void + { + $collector = $this->collector(); + $scenario = $collector->scenario([['status' => 202, 'delayMs' => self::SLOW_COLLECTOR_MS]]); + $client = new McpHttpClient($this->server()->baseUrl() . '/mcp'); + $started = microtime(true); + + $answer = $client->callToolModern('echo', ['msg' => 'hi', 'session_id' => 'start', 'context' => 'buffered'], ['name' => 'c', 'version' => '1'], ['X-E2E-Collector' => $scenario]); + + $this->assertLessThan(1.5, $answer['receivedAt'] - $started, 'the answer waited for the delivery'); + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer['message']), 'content', 1, 'text')); + $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; + while ($collector->requests($scenario) === [] && microtime(true) < $deadline) { + usleep(20_000); + } + $this->assertCount(1, $collector->requests($scenario), 'the delivery ran after the answer'); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } + + private function server(): BuiltInServer + { + $this->assertInstanceOf(BuiltInServer::class, self::$server); + + return self::$server; + } + + /** The bootstrap removes only plain files in the test HOME (Ruling R8): remove this tree here. */ + private static function remove(string $path): void + { + if ($path === '' || (!is_dir($path) && !is_file($path))) { + return; + } + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + $names = scandir($path); + foreach ($names === false ? [] : $names as $name) { + if ($name !== '.' && $name !== '..') { + self::remove($path . '/' . $name); + } + } + rmdir($path); + } +} diff --git a/tests/E2E/Fixtures/buffered-server.php b/tests/E2E/Fixtures/buffered-server.php new file mode 100644 index 0000000..b46a827 --- /dev/null +++ b/tests/E2E/Fixtures/buffered-server.php @@ -0,0 +1,30 @@ +setServerInfo('e2e-buffered-server', '1.0.0') + ->addTool(static fn(string $msg): string => 'echo: ' . $msg, 'echo', description: 'Echo', inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']]); +AgentCat::track($builder, 'proj_' . str_repeat('e', 27), new Options(apiBaseUrl: $collector)); + +$factory = new Psr17Factory(); +$response = $builder->build()->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); +$body = (string) $response->getBody(); +http_response_code($response->getStatusCode()); +foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header($name . ': ' . $value, false); + } +} +header('Content-Length: ' . strlen($body)); +echo $body; // no flush(): with output_buffering=4096 the body waits in PHP's buffer until AgentCat sends it diff --git a/tests/E2E/Support/BuiltInServer.php b/tests/E2E/Support/BuiltInServer.php index bef7864..c483090 100644 --- a/tests/E2E/Support/BuiltInServer.php +++ b/tests/E2E/Support/BuiltInServer.php @@ -47,12 +47,13 @@ public function __destruct() * * @param string $router the router script that answers every request * @param array $env environment variables of the server, on top of this process's + * @param array $ini php.ini settings of the server (`-d`) */ - public static function start(string $router, array $env): self + public static function start(string $router, array $env, array $ini = ['output_buffering' => '0']): self { $errors = ''; for ($attempt = 1; $attempt <= self::START_ATTEMPTS; ++$attempt) { - $server = self::launch($router, $env); + $server = self::launch($router, $env, $ini); if ($server->waitUntilListening()) { return $server; } @@ -96,16 +97,24 @@ public function stop(): void $this->process->stop(0); } - /** @param array $env */ - private static function launch(string $router, array $env): self + /** + * @param array $env + * @param array $ini + */ + private static function launch(string $router, array $env, array $ini): self { $port = self::freePort(); $input = new InputStream(); - // php.ini-production sets output_buffering=4096, and PHP empties that output buffer only after the shutdown - // functions, so the response would wait for AgentCat's shutdown delivery. Without it, the router's flush() - // sends the body before the shutdown functions run. + // The default is output_buffering=0. php.ini-production sets output_buffering=4096, and PHP empties that output + // buffer only after the shutdown functions. Without it, the router's flush() sends the body before the shutdown + // functions run; BufferedOutputTest keeps 4096 to show that AgentCat sends the buffer before it delivers. + $settings = []; + foreach ($ini as $name => $value) { + $settings[] = '-d'; + $settings[] = $name . '=' . $value; + } $process = new Process( - [PHP_BINARY, __DIR__ . '/../../Integration/Support/watchdog.php', PHP_BINARY, '-d', 'output_buffering=0', '-S', '127.0.0.1:' . $port, $router], + [PHP_BINARY, __DIR__ . '/../../Integration/Support/watchdog.php', PHP_BINARY, ...$settings, '-S', '127.0.0.1:' . $port, $router], null, ['PHP_CLI_SERVER_WORKERS' => '4', 'AGENTCAT_WATCHDOG_TIMEOUT' => (string) self::LIFETIME_SECONDS, ...$env], $input, diff --git a/tests/Unit/Internal/Delivery/FinishRequestTest.php b/tests/Unit/Internal/Delivery/FinishRequestTest.php index 75ec113..f7e76ee 100644 --- a/tests/Unit/Internal/Delivery/FinishRequestTest.php +++ b/tests/Unit/Internal/Delivery/FinishRequestTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Unit\Internal\Delivery; use AgentCat\Internal\Delivery\FinishRequest; +use AgentCat\Tests\Support\Environment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -49,6 +50,39 @@ public function testFunctionFor(string $sapi, array $server, array $available, ? $this->assertSame($expected, FinishRequest::functionFor($sapi, $server, $functionExists)); } + public function testFrankenPhpWorkerModeIsAlsoReadFromTheEnvironment(): void + { + $exists = static fn(string $name): bool => true; + $getenv = $this->environment('1'); + + $this->assertNull(FinishRequest::functionFor('frankenphp', [], $exists, $getenv)); + $this->assertSame('frankenphp_finish_request', FinishRequest::functionFor('frankenphp', [], $exists, $this->environment(false))); + $this->assertNull(FinishRequest::functionFor('frankenphp', ['FRANKENPHP_WORKER' => '1'], $exists, $this->environment(false))); + $this->assertTrue(FinishRequest::isFrankenPhpWorker([], $getenv)); + $this->assertFalse(FinishRequest::isFrankenPhpWorker([], $this->environment(false))); + } + + /** An empty `FRANKENPHP_WORKER` still marks worker mode: only an unset variable is classic mode. */ + public function testAnEmptyWorkerVariableCounts(): void + { + $this->assertTrue(FinishRequest::isFrankenPhpWorker([], $this->environment(''))); + $this->assertTrue(FinishRequest::isFrankenPhpWorker(['FRANKENPHP_WORKER' => ''], $this->environment(false))); + } + + public function testWorkerModeReadsTheRealEnvironmentByDefault(): void + { + $environment = Environment::clear('FRANKENPHP_WORKER'); + + try { + $this->assertFalse(FinishRequest::isFrankenPhpWorker([])); + putenv('FRANKENPHP_WORKER=1'); + $this->assertTrue(FinishRequest::isFrankenPhpWorker([])); + $this->assertNull(FinishRequest::functionFor('frankenphp', [], static fn(string $name): bool => true)); + } finally { + $environment->restore(); + } + } + public function testDetectFindsNothingUnderTheCliTestRunner(): void { $this->assertSame('cli', PHP_SAPI); @@ -79,6 +113,12 @@ public function testClosureForNeedsACallableFunction(): void $this->assertNull(FinishRequest::closureFor('agentcat_no_such_function')); } + /** @return \Closure(string): (string|false) a `getenv()` that knows only `FRANKENPHP_WORKER` */ + private function environment(string|false $worker): \Closure + { + return static fn(string $name): string|false => $name === 'FRANKENPHP_WORKER' ? $worker : false; + } + public static function probe(): void { self::$probeCalls[] = ignore_user_abort(); diff --git a/tests/Unit/Internal/Delivery/RequestEndTest.php b/tests/Unit/Internal/Delivery/RequestEndTest.php new file mode 100644 index 0000000..ee525bd --- /dev/null +++ b/tests/Unit/Internal/Delivery/RequestEndTest.php @@ -0,0 +1,332 @@ + */ + private array $calls = []; + + /** Session files of the `detect()` tests, under the test HOME (Ruling R8). */ + private string $sessionDir = ''; + + /** @var array the ini settings a test changed, with their values before it */ + private array $savedIni = []; + + private ?int $savedIgnoreUserAbort = null; + + #[\Override] + protected function tearDown(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_abort(); + } + foreach ($this->savedIni as $name => $value) { + ini_set($name, $value); + } + if ($this->savedIgnoreUserAbort !== null) { + ignore_user_abort((bool) $this->savedIgnoreUserAbort); + } + if ($this->sessionDir !== '') { + $files = glob($this->sessionDir . '/*'); + array_map(unlink(...), $files === false ? [] : $files); + rmdir($this->sessionDir); + } + } + + public function testFpmClosesTheSessionAndExtendsTheTimeLimitButKeepsTheBuffers(): void + { + $this->end('fpm-fcgi', finishes: true)->prepare(5.0); + + $this->assertSame(['session', 'limit 6'], $this->calls); + } + + #[Group('ac-03-1')] + public function testASapiWithoutAFinishFunctionAlsoSendsTheOutputBuffers(): void + { + $this->end('apache2handler', finishes: false)->prepare(5.0); + + $this->assertSame(['session', 'limit 6', 'output'], $this->calls); + } + + /** @return iterable */ + public static function commandLineSapis(): iterable + { + yield 'cli' => ['cli']; + yield 'phpdbg' => ['phpdbg']; + } + + #[DataProvider('commandLineSapis')] + #[Group('ac-03-37')] + public function testTheCommandLineIsLeftAlone(string $sapi): void + { + $this->end($sapi, finishes: false)->prepare(5.0); + + $this->assertSame([], $this->calls); + } + + public function testNoSessionAndNoTimeLimitMeanNothingToDo(): void + { + $this->end('fpm-fcgi', finishes: true, session: false, limit: 0)->prepare(0.4); + + $this->assertSame([], $this->calls); + } + + public function testAFractionalBudgetRoundsUp(): void + { + $this->end('fpm-fcgi', finishes: true, session: false)->prepare(1.2); + + $this->assertSame(['limit 3'], $this->calls); + } + + /** @return iterable */ + public static function budgets(): iterable + { + yield 'none' => [0.0, 1]; + yield 'negative' => [-3.5, 1]; + yield 'NaN' => [NAN, 6]; + yield 'INF' => [INF, 6]; + yield '-INF' => [-INF, 6]; + yield 'beyond the int range' => [9.2233720368547758E18, 6]; + yield 'long, inside the int range' => [1.0E18, 1_000_000_000_000_000_001]; + } + + /** A budget that is not finite, or that `(int)` cannot convert, counts as the shutdown timeout (5 s). */ + #[DataProvider('budgets')] + public function testTheTimeLimitIsTheBudgetPlusOneSecond(float $budget, int $limit): void + { + $this->end('fpm-fcgi', finishes: true, session: false)->prepare($budget); + + $this->assertSame(['limit ' . $limit], $this->calls); + } + + public function testAFailingStepNeverStopsTheOthers(): void + { + $end = new RequestEnd( + 'cli-server', + false, + sessionActive: static fn(): bool => throw new \RuntimeException('session'), + closeSession: function (): void { + $this->calls[] = 'session'; + }, + timeLimit: static fn(): int => throw new \RuntimeException('ini'), + setTimeLimit: function (int $seconds): void { + $this->calls[] = 'limit'; + }, + closeOutput: function (): void { + $this->calls[] = 'output'; + }, + ); + + $end->prepare(5.0); + + $this->assertSame(['output'], $this->calls); + } + + public function testAThrowingCallNeverStopsTheNextStepOrReachesTheCaller(): void + { + $end = new RequestEnd( + 'cli-server', + false, + sessionActive: static fn(): bool => true, + closeSession: function (): never { + $this->calls[] = 'session'; + + throw new \RuntimeException('session'); + }, + timeLimit: static fn(): int => 30, + setTimeLimit: function (int $seconds): never { + $this->calls[] = 'limit ' . $seconds; + + throw new \RuntimeException('limit'); + }, + closeOutput: function (): never { + $this->calls[] = 'output'; + + throw new \RuntimeException('output'); + }, + ); + + $end->prepare(5.0); + + $this->assertSame(['session', 'limit 6', 'output'], $this->calls); + } + + public function testCloseOutputBuffersSendsWhatTheBuffersAboveTheKeptLevelHold(): void + { + $level = ob_get_level(); // PHPUnit's own buffer stays: the test keeps its level + ob_start(); + ob_start(); + echo 'held'; + + RequestEnd::closeOutputBuffers($level); + + $this->assertSame($level, ob_get_level()); + $this->expectOutputString('held'); + } + + /** + * The shutdown pass keeps no level: PHP's own buffer (`output_buffering`) goes too. Here that is PHPUnit's buffer, + * empty at this point; the test opens a new one in its place, so PHPUnit finds its level again. + */ + public function testCloseOutputBuffersSendsEveryRemovableBufferByDefault(): void + { + $level = ob_get_level(); + $this->assertGreaterThan(0, $level, "PHPUnit's own buffer"); + ob_start(); + + RequestEnd::closeOutputBuffers(); + $closed = ob_get_level(); + while (ob_get_level() < $level) { + ob_start(); + } + + $this->assertSame(0, $closed); + } + + /** Symfony's rule: a buffer that may not be flushed, and every buffer below it, stays. */ + public function testCloseOutputBuffersStopsAtABufferThatMayNotBeFlushed(): void + { + $level = ob_get_level(); + ob_start(); + echo 'below '; + ob_start(null, 0, self::NOT_FLUSHABLE); + echo 'kept '; + ob_start(); + echo 'sent'; + + RequestEnd::closeOutputBuffers($level); + + $this->assertSame($level + 2, ob_get_level()); + $this->assertSame('kept sent', ob_get_clean()); + $this->assertSame('below ', ob_get_clean()); + } + + public function testDetectDoesNothingOnTheCommandLine(): void + { + $this->assertSame('cli', PHP_SAPI); + $this->startSession(); + $this->setIni('max_execution_time', '30'); + + RequestEnd::detect(false)->prepare(5.0); + + $this->assertSame(PHP_SESSION_ACTIVE, session_status()); + $this->assertSame('30', ini_get('max_execution_time')); + } + + /** + * The real functions on a SAPI without a finish call. The close stops at a buffer that may not be flushed, which + * stands in for PHPUnit's own: the real close sends every removable buffer. + */ + #[Group('ac-03-1')] + public function testDetectWritesTheSessionSetsTheTimeLimitAndSendsTheBuffers(): void + { + $this->startSession(); + $_SESSION['seen'] = 'yes'; + $this->setIni('max_execution_time', '30'); + $this->saveIgnoreUserAbort(false); + ob_start(null, 0, self::NOT_FLUSHABLE); + ob_start(); + echo 'held'; + + RequestEnd::detect(false, 'cli-server')->prepare(5.0); + + $this->assertSame('held', ob_get_clean(), 'the open buffer went into the one below'); + $this->assertSame(PHP_SESSION_NONE, session_status()); + $files = glob($this->sessionDir . '/sess_*'); + $this->assertIsArray($files); + $this->assertCount(1, $files); + $this->assertStringContainsString('seen|s:3:"yes"', (string) file_get_contents($files[0])); + $this->assertSame('6', ini_get('max_execution_time')); + $this->assertSame(1, ignore_user_abort(), 'the client may leave once it has the body'); + } + + /** `disable_functions` and a PHP without ext-session: each step whose function is missing is skipped. */ + public function testDetectSkipsTheStepsWhoseFunctionsAreMissing(): void + { + $this->startSession(); + $this->setIni('max_execution_time', '30'); + $this->saveIgnoreUserAbort(false); + ob_start(null, 0, self::NOT_FLUSHABLE); + ob_start(); + echo 'held'; + + RequestEnd::detect(false, 'cli-server', static fn(string $function): bool => false)->prepare(5.0); + + $this->assertSame('held', ob_get_clean(), 'output buffering is part of PHP itself'); + $this->assertSame(PHP_SESSION_ACTIVE, session_status()); + $this->assertSame('30', ini_get('max_execution_time')); + $this->assertSame(0, ignore_user_abort()); + } + + /** @return iterable */ + public static function sessionFunctions(): iterable + { + yield 'session_status' => ['session_status']; + yield 'session_write_close' => ['session_write_close']; + } + + #[DataProvider('sessionFunctions')] + public function testDetectNeedsBothSessionFunctions(string $missing): void + { + $this->startSession(); + + RequestEnd::detect(true, 'fpm-fcgi', static fn(string $function): bool => $function !== $missing)->prepare(5.0); + + $this->assertSame(PHP_SESSION_ACTIVE, session_status()); + } + + private function end(string $sapi, bool $finishes, bool $session = true, int $limit = 30): RequestEnd + { + return new RequestEnd( + $sapi, + $finishes, + sessionActive: static fn(): bool => $session, + closeSession: function (): void { + $this->calls[] = 'session'; + }, + timeLimit: static fn(): int => $limit, + setTimeLimit: function (int $seconds): void { + $this->calls[] = 'limit ' . $seconds; + }, + closeOutput: function (): void { + $this->calls[] = 'output'; + }, + ); + } + + /** A native session in this CLI process: no cookie, no cache headers, its file under the test HOME. */ + private function startSession(): void + { + $home = getenv('HOME'); + $this->assertIsString($home); + $this->sessionDir = $home . '/sessions-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->sessionDir, 0o700)); + $this->setIni('session.use_cookies', '0'); + $this->setIni('session.cache_limiter', ''); + $this->setIni('session.save_path', $this->sessionDir); + $this->assertTrue(session_start()); + } + + private function setIni(string $name, string $value): void + { + $previous = ini_set($name, $value); + $this->assertNotFalse($previous, $name); + $this->savedIni += [$name => $previous]; + } + + private function saveIgnoreUserAbort(bool $value): void + { + $this->savedIgnoreUserAbort = ignore_user_abort($value); + } +} diff --git a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php index f0b6bd3..a1f3f53 100644 --- a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php +++ b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php @@ -7,6 +7,7 @@ use AgentCat\Event; use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Delivery\ExporterSink; +use AgentCat\Internal\Delivery\RequestEnd; use AgentCat\Internal\Delivery\ShutdownFlusher; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\QueuedEvent; @@ -29,6 +30,9 @@ final class ShutdownFlusherTest extends TestCase /** @var list<\Closure(): void> */ private array $registered = []; + /** @var list the request-end and finish calls, in order */ + private array $calls = []; + #[\Override] protected function setUp(): void { @@ -268,6 +272,44 @@ public function export(Event $event): void $this->assertTrue($aborted->isTerminated()); } + public function testTheRequestEndRunsBeforeTheFinishCallAndOnlyWithWork(): void + { + $queue = new EventQueue(transport: new RecordingTransport()); + $end = new RequestEnd('fpm-fcgi', true, static fn(): bool => true, function (): void { + $this->calls[] = 'session'; + }, static fn(): int => 0, static function (int $s): void {}, static function (): void {}); + $flusher = new ShutdownFlusher(static function () use (&$queue): EventQueue { + return $queue; + }, static function (\Closure $f): void {}, function (): void { + $this->calls[] = 'finish'; + }, $end); + + $flusher->onFinalShutdown(); + $this->assertSame([], $this->calls); // nothing queued: the request is left as it is + + $queue = new EventQueue(transport: new RecordingTransport()); // the closure above reads $queue by reference + $queue->add(new QueuedEvent(new Event(projectId: 'proj_' . str_repeat('a', 27)))); + $flusher->onFinalShutdown(); + + $this->assertSame(['session', 'finish'], $this->calls); + } + + /** The drain's time limit covers the whole shutdown budget, also without a finish call. */ + public function testTheRequestEndGetsTheShutdownBudgetWithoutAFinishCall(): void + { + $this->queue->add(new QueuedEvent($this->event())); + $end = new RequestEnd('cli-server', false, static fn(): bool => false, static function (): void {}, static fn(): int => 30, function (int $seconds): void { + $this->calls[] = 'limit ' . $seconds; + }, function (): void { + $this->calls[] = 'output with ' . count($this->transport->deliveries) . ' deliveries'; + }); + + (new ShutdownFlusher(fn(): EventQueue => $this->queue, $this->recorder(), null, $end))->onFinalShutdown(); + + $this->assertSame(['limit 6', 'output with 0 deliveries'], $this->calls); + $this->assertCount(1, $this->transport->deliveries); + } + /** * @param (\Closure(): void)|null $finishRequest */ diff --git a/tests/Unit/Internal/Delivery/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index 03d6f73..e95ab49 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -283,6 +283,26 @@ public function testTheDefaultRaiseEndsTheProcessWithTheDefaultDisposition(): vo $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGWINCH)); } + /** @return iterable */ + public static function coroutineExtensions(): iterable + { + yield 'swoole' => ['swoole']; + yield 'openswoole' => ['openswoole']; + } + + #[DataProvider('coroutineExtensions')] + #[RequiresPhpExtension('pcntl')] + public function testNothingIsInstalledUnderACoroutineServer(string $extension): void + { + $before = pcntl_signal_get_handler(\SIGTERM); + $drain = new SignalDrain(fn(): EventQueue => $this->queue, static function (int $signal): void {}, static fn(string $f): bool => true, 'cli', static fn(string $loaded): bool => $loaded === $extension); + + $drain->install(); + + $this->assertSame($before, pcntl_signal_get_handler(\SIGTERM)); + $this->assertTrue($drain->disable()); // never installed, so a later switch-off still counts + } + /** SIG_DFL on both signals and synchronous dispatch, so that a test sees exactly what `install()` changed. */ private function resetHandlers(): void { From 0a057684b6a67242714d72b6c4e62fe9c4dae3ff Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 16:03:27 +0000 Subject: [PATCH 02/30] fix: send the output buffers only when PHP ignores an aborted client --- src/Internal/Delivery/RequestEnd.php | 54 ++++++++---- tests/E2E/BufferedOutputTest.php | 23 +---- tests/E2E/HttpEndToEndTest.php | 23 +---- tests/E2E/StdioEndToEndTest.php | 23 +---- tests/Support/FileTree.php | 29 +++++++ .../Unit/Internal/Delivery/RequestEndTest.php | 87 ++++++++++++++++--- 6 files changed, 147 insertions(+), 92 deletions(-) create mode 100644 tests/Support/FileTree.php diff --git a/src/Internal/Delivery/RequestEnd.php b/src/Internal/Delivery/RequestEnd.php index 1f19b02..6ccc5bd 100644 --- a/src/Internal/Delivery/RequestEnd.php +++ b/src/Internal/Delivery/RequestEnd.php @@ -9,8 +9,8 @@ * writes and closes an active native session, so its lock is not held while AgentCat delivers; it gives the drain its * own time limit, so a request that used most of `max_execution_time` does not end the drain with a fatal error; and, * without a finish-request function, it sends the output buffers, so a response that carries a Content-Length - * reaches the client before hooks and delivery run. Nothing runs on the command line. Every step is guarded: a - * failing step skips only itself. + * reaches the client before hooks and delivery run, unless PHP cannot ignore an aborted client. Nothing runs on the + * command line. Every step is guarded: a failing step skips only itself. * * @internal */ @@ -45,6 +45,7 @@ public static function detect(bool $finishes, string $sapi = PHP_SAPI, ?\Closure $session = $exists('session_status') && $exists('session_write_close'); $limit = $exists('set_time_limit'); $abort = $exists('ignore_user_abort'); + $ini = $exists('ini_set'); return new self( $sapi, @@ -53,15 +54,16 @@ public static function detect(bool $finishes, string $sapi = PHP_SAPI, ?\Closure static function (): void { session_write_close(); }, - static fn(): int => $limit ? (int) ini_get('max_execution_time') : 0, - static function (int $seconds): void { - set_time_limit($seconds); + static fn(): int => (int) ini_get('max_execution_time'), + static function (int $seconds) use ($limit): void { + if ($limit) { + set_time_limit($seconds); + } }, - static function () use ($abort): void { - if ($abort) { - ignore_user_abort(true); // the client may leave once it has the body + static function () use ($abort, $ini): void { + if (self::ignoreUserAbort($abort, $ini)) { + self::closeOutputBuffers(); } - self::closeOutputBuffers(); }, ); } @@ -104,16 +106,38 @@ public function prepare(float $budgetSeconds): void */ public static function closeOutputBuffers(int $keep = 0): void { - $required = PHP_OUTPUT_HANDLER_REMOVABLE | PHP_OUTPUT_HANDLER_FLUSHABLE; - for ($level = ob_get_level(); $level > $keep; --$level) { // at most one pass per buffer - $flags = ob_get_status()['flags'] ?? null; // the buffer on top - if (!is_int($flags) || ($flags & $required) !== $required || !ob_end_flush()) { - break; - } + for ($level = ob_get_level(); $level > $keep && self::canSendTopBuffer(); --$level) { // at most one pass per buffer + ob_end_flush(); } flush(); } + /** The buffer on top may be removed and flushed (Symfony's rule), so `ob_end_flush()` closes it without a notice. */ + private static function canSendTopBuffer(): bool + { + $flags = ob_get_status()['flags'] ?? null; + $required = PHP_OUTPUT_HANDLER_REMOVABLE | PHP_OUTPUT_HANDLER_FLUSHABLE; + + return is_int($flags) && ($flags & $required) === $required; + } + + /** + * Lets the script go on when the client leaves once it has the body. On mod_php and php-cgi, a write to a client + * that left ends the script at once unless PHP ignores the abort, so sending the buffers would end the shutdown + * pass before delivery. With `ignore_user_abort()` disabled, `ini_set()` still switches the setting on. True when + * PHP ignores the abort now. + */ + private static function ignoreUserAbort(bool $function, bool $ini): bool + { + if ($function) { + ignore_user_abort(true); + } elseif ($ini) { + ini_set('ignore_user_abort', '1'); + } + + return ini_get('ignore_user_abort') === '1'; // false also when an administrator locked the setting off + } + /** * The budget in whole seconds, rounded up, plus one. A negative budget counts as none; one that is not finite, or * that `(int)` cannot convert (it warns on PHP 8.5), counts as the shutdown timeout. diff --git a/tests/E2E/BufferedOutputTest.php b/tests/E2E/BufferedOutputTest.php index 95c12f3..be929c3 100644 --- a/tests/E2E/BufferedOutputTest.php +++ b/tests/E2E/BufferedOutputTest.php @@ -7,6 +7,7 @@ use AgentCat\Tests\E2E\Support\BuiltInServer; use AgentCat\Tests\E2E\Support\McpHttpClient; use AgentCat\Tests\Integration\Support\MockCollector; +use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\HttpHarness; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; @@ -61,7 +62,7 @@ public static function tearDownAfterClass(): void self::$server = null; self::$collector?->stop(); self::$collector = null; - self::remove(self::$dir); + FileTree::remove(self::$dir); } #[Group('ac-03-1')] @@ -98,24 +99,4 @@ private function server(): BuiltInServer return self::$server; } - - /** The bootstrap removes only plain files in the test HOME (Ruling R8): remove this tree here. */ - private static function remove(string $path): void - { - if ($path === '' || (!is_dir($path) && !is_file($path))) { - return; - } - if (is_file($path) || is_link($path)) { - unlink($path); - - return; - } - $names = scandir($path); - foreach ($names === false ? [] : $names as $name) { - if ($name !== '.' && $name !== '..') { - self::remove($path . '/' . $name); - } - } - rmdir($path); - } } diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index 0324765..b45757b 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -10,6 +10,7 @@ use AgentCat\Tests\E2E\Support\BuiltInServer; use AgentCat\Tests\E2E\Support\McpHttpClient; use AgentCat\Tests\Integration\Support\MockCollector; +use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\Golden; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\HttpHarness; @@ -93,7 +94,7 @@ public static function tearDownAfterClass(): void self::$server = null; self::$collector?->stop(); self::$collector = null; - self::remove(self::$dir); + FileTree::remove(self::$dir); } #[\Override] @@ -499,24 +500,4 @@ private function server(): BuiltInServer return self::$server; } - - /** The bootstrap removes only plain files in the test HOME (Ruling R8): remove this tree here. */ - private static function remove(string $path): void - { - if ($path === '' || (!is_dir($path) && !is_file($path))) { - return; - } - if (is_file($path) || is_link($path)) { - unlink($path); - - return; - } - $names = scandir($path); - foreach ($names === false ? [] : $names as $name) { - if ($name !== '.' && $name !== '..') { - self::remove($path . '/' . $name); - } - } - rmdir($path); - } } diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index fed39a0..5695146 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Json\Json; use AgentCat\Tests\E2E\Support\StdioMcpClient; use AgentCat\Tests\Integration\Support\MockCollector; +use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\HttpHarness; use AgentCat\Tests\Support\McpSdk\McpSdkVersion; @@ -83,7 +84,7 @@ public static function tearDownAfterClass(): void { self::$collector?->stop(); self::$collector = null; - self::remove(self::$dir); + FileTree::remove(self::$dir); } #[\Override] @@ -470,24 +471,4 @@ private function collector(): MockCollector return self::$collector; } - - /** The bootstrap removes only plain files in the test HOME (Ruling R8): remove this tree here. */ - private static function remove(string $path): void - { - if ($path === '' || (!is_dir($path) && !is_file($path))) { - return; - } - if (is_file($path) || is_link($path)) { - unlink($path); - - return; - } - $names = scandir($path); - foreach ($names === false ? [] : $names as $name) { - if ($name !== '.' && $name !== '..') { - self::remove($path . '/' . $name); - } - } - rmdir($path); - } } diff --git a/tests/Support/FileTree.php b/tests/Support/FileTree.php new file mode 100644 index 0000000..4dd7315 --- /dev/null +++ b/tests/Support/FileTree.php @@ -0,0 +1,29 @@ +savedIgnoreUserAbort !== null) { ignore_user_abort((bool) $this->savedIgnoreUserAbort); } - if ($this->sessionDir !== '') { - $files = glob($this->sessionDir . '/*'); - array_map(unlink(...), $files === false ? [] : $files); - rmdir($this->sessionDir); - } + FileTree::remove($this->sessionDir); } public function testFpmClosesTheSessionAndExtendsTheTimeLimitButKeepsTheBuffers(): void @@ -235,13 +232,11 @@ public function testDetectWritesTheSessionSetsTheTimeLimitAndSendsTheBuffers(): $_SESSION['seen'] = 'yes'; $this->setIni('max_execution_time', '30'); $this->saveIgnoreUserAbort(false); - ob_start(null, 0, self::NOT_FLUSHABLE); - ob_start(); - echo 'held'; + $level = $this->openBuffers(); RequestEnd::detect(false, 'cli-server')->prepare(5.0); - $this->assertSame('held', ob_get_clean(), 'the open buffer went into the one below'); + $this->assertSame(['held'], $this->closeBuffers($level), 'the open buffer went into the one below'); $this->assertSame(PHP_SESSION_NONE, session_status()); $files = glob($this->sessionDir . '/sess_*'); $this->assertIsArray($files); @@ -251,24 +246,57 @@ public function testDetectWritesTheSessionSetsTheTimeLimitAndSendsTheBuffers(): $this->assertSame(1, ignore_user_abort(), 'the client may leave once it has the body'); } - /** `disable_functions` and a PHP without ext-session: each step whose function is missing is skipped. */ + /** + * `disable_functions` and a PHP without ext-session: each step whose function is missing is skipped. Without + * `ignore_user_abort()` and `ini_set()`, the buffers stay for PHP to send at the end. + */ public function testDetectSkipsTheStepsWhoseFunctionsAreMissing(): void { $this->startSession(); $this->setIni('max_execution_time', '30'); $this->saveIgnoreUserAbort(false); - ob_start(null, 0, self::NOT_FLUSHABLE); - ob_start(); - echo 'held'; + $level = $this->openBuffers(); RequestEnd::detect(false, 'cli-server', static fn(string $function): bool => false)->prepare(5.0); - $this->assertSame('held', ob_get_clean(), 'output buffering is part of PHP itself'); + $this->assertSame(['held', ''], $this->closeBuffers($level)); $this->assertSame(PHP_SESSION_ACTIVE, session_status()); $this->assertSame('30', ini_get('max_execution_time')); $this->assertSame(0, ignore_user_abort()); } + /** @return iterable, bool, list, int}> */ + public static function abortSettings(): iterable + { + yield 'ignore_user_abort() disabled: ini_set() switches the setting on' => [['ignore_user_abort'], false, ['held'], 1]; + yield 'both disabled: the buffers stay' => [['ignore_user_abort', 'ini_set'], false, ['held', ''], 0]; + yield 'both disabled, the setting already on' => [['ignore_user_abort', 'ini_set'], true, ['held'], 1]; + } + + /** + * On mod_php and php-cgi, a write to a client that left ends the script at once unless PHP ignores the abort; the + * shutdown pass would then end before delivery. So the buffers are sent only when PHP ignores the abort; the + * other steps run either way. + * + * @param list $missing + * @param list $buffers + */ + #[DataProvider('abortSettings')] + public function testTheBuffersAreSentOnlyWhenPhpIgnoresAnAbortedClient(array $missing, bool $before, array $buffers, int $after): void + { + $this->startSession(); + $this->setIni('max_execution_time', '30'); + $this->saveIgnoreUserAbort($before); + $level = $this->openBuffers(); + + RequestEnd::detect(false, 'cli-server', static fn(string $function): bool => !in_array($function, $missing, true))->prepare(5.0); + + $this->assertSame($buffers, $this->closeBuffers($level)); + $this->assertSame($after, ignore_user_abort()); + $this->assertSame(PHP_SESSION_NONE, session_status()); + $this->assertSame('6', ini_get('max_execution_time')); + } + /** @return iterable */ public static function sessionFunctions(): iterable { @@ -325,6 +353,37 @@ private function setIni(string $name, string $value): void $this->savedIni += [$name => $previous]; } + /** + * A buffer that the close stops at (it stands in for PHPUnit's), with an open buffer that holds `held` above it. + * + * @return int the level below both + */ + private function openBuffers(): int + { + $level = ob_get_level(); + ob_start(null, 0, self::NOT_FLUSHABLE); + ob_start(); + echo 'held'; + + return $level; + } + + /** + * Removes the buffers above `$level` and returns what each held, top first. More than two buffers fail the test. + * + * @return list + */ + private function closeBuffers(int $level): array + { + $held = []; + while (ob_get_level() > $level) { + $held[] = (string) ob_get_clean(); + } + $this->assertLessThanOrEqual(2, count($held)); + + return $held; + } + private function saveIgnoreUserAbort(bool $value): void { $this->savedIgnoreUserAbort = ignore_user_abort($value); From 8a30b3249cb2a75cbf7264e030dcbcfa6d3c07c0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 16:19:14 +0000 Subject: [PATCH 03/30] refactor: track servers through a registry of MCP libraries --- src/Client.php | 24 +++--- src/Internal/Engine/Libraries.php | 61 ++++++++++++++ src/Internal/Engine/Library.php | 56 +++++++++++++ .../Engine}/ThrowableStash.php | 7 +- src/Internal/Logging/LogLine.php | 21 ++++- src/Internal/Support/TestMode.php | 22 ++++- src/McpSdk/Internal/CallToolInterceptor.php | 1 + .../Internal/CapturingReferenceHandler.php | 1 + src/McpSdk/Internal/McpCallSeam.php | 1 + src/McpSdk/McpSdkAdapter.php | 2 +- src/McpSdk/McpSdkLibrary.php | 80 +++++++++++++++++++ tests/Integration/ClientTrackTest.php | 22 +++-- tests/Integration/McpSdk/DelegatesTest.php | 2 +- .../Integration/McpSdk/McpSdkLibraryTest.php | 71 ++++++++++++++++ tests/Unit/AgentCatTest.php | 7 +- .../Internal/Diagnostics/DiagnosticsTest.php | 2 +- tests/Unit/Internal/Engine/LibrariesTest.php | 42 ++++++++++ .../Engine}/ThrowableStashTest.php | 4 +- .../Unit/Internal/Logging/FileLoggerTest.php | 5 +- tests/Unit/Internal/Logging/LogLineTest.php | 21 ++++- tests/Unit/Internal/Logging/LogTest.php | 2 +- tests/Unit/Internal/Support/TestModeTest.php | 20 +++++ .../McpSdk/CapturingReferenceHandlerTest.php | 2 +- 23 files changed, 434 insertions(+), 42 deletions(-) create mode 100644 src/Internal/Engine/Libraries.php create mode 100644 src/Internal/Engine/Library.php rename src/{McpSdk/Internal => Internal/Engine}/ThrowableStash.php (78%) create mode 100644 src/McpSdk/McpSdkLibrary.php create mode 100644 tests/Integration/McpSdk/McpSdkLibraryTest.php create mode 100644 tests/Unit/Internal/Engine/LibrariesTest.php rename tests/Unit/{McpSdk => Internal/Engine}/ThrowableStashTest.php (91%) diff --git a/src/Client.php b/src/Client.php index 0fa2eb3..f8674ac 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,6 +9,8 @@ use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Engine\CallInterceptor; +use AgentCat\Internal\Engine\Libraries; +use AgentCat\Internal\Engine\Library; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Engine\TrackedServers; @@ -20,7 +22,6 @@ use AgentCat\Internal\Support\Values; use AgentCat\Internal\Tags\TagValidator; use AgentCat\Internal\Telemetry\TelemetryManager; -use AgentCat\McpSdk\McpSdkAdapter; use Psr\Log\LoggerInterface; /** @@ -94,25 +95,26 @@ public function track(object $server, ?string $projectId = null, ?Options $optio if ($options->disableSignalHandlers && !$this->publisher->queue()->disableSignalHandlers() && $this->publisher->queue()->claimSignalHandlersWarning()) { Log::write('WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored'); } - if (!McpSdkAdapter::supports($server)) { - $signals = McpSdkAdapter::fingerprint($server); - Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, McpSdkAdapter::SUPPORT_MATRIX)); + $library = Libraries::for($server); + if (!$library instanceof Library) { + $signals = Libraries::fingerprint($server); + Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, Libraries::supportMatrix())); - throw new \UnexpectedValueException('AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . McpSdkAdapter::SUPPORT_MATRIX); + throw new \UnexpectedValueException('AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . Libraries::supportMatrix()); } - $logger = McpSdkAdapter::logger($server); + $logger = $library->logger($server); if ($logger instanceof LoggerInterface) { Log::adopt($logger); // D30, D38 } $tracked = TrackedServers::get($server) instanceof TrackedServer; if (!$tracked) { - McpSdkAdapter::inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing + $library->inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing } if (($options->apiBaseUrl ?? '') !== '' || Env::get('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) { $this->publisher->queue()->configure($options->apiBaseUrl); // before the already-tracked check; the last call wins } $project = $projectId !== '' ? $projectId : '(telemetry-only)'; - Log::write(sprintf('AgentCat setup started | project %s | sdk mcp-sdk/builder | signals %s', $project, McpSdkAdapter::fingerprint($server))); + Log::write(sprintf('AgentCat setup started | project %s | sdk %s | signals %s', $project, $library->sdk(), implode(',', $library->signals($server)))); if ($tracked) { Log::write('[SESSION DEBUG] track() - Server already being tracked, skipping initialization'); @@ -126,8 +128,8 @@ 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, McpSdkAdapter::serverInfo($server), $this->publisher); - McpSdkAdapter::install($server, $state, $this->publisher, $this->interceptor); + $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher); + $library->install($server, $state, $this->publisher, $this->interceptor); TrackedServers::add($server, $state); Log::write(sprintf( 'AgentCat setup complete | project %s | tracing=%s context=%s reportMissing=%s exporters=%d', @@ -188,7 +190,7 @@ public function publishCustomEvent(object|string $serverOrSessionId, ?string $pr if (is_string($serverOrSessionId)) { $sessionId = $sessionId !== '' ? $sessionId : $serverOrSessionId; // verbatim: no derivation, '' is sessionless } else { - $server = TrackedServers::get($serverOrSessionId) ?? McpSdkAdapter::trackedServer($serverOrSessionId); + $server = TrackedServers::get($serverOrSessionId) ?? Libraries::trackedServer($serverOrSessionId); if (!$server instanceof TrackedServer) { if (TrackedServers::incomplete()) { // AgentCat's own failure, logged before: it must not throw into the customer's code diff --git a/src/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php new file mode 100644 index 0000000..602887d --- /dev/null +++ b/src/Internal/Engine/Libraries.php @@ -0,0 +1,61 @@ + */ + public static function all(): array + { + return [new McpSdkLibrary()]; + } + + public static function for(object $server): ?Library + { + foreach (self::all() as $library) { + if ($library->supports($server)) { + return $library; + } + } + + return null; + } + + /** Every library's shape signals for the object, comma-joined (TS `fingerprintServerShape`); '' when none applies. */ + public static function fingerprint(object $server): string + { + $signals = []; + foreach (self::all() as $library) { + array_push($signals, ...$library->signals($server)); + } + + return implode(',', $signals); + } + + public static function supportMatrix(): string + { + return implode(' ', array_map(static fn(Library $library): string => $library->supportMatrix(), self::all())); + } + + public static function trackedServer(object $server): ?TrackedServer + { + foreach (self::all() as $library) { + $tracked = $library->trackedServer($server); + if ($tracked instanceof TrackedServer) { + return $tracked; + } + } + + return null; + } +} diff --git a/src/Internal/Engine/Library.php b/src/Internal/Engine/Library.php new file mode 100644 index 0000000..0fdb2ba --- /dev/null +++ b/src/Internal/Engine/Library.php @@ -0,0 +1,56 @@ + + */ + public function signals(object $server): array; + + /** The PSR-3 logger set on the server; null when none is set or it cannot be read. Any object; never throws. */ + public function logger(object $server): ?LoggerInterface; + + /** + * Checks that AgentCat can instrument the server. Runs only after `supports()`. + * + * @throws \UnexpectedValueException with the compatibility message, after logging the compatibility line + */ + public function inspect(object $server): void; + + /** + * Reads the server name and version when an event is published. Runs only after `supports()`. + * + * @return \Closure(): array{0: ?string, 1: ?string} + */ + public function serverInfo(object $server): \Closure; + + /** Adds AgentCat's handlers to the server. Runs only after `supports()` and `inspect()`. */ + public function install(object $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void; + + /** The tracking state AgentCat keeps in the object; null when it has none. Takes any object; never throws. */ + public function trackedServer(object $server): ?TrackedServer; +} diff --git a/src/McpSdk/Internal/ThrowableStash.php b/src/Internal/Engine/ThrowableStash.php similarity index 78% rename from src/McpSdk/Internal/ThrowableStash.php rename to src/Internal/Engine/ThrowableStash.php index 38ed627..5a2eaa5 100644 --- a/src/McpSdk/Internal/ThrowableStash.php +++ b/src/Internal/Engine/ThrowableStash.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace AgentCat\McpSdk\Internal; +namespace AgentCat\Internal\Engine; /** - * The exception a tool threw, kept for the request it ran for. mcp/sdk catches tool exceptions and answers with an - * error result or a JSON-RPC error; the event needs the exception itself (05 E5). Entries go away with their request. + * The exception a tool threw, kept for the request it ran for. The MCP libraries catch tool exceptions and answer + * with an error result or a JSON-RPC error; the event needs the exception itself (05 E5). Entries go away with their + * request. * * @internal */ diff --git a/src/Internal/Logging/LogLine.php b/src/Internal/Logging/LogLine.php index 3cdd6e9..4d82570 100644 --- a/src/Internal/Logging/LogLine.php +++ b/src/Internal/Logging/LogLine.php @@ -15,10 +15,27 @@ final class LogLine { private static ?string $prefix = null; - /** `[sdk= php= mcp=]`, resolved once per process (03 AC-38). */ + /** `[]` for this process, resolved once (03 AC-38). */ public static function prefix(): string { - return self::$prefix ??= sprintf('[sdk=%s php=%s mcp=%s]', SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk') ?? 'unknown'); + return self::$prefix ??= '[' . self::versions(SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk'), SdkVersion::installed('laravel/mcp')) . ']'; + } + + /** `sdk= php=` and the installed MCP libraries; `mcp=unknown` when none is (TS `mcp=`/`mcp2=`, 03 §5.2). */ + public static function versions(string $sdk, string $php, ?string $mcpSdk, ?string $laravelMcp): string + { + $parts = ['sdk=' . $sdk, 'php=' . $php]; + if ($mcpSdk !== null) { + $parts[] = 'mcp=' . $mcpSdk; + } + if ($laravelMcp !== null) { + $parts[] = 'laravel-mcp=' . $laravelMcp; + } + if ($mcpSdk === null && $laravelMcp === null) { + $parts[] = 'mcp=unknown'; + } + + return implode(' ', $parts); } /** `[] `; `$now` defaults to the current time. */ diff --git a/src/Internal/Support/TestMode.php b/src/Internal/Support/TestMode.php index d771a0d..bc538df 100644 --- a/src/Internal/Support/TestMode.php +++ b/src/Internal/Support/TestMode.php @@ -55,13 +55,27 @@ public static function inTestEnvironment(?\Closure $classLoaded = null): bool */ public static function sdkEnabled(?\Closure $classLoaded = null): bool { - $value = self::normalized('AGENTCAT_ENABLED'); + return self::word(Env::get('AGENTCAT_ENABLED') ?? '') ?? self::byDefault(self::normalized('AGENTCAT_ENABLED'), $classLoaded); + } + + /** + * The D44 words: `true`, `1`, `yes`, `on` (trimmed, any case), bool true and int 1 are true; `false`, `0`, `no`, + * `off`, bool false and int 0 are false. Anything else, blank included, is null. + */ + public static function word(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if ($value === 1 || $value === 0) { + return $value === 1; + } - return match ($value) { + return is_string($value) ? match (strtolower(trim($value))) { 'true', '1', 'yes', 'on' => true, 'false', '0', 'no', 'off' => false, - default => self::byDefault($value, $classLoaded), - }; + default => null, + } : null; } /** Whether the class is loaded already. It never autoloads: an installed but unused PHPUnit does not count. */ diff --git a/src/McpSdk/Internal/CallToolInterceptor.php b/src/McpSdk/Internal/CallToolInterceptor.php index 799d84f..1b51bc9 100644 --- a/src/McpSdk/Internal/CallToolInterceptor.php +++ b/src/McpSdk/Internal/CallToolInterceptor.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; diff --git a/src/McpSdk/Internal/CapturingReferenceHandler.php b/src/McpSdk/Internal/CapturingReferenceHandler.php index 5b19829..500e138 100644 --- a/src/McpSdk/Internal/CapturingReferenceHandler.php +++ b/src/McpSdk/Internal/CapturingReferenceHandler.php @@ -4,6 +4,7 @@ namespace AgentCat\McpSdk\Internal; +use AgentCat\Internal\Engine\ThrowableStash; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ToolReference; diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index 5ca0c37..d0e9b29 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -7,6 +7,7 @@ use AgentCat\Internal\Engine\CallContext; use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; +use AgentCat\Internal\Engine\ThrowableStash; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php index 2b595b6..0996190 100644 --- a/src/McpSdk/McpSdkAdapter.php +++ b/src/McpSdk/McpSdkAdapter.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; @@ -20,7 +21,6 @@ use AgentCat\McpSdk\Internal\Drift; use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\Internal\RequestReader; -use AgentCat\McpSdk\Internal\ThrowableStash; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Registry\ReferenceHandlerInterface; diff --git a/src/McpSdk/McpSdkLibrary.php b/src/McpSdk/McpSdkLibrary.php new file mode 100644 index 0000000..4c1a687 --- /dev/null +++ b/src/McpSdk/McpSdkLibrary.php @@ -0,0 +1,80 @@ +builder($server)); + } + + #[\Override] + public function serverInfo(object $server): \Closure + { + return McpSdkAdapter::serverInfo($this->builder($server)); + } + + #[\Override] + public function install(object $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void + { + McpSdkAdapter::install($this->builder($server), $state, $publisher, $interceptor); + } + + #[\Override] + public function trackedServer(object $server): ?TrackedServer + { + return McpSdkAdapter::trackedServer($server); + } + + /** `track()` asks `supports()` first, so this never throws there. */ + private function builder(object $server): Builder + { + return $server instanceof Builder ? $server : throw new \UnexpectedValueException('Not an mcp/sdk Builder: ' . get_debug_type($server)); + } +} diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index 9a540de..e06975b 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -13,6 +13,7 @@ use AgentCat\Internal\Constants; use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Delivery\SignalDrain; +use AgentCat\Internal\Engine\Libraries; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; @@ -21,6 +22,7 @@ use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\RetryPolicy; use AgentCat\McpSdk\Internal\Access\BuilderAccess; +use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\Options; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; @@ -47,8 +49,6 @@ /** `Client::track()` on real mcp/sdk Builders, end to end through the adapter. */ final class ClientTrackTest extends TestCase { - private const string MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; - private const string PROJECT = 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa'; private const string NO_PROJECT = 'Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.'; @@ -118,6 +118,7 @@ public function testTrackWarnsOncePerProcessWhenTheSignalHandlersAreInstalledAlr $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); + $this->assertContains(self::SIGNALS_INSTALLED, $this->logger->messages(), 'the first call warns'); $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); $client->track(TrackingFixture::builder(), self::PROJECT); @@ -208,11 +209,18 @@ public function testAnUnsupportedObjectIsReturnedWithTheShapeLine(\Closure $make $this->assertSame($server, $this->client->track($server, self::PROJECT)); $this->assertSame([ - ['level' => LogLevel::WARNING, 'message' => 'AgentCat SDK Compatibility: unrecognized server shape | signals ' . $signals . '. ' . self::MATRIX], - ['level' => LogLevel::WARNING, 'message' => 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . self::MATRIX], + ['level' => LogLevel::WARNING, 'message' => 'AgentCat SDK Compatibility: unrecognized server shape | signals ' . $signals . '. ' . Libraries::supportMatrix()], + ['level' => LogLevel::WARNING, 'message' => 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . Libraries::supportMatrix()], ], $this->logger->records); } + public function testTheSetupLineNamesTheLibraryOfTheServer(): void + { + $this->client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertSame('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', $this->logger->messages()[0] ?? null); + } + #[Group('ac-01-2')] public function testABuiltBuilderIsLeftAlone(): void { @@ -223,8 +231,8 @@ public function testABuiltBuilderIsLeftAlone(): void $this->assertSame($builder, $this->client->track($builder, self::PROJECT)); $this->assertSame([ - 'AgentCat SDK Compatibility: ' . self::MATRIX, - 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: the mcp/sdk Builder was already built. ' . self::MATRIX, + 'AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, + 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: the mcp/sdk Builder was already built. ' . McpSdkAdapter::SUPPORT_MATRIX, ], $this->logger->messages()); $this->assertSame(['echo'], $this->names($this->initialized($server)->listTools())); $this->assertSame(['echo'], $this->names($this->harness($builder)->listTools()), 'no AgentCat handler was added'); @@ -564,7 +572,7 @@ public function testTheBuildersLoggerReceivesTheCompatibilityLine(): void $this->client->track($builder, self::PROJECT); - $this->assertSame('AgentCat SDK Compatibility: ' . self::MATRIX, $builderLogger->messages()[0] ?? null); + $this->assertSame('AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, $builderLogger->messages()[0] ?? null); } #[Group('ac-03-1')] diff --git a/tests/Integration/McpSdk/DelegatesTest.php b/tests/Integration/McpSdk/DelegatesTest.php index e152765..4cd669e 100644 --- a/tests/Integration/McpSdk/DelegatesTest.php +++ b/tests/Integration/McpSdk/DelegatesTest.php @@ -7,13 +7,13 @@ use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Logging\Log; use AgentCat\McpSdk\Internal\CallToolInterceptor; use AgentCat\McpSdk\Internal\Delegates; use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\Internal\RequestReader; -use AgentCat\McpSdk\Internal\ThrowableStash; use AgentCat\Options; use AgentCat\Tests\Support\McpSdk\CustomerHandler; use AgentCat\Tests\Support\MemoryLogger; diff --git a/tests/Integration/McpSdk/McpSdkLibraryTest.php b/tests/Integration/McpSdk/McpSdkLibraryTest.php new file mode 100644 index 0000000..b3cbe1d --- /dev/null +++ b/tests/Integration/McpSdk/McpSdkLibraryTest.php @@ -0,0 +1,71 @@ +assertTrue($library->supports($builder)); + $this->assertFalse($library->supports(Server::builder()->build())); + $this->assertSame('mcp-sdk/builder', $library->sdk()); + $this->assertSame(McpSdkAdapter::SUPPORT_MATRIX, $library->supportMatrix()); + $this->assertSame(['mcpSdkBuilder'], $library->signals($builder)); + $this->assertSame([], $library->signals(new \stdClass())); + } + + public function testTheLoggerIsTheBuildersOwnAndAnyOtherObjectHasNone(): void + { + $library = new McpSdkLibrary(); + $logger = new NullLogger(); + + $this->assertSame($logger, $library->logger(Server::builder()->setLogger($logger))); + $this->assertNull($library->logger(Server::builder())); + $this->assertNull($library->logger(new \stdClass())); + } + + public function testServerInfoReadsTheBuilder(): void + { + $builder = Server::builder()->setServerInfo('library-server', '2.0.0'); + $info = (new McpSdkLibrary())->serverInfo($builder); + + $builder->build(); + + $this->assertSame(['library-server', '2.0.0'], $info()); + } + + public function testTheBuilderStepsRefuseAnyOtherObject(): void + { + $library = new McpSdkLibrary(); + $object = new \stdClass(); + $publisher = new Publisher(new EventQueue(transport: new RecordingTransport())); + $state = new TrackedServer('', new Options(), static fn(): array => [null, null], $publisher); + + $steps = [ + 'inspect' => static fn() => $library->inspect($object), + 'serverInfo' => static fn(): \Closure => $library->serverInfo($object), + 'install' => static fn() => $library->install($object, $state, $publisher, new CallInterceptor($publisher)), + ]; + foreach ($steps as $step => $run) { + $this->assertSame('Not an mcp/sdk Builder: stdClass', Thrown::by(\UnexpectedValueException::class, $run)->getMessage(), $step); + } + } +} diff --git a/tests/Unit/AgentCatTest.php b/tests/Unit/AgentCatTest.php index 1893b81..d821a70 100644 --- a/tests/Unit/AgentCatTest.php +++ b/tests/Unit/AgentCatTest.php @@ -10,6 +10,7 @@ use AgentCat\Event; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\Libraries; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; @@ -23,8 +24,6 @@ final class AgentCatTest extends TestCase { - private const string MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; - private Environment $environment; private MemoryLogger $logger; @@ -93,8 +92,8 @@ public function testTrackGoesThroughTheClient(): void $this->assertSame($object, AgentCat::track($object, 'proj_x')); $this->assertSame([ - 'AgentCat SDK Compatibility: unrecognized server shape | signals (none). ' . self::MATRIX, - 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . self::MATRIX, + 'AgentCat SDK Compatibility: unrecognized server shape | signals (none). ' . Libraries::supportMatrix(), + 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: server object does not match any supported MCP SDK shape. ' . Libraries::supportMatrix(), ], $this->logger->messages()); } diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index 57bca24..b1f2306 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -91,7 +91,7 @@ public function testAWarningLineIsPostedToTheDefaultCollector(): void $record = JsonAssert::at($body, 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords', 0); $this->assertSame(13, JsonAssert::at($record, 'severityNumber')); $this->assertSame('WARN', JsonAssert::at($record, 'severityText')); - $this->assertMatchesRegularExpression('/\A\[\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\] \[sdk=\S+ php=\S+ mcp=\S+\] Warning: something\z/', $this->text($record)); + $this->assertMatchesRegularExpression('/\A\[\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\] \[sdk=\S+ php=\S+ mcp=\S+(?: laravel-mcp=\S+)?\] Warning: something\z/', $this->text($record)); $this->assertSame('1792163096821000000', JsonAssert::at($record, 'timeUnixNano')); $this->assertSame(Json::encodeJs(DiagnosticsAttributes::record()), Json::encodeJs(JsonAssert::at($record, 'attributes'))); $this->assertSame(1, $this->armed, 'the first line registers the shutdown delivery'); diff --git a/tests/Unit/Internal/Engine/LibrariesTest.php b/tests/Unit/Internal/Engine/LibrariesTest.php new file mode 100644 index 0000000..2333492 --- /dev/null +++ b/tests/Unit/Internal/Engine/LibrariesTest.php @@ -0,0 +1,42 @@ +assertInstanceOf(McpSdkLibrary::class, Libraries::all()[0] ?? null); + } + + public function testABuilderGoesToTheMcpSdkLibrary(): void + { + $this->assertInstanceOf(McpSdkLibrary::class, Libraries::for(Server::builder())); + $this->assertSame('mcp-sdk/builder', Libraries::for(Server::builder())->sdk()); + } + + #[Group('ac-02-2')] + public function testAnythingElseHasNoLibraryAndKeepsItsSignals(): void + { + $this->assertNull(Libraries::for(new \stdClass())); + $this->assertSame('', Libraries::fingerprint(new \stdClass())); + $this->assertSame('mcpSdkServer', Libraries::fingerprint(Server::builder()->build())); + $this->assertNull(Libraries::trackedServer(new \stdClass())); + } + + public function testTheSupportMatrixHasOneSentencePerLibrary(): void + { + $this->assertSame( + 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).', + Libraries::supportMatrix(), + ); + } +} diff --git a/tests/Unit/McpSdk/ThrowableStashTest.php b/tests/Unit/Internal/Engine/ThrowableStashTest.php similarity index 91% rename from tests/Unit/McpSdk/ThrowableStashTest.php rename to tests/Unit/Internal/Engine/ThrowableStashTest.php index 47f84ed..d062e60 100644 --- a/tests/Unit/McpSdk/ThrowableStashTest.php +++ b/tests/Unit/Internal/Engine/ThrowableStashTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace AgentCat\Tests\Unit\McpSdk; +namespace AgentCat\Tests\Unit\Internal\Engine; -use AgentCat\McpSdk\Internal\ThrowableStash; +use AgentCat\Internal\Engine\ThrowableStash; use PHPUnit\Framework\TestCase; final class ThrowableStashTest extends TestCase diff --git a/tests/Unit/Internal/Logging/FileLoggerTest.php b/tests/Unit/Internal/Logging/FileLoggerTest.php index e370abd..64b1670 100644 --- a/tests/Unit/Internal/Logging/FileLoggerTest.php +++ b/tests/Unit/Internal/Logging/FileLoggerTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Unit\Internal\Logging; use AgentCat\Internal\Logging\FileLogger; +use AgentCat\Internal\Logging\LogLine; use AgentCat\Internal\SdkVersion; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -38,10 +39,10 @@ public function testWritesOneFormattedLinePerCall(): void $lines = file($this->path, FILE_IGNORE_NEW_LINES); $this->assertIsArray($lines); $this->assertCount(2, $lines); - $prefix = '/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[sdk=[^ ]+ php=[^ ]+ mcp=(v?\d[^\]]*|dev-[^\]]+|unknown)\] /'; + $prefix = '/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[sdk=[^ ]+ php=[^ ]+ mcp=(v?\d[^\] ]*|dev-[^\] ]+|unknown)(?: laravel-mcp=(v?\d[^\] ]*|dev-[^\] ]+))?\] /'; $mcp = SdkVersion::installed('mcp/sdk'); $this->assertNotNull($mcp, 'mcp/sdk is a dev dependency'); - $versions = '[sdk=' . SdkVersion::get() . ' php=' . PHP_VERSION . ' mcp=' . $mcp . ']'; + $versions = sprintf('[%s]', LogLine::versions(SdkVersion::get(), PHP_VERSION, $mcp, SdkVersion::installed('laravel/mcp'))); foreach ($lines as $line) { $this->assertMatchesRegularExpression($prefix, $line); } diff --git a/tests/Unit/Internal/Logging/LogLineTest.php b/tests/Unit/Internal/Logging/LogLineTest.php index 1e6ab10..8a7380f 100644 --- a/tests/Unit/Internal/Logging/LogLineTest.php +++ b/tests/Unit/Internal/Logging/LogLineTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Logging\LogLine; use AgentCat\Internal\SdkVersion; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -42,8 +43,24 @@ public function testThePrefixNamesTheSdkPhpAndMcpVersionsOnce(): void { $prefix = LogLine::prefix(); - $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\d+\.\d+\.\d+\S* mcp=(v?\d\S*|dev-\S+|unknown)\]\z/', $prefix); - $this->assertSame(sprintf('[sdk=%s php=%s mcp=%s]', SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk') ?? 'unknown'), $prefix); + $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\d+\.\d+\.\d+\S* mcp=(v?\d\S*|dev-\S+|unknown)(?: laravel-mcp=(v?\d\S*|dev-\S+))?\]\z/', $prefix); + $this->assertSame(sprintf('[%s]', LogLine::versions(SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk'), SdkVersion::installed('laravel/mcp'))), $prefix); $this->assertSame($prefix, LogLine::prefix()); } + + /** @return iterable */ + public static function libraryVersions(): iterable + { + yield 'mcp/sdk only' => ['v0.8.1', null, 'sdk=1.0.0 php=8.3.6 mcp=v0.8.1']; + yield 'laravel/mcp only' => [null, 'v1.0.2', 'sdk=1.0.0 php=8.3.6 laravel-mcp=v1.0.2']; + yield 'both' => ['v0.8.1', 'v1.0.2', 'sdk=1.0.0 php=8.3.6 mcp=v0.8.1 laravel-mcp=v1.0.2']; + yield 'neither' => [null, null, 'sdk=1.0.0 php=8.3.6 mcp=unknown']; + } + + #[DataProvider('libraryVersions')] + #[Group('ac-03-35')] + public function testThePrefixNamesTheInstalledMcpLibraries(?string $mcpSdk, ?string $laravelMcp, string $expected): void + { + $this->assertSame($expected, LogLine::versions('1.0.0', '8.3.6', $mcpSdk, $laravelMcp)); + } } diff --git a/tests/Unit/Internal/Logging/LogTest.php b/tests/Unit/Internal/Logging/LogTest.php index e2ce78f..679ce86 100644 --- a/tests/Unit/Internal/Logging/LogTest.php +++ b/tests/Unit/Internal/Logging/LogTest.php @@ -319,7 +319,7 @@ public function testWithoutAHomeTheSinkStillGetsTheLine(): void Log::write('hello'); $this->assertSame([LogLine::prefix() . ' hello'], $this->withoutTime($lines)); - $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\S+ mcp=\S+\]\z/', LogLine::prefix()); + $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\S+ mcp=\S+(?: laravel-mcp=\S+)?\]\z/', LogLine::prefix()); } /** D38: lines that go to the PSR-3 logger of the tracked server reach the sink too. */ diff --git a/tests/Unit/Internal/Support/TestModeTest.php b/tests/Unit/Internal/Support/TestModeTest.php index 55b3b87..9a37538 100644 --- a/tests/Unit/Internal/Support/TestModeTest.php +++ b/tests/Unit/Internal/Support/TestModeTest.php @@ -179,6 +179,26 @@ public function testAnyOtherValueKeepsTheDefaultAndIsReportedOnce(string $value) ], $this->logger->messages(), 'once per process'); } + /** @return iterable */ + public static function words(): iterable + { + foreach ([true, 1, 'true', ' YES ', 'on', '1'] as $i => $value) { + yield 'on ' . $i => [$value, true]; + } + foreach ([false, 0, 'false', 'No', ' off', '0'] as $i => $value) { + yield 'off ' . $i => [$value, false]; + } + foreach ([null, '', ' ', 'maybe', 2, 1.0, []] as $i => $value) { + yield 'neither ' . $i => [$value, null]; + } + } + + #[DataProvider('words')] + public function testTheEnabledWords(mixed $value, ?bool $expected): void + { + $this->assertSame($expected, TestMode::word($value)); + } + public function testResetReportsAnOtherValueAgain(): void { putenv('AGENTCAT_ENABLED=maybe'); diff --git a/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php b/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php index ea378da..06749c8 100644 --- a/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php +++ b/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php @@ -4,8 +4,8 @@ namespace AgentCat\Tests\Unit\McpSdk; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\McpSdk\Internal\CapturingReferenceHandler; -use AgentCat\McpSdk\Internal\ThrowableStash; use AgentCat\Tests\Support\Thrown; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; From 55c2ac22452427ab609d0289052abe0be3045447 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 17:06:40 +0000 Subject: [PATCH 04/30] test: run the default output-buffer close in a PHP process, not over PHPUnit's buffer PHPUnit 13.4 reports a test as risky when it closes PHPUnit's own output buffer, even when the test opens a new one in its place. The default RequestEnd::closeOutputBuffers() keeps no level, so the in-process test had to close that buffer, and failOnRisky failed the PHP 8.4 and 8.5 highest runs. A PHP process with output_buffering on now runs the close, so the bottom buffer is PHP's own, as in production. The test checks that the buffered text goes out at the close (before a direct STDOUT write), that PHP's buffer and the script's were open, and that no level is left. --- .../Delivery/CloseOutputBuffersTest.php | 35 +++++++++++++++++++ .../Fixtures/close-output-buffers.php | 22 ++++++++++++ .../Unit/Internal/Delivery/RequestEndTest.php | 19 ---------- 3 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 tests/Integration/Delivery/CloseOutputBuffersTest.php create mode 100644 tests/Integration/Fixtures/close-output-buffers.php diff --git a/tests/Integration/Delivery/CloseOutputBuffersTest.php b/tests/Integration/Delivery/CloseOutputBuffersTest.php new file mode 100644 index 0000000..1ef3471 --- /dev/null +++ b/tests/Integration/Delivery/CloseOutputBuffersTest.php @@ -0,0 +1,35 @@ +setTimeout(10); + + $process->run(); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame('held after the close', $process->getOutput(), 'the close sent the buffered text'); + $this->assertEquals( + (object) ['before' => ['default output handler', 'default output handler'], 'after' => 0], + Json::decode($process->getErrorOutput()), + "PHP's own buffer and the script's were open, and none is left", + ); + } +} diff --git a/tests/Integration/Fixtures/close-output-buffers.php b/tests/Integration/Fixtures/close-output-buffers.php new file mode 100644 index 0000000..9dcde45 --- /dev/null +++ b/tests/Integration/Fixtures/close-output-buffers.php @@ -0,0 +1,22 @@ + $before, 'after' => $after], JSON_THROW_ON_ERROR)); diff --git a/tests/Unit/Internal/Delivery/RequestEndTest.php b/tests/Unit/Internal/Delivery/RequestEndTest.php index 290b0b7..b9749a9 100644 --- a/tests/Unit/Internal/Delivery/RequestEndTest.php +++ b/tests/Unit/Internal/Delivery/RequestEndTest.php @@ -172,25 +172,6 @@ public function testCloseOutputBuffersSendsWhatTheBuffersAboveTheKeptLevelHold() $this->expectOutputString('held'); } - /** - * The shutdown pass keeps no level: PHP's own buffer (`output_buffering`) goes too. Here that is PHPUnit's buffer, - * empty at this point; the test opens a new one in its place, so PHPUnit finds its level again. - */ - public function testCloseOutputBuffersSendsEveryRemovableBufferByDefault(): void - { - $level = ob_get_level(); - $this->assertGreaterThan(0, $level, "PHPUnit's own buffer"); - ob_start(); - - RequestEnd::closeOutputBuffers(); - $closed = ob_get_level(); - while (ob_get_level() < $level) { - ob_start(); - } - - $this->assertSame(0, $closed); - } - /** Symfony's rule: a buffer that may not be flushed, and every buffer below it, stays. */ public function testCloseOutputBuffersStopsAtABufferThatMayNotBeFlushed(): void { From 90a707034c88e8725c02a84c9f87c8d759cbe752 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 17:06:51 +0000 Subject: [PATCH 05/30] build: add laravel/mcp as an optional adapter dependency with private-member contract tests laravel/mcp ^1.0 is a dev dependency, a suggestion and a conflict below 1.0 (D51), with laravel/framework, orchestra/testbench and larastan for the tests and the analysis. The protected members AgentCat reads (Server::$methods, Server::$transport, HttpTransport::$request) are in the manifest, with contract tests, and LaravelTestCase keeps every Laravel write in a directory of its own under the test HOME. symfony/translation gets the dev floor ^6.4.3 || ^7.0.3 || ^8.0. The lowest set reaches v6.4.0 through nesbot/carbon, whatever the laravel/framework or orchestra/testbench floor; that version declares an implicitly nullable parameter in a file the autoloader loads, so PHP 8.4 and 8.5 print a deprecation in every process. --- composer-dependency-analyser.php | 10 +- composer.json | 7 ++ phpstan.neon.dist | 4 + rector.php | 8 +- .../Internal/Access/HttpTransportAccess.php | 27 +++++ .../Internal/Access/ServerAccess.php | 50 +++++++++ tests/Architecture/LayeringTest.php | 31 ++++++ tests/Contract/LaravelMcp/SeamsTest.php | 82 ++++++++++++++ .../Contract/LaravelMcp/ServerMembersTest.php | 96 ++++++++++++++++ tests/Contract/ManifestTest.php | 7 ++ tests/Contract/McpSdk/BuilderMembersTest.php | 32 +----- tests/Contract/MemberShape.php | 56 ++++++++++ .../Integration/Delivery/MissingCurlTest.php | 9 +- tests/Support/LaravelMcp/EmptyServer.php | 17 +++ tests/Support/LaravelMcp/LaravelTestCase.php | 103 ++++++++++++++++++ tests/Support/LaravelMcp/LineTransport.php | 70 ++++++++++++ tests/Support/LaravelMcp/RecordingMethod.php | 34 ++++++ tests/Support/LaravelMcp/ToolCalls.php | 29 +++++ tests/Support/McpSdk/BuilderMembers.php | 9 +- tests/Support/Members.php | 27 +++++ 20 files changed, 668 insertions(+), 40 deletions(-) create mode 100644 src/LaravelMcp/Internal/Access/HttpTransportAccess.php create mode 100644 src/LaravelMcp/Internal/Access/ServerAccess.php create mode 100644 tests/Contract/LaravelMcp/SeamsTest.php create mode 100644 tests/Contract/LaravelMcp/ServerMembersTest.php create mode 100644 tests/Contract/MemberShape.php create mode 100644 tests/Support/LaravelMcp/EmptyServer.php create mode 100644 tests/Support/LaravelMcp/LaravelTestCase.php create mode 100644 tests/Support/LaravelMcp/LineTransport.php create mode 100644 tests/Support/LaravelMcp/RecordingMethod.php create mode 100644 tests/Support/LaravelMcp/ToolCalls.php create mode 100644 tests/Support/Members.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 426fe2b..d8e55c2 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -19,4 +19,12 @@ // 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]); + ->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/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/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // 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 both files (LayeringTest). + ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // 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]); diff --git a/composer.json b/composer.json index af4e296..e083e1c 100644 --- a/composer.json +++ b/composer.json @@ -38,9 +38,13 @@ "ergebnis/composer-normalize": "^2.54", "ergebnis/phpunit-slow-test-detector": "^2.26", "infection/infection": "^0.35.8", + "larastan/larastan": "^3.13", + "laravel/framework": "^12.41.1 || ^13.0", + "laravel/mcp": "^1.0", "mcp/sdk": "^0.8.1", "nyholm/psr7": "^1.8.2", "opis/json-schema": "^2.6", + "orchestra/testbench": "^10.8 || ^11.3", "php-cs-fixer/shim": "3.95.27", "phpat/phpat": "^0.12.5", "phpdocumentor/type-resolver": "^1.10 || ^2.0", @@ -55,15 +59,18 @@ "spaze/phpstan-disallowed-calls": "^4.15", "symfony/polyfill-uuid": "^1.20", "symfony/process": "^7.4 || ^8.1", + "symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0", "symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0", "tomasvotruba/type-coverage": "^2.5.1" }, "conflict": { + "laravel/mcp": "<1.0", "mcp/sdk": "<0.8.1" }, "suggest": { "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)" }, "minimum-stability": "stable", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 91006dc..b7b587d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,10 @@ parameters: reportPossiblyNonexistentGeneralArrayOffset: true reportUnmatchedIgnoredErrors: true reportIgnoresWithoutComments: true + # Larastan: the SDK reads Env::get(), never env() (the only env() calls are in config/agentcat.php, which is not + # analysed), and it has no Eloquent models. + noEnvCallsOutsideOfConfig: false + checkModelProperties: false type_coverage: return: 100 param: 100 diff --git a/rector.php b/rector.php index f4b7de8..942987e 100644 --- a/rector.php +++ b/rector.php @@ -4,6 +4,7 @@ use Rector\Config\RectorConfig; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; +use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector; return RectorConfig::configure() ->withPaths([__DIR__ . '/src', __DIR__ . '/tests']) @@ -18,7 +19,10 @@ phpunitCodeQuality: true, ) ->withSkip([ - // Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). - StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php'], + // Keeps #[\Override] on setUp() and tearDown() that call the parent: PHPStan's checkMissingOverrideMethodAttribute requires it. + NoSetupWithParentCallOverrideRector::class, + // Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). AfterRequest + // names the Octane event class, which is not a dependency either. + StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Laravel/Internal/AfterRequest.php'], ]) ->withCache(__DIR__ . '/.cache/rector'); diff --git a/src/LaravelMcp/Internal/Access/HttpTransportAccess.php b/src/LaravelMcp/Internal/Access/HttpTransportAccess.php new file mode 100644 index 0000000..f93a842 --- /dev/null +++ b/src/LaravelMcp/Internal/Access/HttpTransportAccess.php @@ -0,0 +1,27 @@ + $t->request, null, HttpTransport::class); + + return $read($transport); + } +} diff --git a/src/LaravelMcp/Internal/Access/ServerAccess.php b/src/LaravelMcp/Internal/Access/ServerAccess.php new file mode 100644 index 0000000..5cdec0b --- /dev/null +++ b/src/LaravelMcp/Internal/Access/ServerAccess.php @@ -0,0 +1,50 @@ + JSON-RPC method => handler class (or container key); the entries are not checked here + * + * @phpstan-impure + */ + #[TouchesPrivateMember(class: Server::class, member: 'methods', type: 'array', visibility: 'protected')] + public static function methods(Server $server): array + { + $read = \Closure::bind(static fn(Server $s): array => $s->methods, null, Server::class); + + return $read($server); + } + + /** @phpstan-impure */ + #[TouchesPrivateMember(class: Server::class, member: 'transport', type: Transport::class, visibility: 'protected')] + public static function transport(Server $server): Transport + { + $read = \Closure::bind(static fn(Server $s): Transport => $s->transport, null, Server::class); + + return $read($server); + } + + /** Reads every member once with warnings as exceptions: a renamed or removed member throws here. */ + public static function probe(Server $server): void + { + StrictErrors::run(static function () use ($server): void { + self::methods($server); + self::transport($server); + }); + } +} diff --git a/tests/Architecture/LayeringTest.php b/tests/Architecture/LayeringTest.php index 15851d5..f44b791 100644 --- a/tests/Architecture/LayeringTest.php +++ b/tests/Architecture/LayeringTest.php @@ -36,4 +36,35 @@ public function onlyTheAdapterDependsOnMcpSdk(): Rule ->classes(Selector::inNamespace('Mcp')) ->because('mcp/sdk is optional: only AgentCat\McpSdk may reference it (01 AC-90)'); } + + #[TestRule] + public function onlyTheLaravelPartsDependOnLaravel(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('AgentCat')) + ->excluding(Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\Tests')) + ->shouldNot()->dependOn() + ->classes(Selector::inNamespace('Laravel'), Selector::inNamespace('Illuminate')) + ->because('laravel/mcp and Laravel are optional (01 AC-90)'); + } + + #[TestRule] + public function theLaravelAdapterNeedsNoMcpSdk(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Laravel')) + ->shouldNot()->dependOn() + ->classes(Selector::inNamespace('Mcp'), Selector::inNamespace('AgentCat\McpSdk')) + ->because('a Laravel application without mcp/sdk must load every Laravel class'); + } + + #[TestRule] + public function theMcpSdkAdapterNeedsNoLaravel(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('AgentCat\McpSdk')) + ->shouldNot()->dependOn() + ->classes(Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Laravel')) + ->because('an mcp/sdk application without Laravel must load every mcp/sdk class'); + } } diff --git a/tests/Contract/LaravelMcp/SeamsTest.php b/tests/Contract/LaravelMcp/SeamsTest.php new file mode 100644 index 0000000..2d44095 --- /dev/null +++ b/tests/Contract/LaravelMcp/SeamsTest.php @@ -0,0 +1,82 @@ +app?->instance('contract.any-key', $method); + $transport = new LineTransport(); + $server = $this->server($transport); + // @phpstan-ignore argument.type (laravel/mcp resolves the entry with Container::make(), which takes any bound key) + $server->addMethod('tools/list', 'contract.any-key'); + $server->start(); + + $answer = $transport->deliver(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => new \stdClass()]); + $transport->deliver(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => new \stdClass()]); + + $this->assertSame(1, $answer?->id); + $this->assertCount(2, $method->requests); + } + + public function testTheMcpRequestIsBoundWhileAMethodRuns(): void + { + $method = new RecordingMethod(static fn(): mixed => Container::getInstance()->make('mcp.request')); + $this->app?->instance('contract.mcp-request', $method); + $transport = new LineTransport(); + $server = $this->server($transport); + // @phpstan-ignore argument.type (see above) + $server->addMethod('tools/call', 'contract.mcp-request'); + $server->start(); + + $transport->deliver(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'x', 'arguments' => ['a' => 1]]]); + + $seen = $method->seen[0] ?? null; + $this->assertInstanceOf(Request::class, $seen); + $this->assertSame(['a' => 1], $seen->all()); + $this->assertFalse($this->app?->bound('mcp.request'), 'the binding outlives the method'); + } + + public function testTheContextListsTheServersToolsAsToolObjects(): void + { + $server = $this->server(new LineTransport()); + + $this->assertSame(['empty-server', '1.0.0'], [$server->createContext()->implementation->name, $server->createContext()->implementation->version]); + $this->assertSame([], $server->createContext()->tools()->all()); + } + + public function testResultsAndRequestsAreMutableArrays(): void + { + $response = JsonRpcResponse::result(1, ['tools' => []]); + $request = new JsonRpcRequest(1, 'tools/call', ['name' => 'x']); + + $this->assertSame(['id' => 1, 'result' => ['tools' => []]], $response->content); + $this->assertSame(['name' => 'x'], $request->params); + $this->assertFalse((new \ReflectionProperty(JsonRpcResponse::class, 'content'))->isReadOnly()); + $this->assertFalse((new \ReflectionProperty(JsonRpcRequest::class, 'params'))->isReadOnly()); + } + + private function server(LineTransport $transport): EmptyServer + { + $server = $this->app?->make(EmptyServer::class, ['transport' => $transport]); + $this->assertInstanceOf(EmptyServer::class, $server); + + return $server; + } +} diff --git a/tests/Contract/LaravelMcp/ServerMembersTest.php b/tests/Contract/LaravelMcp/ServerMembersTest.php new file mode 100644 index 0000000..cd7934f --- /dev/null +++ b/tests/Contract/LaravelMcp/ServerMembersTest.php @@ -0,0 +1,96 @@ + + */ + public static function members(): array + { + return MemberShape::accessorsIn('Laravel\\'); + } + + #[DataProvider('members')] + public function testEachMemberStillHasTheShapeAgentCatReads(string $accessor): void + { + MemberShape::assertDeclared($accessor); + } + + public function testTheAccessorsReturnTheLiveValues(): void + { + $transport = new HttpTransport(Request::create('/mcp', 'POST')); + $server = $this->app?->make(EmptyServer::class, ['transport' => $transport]); + $this->assertInstanceOf(EmptyServer::class, $server); + + $this->assertSame(ListTools::class, ServerAccess::methods($server)['tools/list'] ?? null); + $this->assertSame($transport, ServerAccess::transport($server)); + $this->assertSame('/mcp', HttpTransportAccess::request($transport)->getPathInfo()); + ServerAccess::probe($server); + } + + public function testTheMethodMapIsReadAfterEachChange(): void + { + $server = new EmptyServer(new LineTransport()); + $server->addMethod('tools/list', RecordingMethod::class); + + $this->assertSame(RecordingMethod::class, ServerAccess::methods($server)['tools/list'] ?? null); + } + + /** @return iterable */ + public static function drift(): iterable + { + yield 'methods uninitialized' => [Members::uninitialize(Server::class, 'methods'), 'Laravel\Mcp\Server::$methods must not be accessed before initialization']; + yield 'transport uninitialized' => [Members::uninitialize(Server::class, 'transport'), 'Laravel\Mcp\Server::$transport must not be accessed before initialization']; + } + + /** + * probe() reads every member, so a member that cannot be read throws there instead of failing later on the request + * path. PHP cannot remove a declared property: the cases leave a member uninitialized. + * + * @param \Closure(object): void $drift + */ + #[DataProvider('drift')] + public function testProbeThrowsWhenAMemberDrifts(\Closure $drift, string $message): void + { + $server = new EmptyServer(new LineTransport()); + $drift($server); + + $error = Thrown::by(\Error::class, static fn() => ServerAccess::probe($server)); + + $this->assertStringContainsString($message, $error->getMessage()); + } + + public function testAnUnreadableRequestThrows(): void + { + $transport = new HttpTransport(Request::create('/mcp', 'POST')); + Members::uninitialize(HttpTransport::class, 'request')($transport); + + $error = Thrown::by(\Error::class, static fn(): Request => HttpTransportAccess::request($transport)); + + $this->assertStringContainsString('Laravel\Mcp\Server\Transport\HttpTransport::$request must not be accessed before initialization', $error->getMessage()); + } +} diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php index b13a7a8..0fdca46 100644 --- a/tests/Contract/ManifestTest.php +++ b/tests/Contract/ManifestTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Access\TouchesPrivateMember; use AgentCat\Internal\Json\Json; +use AgentCat\LaravelMcp\Internal\Access\ServerAccess; use AgentCat\McpSdk\Internal\Access\BuilderAccess; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -17,6 +18,9 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void // A new private member or extended class must change this list, so a reviewer sees it. $this->assertSame( [ + 'Laravel\Mcp\Server::$methods', + 'Laravel\Mcp\Server::$transport', + 'Laravel\Mcp\Server\Transport\HttpTransport::$request', 'Mcp\Schema\Result\CallToolResult::__construct(array $content, bool $isError = false, mixed $structuredContent = null, ?array $meta = null)', 'Mcp\Schema\Result\ListToolsResult::__construct(array $tools, ?string $nextCursor = null)', 'Mcp\Server::$protocol', @@ -34,6 +38,7 @@ public function testEveryAccessorDeclaresWhatItTouches(): void { $classes = PrivateMemberManifest::accessorClasses(); $this->assertContains(BuilderAccess::class, $classes); + $this->assertContains(ServerAccess::class, $classes); foreach ($classes as $class) { foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { @@ -65,5 +70,7 @@ public function testNoMcpLibraryIsARuntimeRequirement(): void } $this->assertObjectHasProperty('mcp/sdk', $composer->suggest); $this->assertSame('<0.8.1', $composer->conflict->{'mcp/sdk'}); + $this->assertObjectHasProperty('laravel/mcp', $composer->suggest); + $this->assertSame('<1.0', $composer->conflict->{'laravel/mcp'}); } } diff --git a/tests/Contract/McpSdk/BuilderMembersTest.php b/tests/Contract/McpSdk/BuilderMembersTest.php index a82ba62..0043164 100644 --- a/tests/Contract/McpSdk/BuilderMembersTest.php +++ b/tests/Contract/McpSdk/BuilderMembersTest.php @@ -5,7 +5,7 @@ namespace AgentCat\Tests\Contract\McpSdk; use AgentCat\McpSdk\Internal\Access\BuilderAccess; -use AgentCat\Tests\Contract\PrivateMemberManifest; +use AgentCat\Tests\Contract\MemberShape; use AgentCat\Tests\Support\McpSdk\BuilderMembers; use AgentCat\Tests\Support\Thrown; use Mcp\Capability\Registry\Container; @@ -24,43 +24,19 @@ final class BuilderMembersTest extends TestCase { /** - * One row per accessor that touches an mcp/sdk member. The test reads the attributes itself, so their code runs - * inside the test (coverage and mutation testing see it; a data provider runs before either starts). + * One row per accessor that touches an mcp/sdk member. * * @return array */ public static function mcpSdkAccessors(): array { - $rows = []; - foreach (PrivateMemberManifest::entries() as $entry) { - if (str_starts_with($entry['member']->class, 'Mcp\\')) { - $rows[$entry['accessor']] = [$entry['accessor']]; - } - } - - return $rows; + return MemberShape::accessorsIn('Mcp\\'); } #[DataProvider('mcpSdkAccessors')] public function testTheMembersStillHaveTheDeclaredShape(string $accessor): void { - $members = array_column(array_filter( - PrivateMemberManifest::entries(), - static fn(array $entry): bool => $entry['accessor'] === $accessor, - ), 'member'); - $this->assertNotSame([], $members); - - foreach ($members as $member) { - $this->assertTrue(property_exists($member->class, $member->member), $member->class . '::$' . $member->member . ' is gone'); - - $property = new \ReflectionProperty($member->class, $member->member); - $this->assertSame($member->class, $property->getDeclaringClass()->getName()); - $this->assertSame($member->visibility === 'private', $property->isPrivate()); - $this->assertSame($member->visibility === 'protected', $property->isProtected()); - $this->assertSame($member->static, $property->isStatic()); - $this->assertSame($member->readonly, $property->isReadOnly()); - $this->assertSame($member->type, (string) $property->getType()); - } + MemberShape::assertDeclared($accessor); } public function testBuiltPartsHoldWhatAgentCatReads(): void diff --git a/tests/Contract/MemberShape.php b/tests/Contract/MemberShape.php new file mode 100644 index 0000000..41fca28 --- /dev/null +++ b/tests/Contract/MemberShape.php @@ -0,0 +1,56 @@ + + */ + public static function accessorsIn(string $namespace): array + { + $rows = []; + foreach (PrivateMemberManifest::entries() as $entry) { + if (str_starts_with($entry['member']->class, $namespace)) { + $rows[$entry['accessor']] = [$entry['accessor']]; + } + } + + return $rows; + } + + /** Asserts that every member the accessor declares still has the declared shape; each check is an assertion. */ + public static function assertDeclared(string $accessor): void + { + $members = array_column(array_filter( + PrivateMemberManifest::entries(), + static fn(array $entry): bool => $entry['accessor'] === $accessor, + ), 'member'); + Assert::assertNotSame([], $members); + + foreach ($members as $member) { + Assert::assertTrue(property_exists($member->class, $member->member), $member->class . '::$' . $member->member . ' is gone'); + + $property = new \ReflectionProperty($member->class, $member->member); + Assert::assertSame($member->class, $property->getDeclaringClass()->getName()); + Assert::assertSame($member->visibility === 'private', $property->isPrivate()); + Assert::assertSame($member->visibility === 'protected', $property->isProtected()); + Assert::assertSame($member->static, $property->isStatic()); + Assert::assertSame($member->readonly, $property->isReadOnly()); + Assert::assertSame($member->type, (string) $property->getType()); + } + } +} diff --git a/tests/Integration/Delivery/MissingCurlTest.php b/tests/Integration/Delivery/MissingCurlTest.php index e07eb21..a647522 100644 --- a/tests/Integration/Delivery/MissingCurlTest.php +++ b/tests/Integration/Delivery/MissingCurlTest.php @@ -41,12 +41,17 @@ protected function tearDown(): void rmdir($this->dir); } - /** @return iterable}> */ + /** + * Every curl function except `curl_version()`, which the SDK never calls: before PHP 8.4 the vendor autoloader + * itself calls it (symfony/polyfill-php84, a Laravel dependency), so no script could start without it. + * + * @return iterable}> + */ public static function disabled(): iterable { $curl = get_extension_funcs('curl'); yield 'curl_multi_init' => [['curl_multi_init']]; - yield 'every curl function' => [$curl === false ? [] : $curl]; + yield 'every curl function' => [array_values(array_diff($curl === false ? [] : $curl, ['curl_version']))]; } /** @param list $functions */ diff --git a/tests/Support/LaravelMcp/EmptyServer.php b/tests/Support/LaravelMcp/EmptyServer.php new file mode 100644 index 0000000..1db797b --- /dev/null +++ b/tests/Support/LaravelMcp/EmptyServer.php @@ -0,0 +1,17 @@ + 'cache/packages.php', + 'APP_SERVICES_CACHE' => 'cache/services.php', + 'APP_CONFIG_CACHE' => 'cache/config.php', + 'APP_ROUTES_CACHE' => 'cache/routes.php', + 'APP_EVENTS_CACHE' => 'cache/events.php', + 'LARAVEL_STORAGE_PATH' => 'storage', + ]; + + /** This test's directory under the test HOME. */ + protected string $laravelDirectory = ''; + + private ?Environment $environment = null; + + #[\Override] + protected function setUp(): void + { + $home = getenv('HOME'); + if (!is_string($home) || $home === '') { + throw new \LogicException('tests/bootstrap.php sets HOME to the test HOME'); + } + $this->laravelDirectory = $home . '/laravel-' . bin2hex(random_bytes(6)); + foreach (['cache', 'config', 'storage/framework/views'] as $directory) { + if (!mkdir($this->laravelDirectory . '/' . $directory, 0o700, true)) { + throw new \RuntimeException('Cannot create ' . $this->laravelDirectory . '/' . $directory); + } + } + $this->environment = Environment::clear(...array_keys(self::PATHS)); + foreach (self::PATHS as $name => $path) { + $value = $this->laravelDirectory . '/' . $path; + putenv($name . '=' . $value); + $_ENV[$name] = $_SERVER[$name] = $value; + } + parent::setUp(); + } + + /** @return list */ + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [McpServiceProvider::class]; + } + + /** + * Runs after the providers registered and before they boot, so `publishes()` targets the configuration path. A + * test that overrides it calls the parent first. + * + * @param Application $app + */ + #[\Override] + protected function defineEnvironment(mixed $app): void + { + $app->useConfigPath($this->laravelDirectory . '/config'); + $app->useEnvironmentPath($this->laravelDirectory); + config([ + 'app.debug' => false, + 'logging.default' => 'single', + 'logging.channels.single' => ['driver' => 'single', 'path' => $this->laravelDirectory . '/laravel.log'], + ]); + } + + #[\Override] + protected function tearDown(): void + { + ToolCalls::reset(); + Log::reset(); + Diagnostics::reset(); + EventQueue::setGlobal(null); + AgentCat::setClient(null); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + + parent::tearDown(); + $this->environment?->restore(); + FileTree::remove($this->laravelDirectory); + } +} diff --git a/tests/Support/LaravelMcp/LineTransport.php b/tests/Support/LaravelMcp/LineTransport.php new file mode 100644 index 0000000..6e352b9 --- /dev/null +++ b/tests/Support/LaravelMcp/LineTransport.php @@ -0,0 +1,70 @@ + every line the server sent, in order */ + public array $sent = []; + + /** @var (\Closure(string): mixed)|null the server's handler of one raw message */ + private ?\Closure $handler = null; + + // @phpstan-ignore missingType.callable (laravel/mcp's Transport takes any Closure: a signature breaks contravariance) + #[\Override] + public function onReceive(\Closure $handler): void + { + $this->handler = static fn(string $message): mixed => $handler($message); + } + + /** Does nothing: the test delivers each message with `deliver()`. */ + #[\Override] + public function run(): void {} + + #[\Override] + public function send(string $message): void + { + $this->sent[] = $message; + } + + /** Runs the stream at once, like laravel/mcp's stdio transport. */ + // @phpstan-ignore missingType.callable (see onReceive()) + #[\Override] + public function stream(\Closure $stream): void + { + $stream(); + } + + /** + * Hands the message, JSON-encoded, to the server. + * + * @param array $message + * + * @return ?\stdClass the last line the server sent for it, decoded; null when it sent none + */ + public function deliver(array $message): ?\stdClass + { + $handler = $this->handler ?? throw new \LogicException('Start the server before delivering a message'); + $before = count($this->sent); + + $handler(json_encode($message, JSON_THROW_ON_ERROR)); + + $lines = array_slice($this->sent, $before); + $line = array_pop($lines); + if ($line === null) { + return null; + } + $answer = Json::decode($line); + + return $answer instanceof \stdClass ? $answer : throw new \UnexpectedValueException('Not a JSON object: ' . $line); + } +} diff --git a/tests/Support/LaravelMcp/RecordingMethod.php b/tests/Support/LaravelMcp/RecordingMethod.php new file mode 100644 index 0000000..f3cef1b --- /dev/null +++ b/tests/Support/LaravelMcp/RecordingMethod.php @@ -0,0 +1,34 @@ + the requests, in order */ + public array $requests = []; + + /** @var list what `$observe` returned in each call, in order */ + public array $seen = []; + + /** @param (\Closure(): mixed)|null $observe runs inside every call, while laravel/mcp runs the method */ + public function __construct(private readonly ?\Closure $observe = null) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse + { + $this->requests[] = $request; + if ($this->observe instanceof \Closure) { + $this->seen[] = ($this->observe)(); + } + + return JsonRpcResponse::result($request->id, ['ok' => true]); + } +} diff --git a/tests/Support/LaravelMcp/ToolCalls.php b/tests/Support/LaravelMcp/ToolCalls.php new file mode 100644 index 0000000..0942c51 --- /dev/null +++ b/tests/Support/LaravelMcp/ToolCalls.php @@ -0,0 +1,29 @@ +>> */ + private static array $calls = []; + + /** @param array $arguments */ + public static function record(string $tool, array $arguments): void + { + self::$calls[$tool][] = $arguments; + } + + /** @return list> the arguments of each call of the tool, in order */ + public static function of(string $tool): array + { + return self::$calls[$tool] ?? []; + } + + public static function reset(): void + { + self::$calls = []; + } +} diff --git a/tests/Support/McpSdk/BuilderMembers.php b/tests/Support/McpSdk/BuilderMembers.php index 84391ee..1515045 100644 --- a/tests/Support/McpSdk/BuilderMembers.php +++ b/tests/Support/McpSdk/BuilderMembers.php @@ -4,6 +4,7 @@ namespace AgentCat\Tests\Support\McpSdk; +use AgentCat\Tests\Support\Members; use Mcp\Server\Builder; /** Simulates mcp/sdk member drift on a Builder for the contract tests. */ @@ -15,12 +16,6 @@ final class BuilderMembers */ public static function uninitialize(string $member): \Closure { - return static function (Builder $builder) use ($member): void { - // @phpstan-ignore disallowed.method (simulates drift: PHP has no public way to unset a private property) - $unset = \Closure::bind(static function (Builder $b) use ($member): void { - unset($b->{$member}); // @phpstan-ignore property.dynamicName (the caller names the member) - }, null, Builder::class); - $unset($builder); - }; + return Members::uninitialize(Builder::class, $member); } } diff --git a/tests/Support/Members.php b/tests/Support/Members.php new file mode 100644 index 0000000..2723fb8 --- /dev/null +++ b/tests/Support/Members.php @@ -0,0 +1,27 @@ +{$member}); // @phpstan-ignore property.dynamicName (the caller names the member) + }, null, $class); + $unset($object); + }; + } +} From 47b97fd7140dc7967c2be0d5874484a3438529bc Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 17:56:47 +0000 Subject: [PATCH 06/30] feat: inject AgentCat's parameters into laravel/mcp tool listings Track laravel/mcp servers through a second library of the registry. The adapter points a server's tools/list and initialize entries, through the public addMethod(), at AgentCat's own method objects, bound in the container under per-server keys; they delegate to the classes the entries named before. A listing gets the injected parameters and get_more_tools on every page; streams, JSON-RPC errors, exceptions and answers without a tool list pass unchanged. initialize records the client of a legacy handshake for the server. JsonTree holds the JSON tree rules both adapters share; mcp/sdk's Trees delegates to it, and McpSdkAdapter::fingerprint(), unused since the registry, is gone. The laravel/mcp harnesses (a new server per HTTP request, one server over stdio) and the tracking fixture share their recording side with the mcp/sdk fixture. --- rector.php | 4 + src/Internal/Engine/Libraries.php | 3 +- src/Internal/Json/JsonTree.php | 61 ++++ src/LaravelMcp/Internal/Delegate.php | 30 ++ src/LaravelMcp/Internal/HandshakeClient.php | 33 ++ src/LaravelMcp/Internal/LaravelTrees.php | 89 +++++ src/LaravelMcp/Internal/MethodKey.php | 22 ++ src/LaravelMcp/Internal/TrackedInitialize.php | 30 ++ src/LaravelMcp/Internal/TrackedListTools.php | 52 +++ src/LaravelMcp/LaravelMcpAdapter.php | 142 ++++++++ src/LaravelMcp/LaravelMcpLibrary.php | 81 +++++ src/McpSdk/Internal/McpCallSeam.php | 3 +- src/McpSdk/Internal/Trees.php | 34 +- src/McpSdk/McpSdkAdapter.php | 6 - .../LaravelMcp/LaravelMcpAdapterTest.php | 233 ++++++++++++ .../LaravelMcp/LaravelMcpLibraryTest.php | 84 +++++ .../Integration/LaravelMcp/ListToolsTest.php | 335 ++++++++++++++++++ .../Integration/McpSdk/McpSdkAdapterTest.php | 11 - .../Support/LaravelMcp/ConformanceServer.php | 31 ++ tests/Support/LaravelMcp/DriftedServer.php | 22 ++ tests/Support/LaravelMcp/LaravelHarness.php | 98 +++++ .../LaravelMcp/LaravelStdioHarness.php | 61 ++++ .../LaravelMcp/LaravelTrackingFixture.php | 37 ++ tests/Support/LaravelMcp/ListingServer.php | 28 ++ tests/Support/LaravelMcp/PagedServer.php | 28 ++ tests/Support/LaravelMcp/RecordingMethod.php | 14 +- tests/Support/LaravelMcp/Tools/BoomTool.php | 24 ++ tests/Support/LaravelMcp/Tools/EchoTool.php | 36 ++ tests/Support/LaravelMcp/Tools/NamedTool.php | 26 ++ tests/Support/LaravelMcp/Tools/OwnTool.php | 33 ++ .../Support/LaravelMcp/Tools/RefusesTool.php | 25 ++ .../LaravelMcp/Tools/StructuredTool.php | 34 ++ tests/Support/McpSdk/TrackingFixture.php | 64 +--- tests/Support/RecordsEvents.php | 73 ++++ tests/Unit/Internal/Engine/LibrariesTest.php | 18 +- tests/Unit/Internal/Json/JsonTreeTest.php | 108 ++++++ tests/Unit/LaravelMcp/HandshakeClientTest.php | 65 ++++ tests/Unit/LaravelMcp/LaravelTreesTest.php | 133 +++++++ tests/Unit/McpSdk/TreesTest.php | 46 +-- 39 files changed, 2096 insertions(+), 161 deletions(-) create mode 100644 src/Internal/Json/JsonTree.php create mode 100644 src/LaravelMcp/Internal/Delegate.php create mode 100644 src/LaravelMcp/Internal/HandshakeClient.php create mode 100644 src/LaravelMcp/Internal/LaravelTrees.php create mode 100644 src/LaravelMcp/Internal/MethodKey.php create mode 100644 src/LaravelMcp/Internal/TrackedInitialize.php create mode 100644 src/LaravelMcp/Internal/TrackedListTools.php create mode 100644 src/LaravelMcp/LaravelMcpAdapter.php create mode 100644 src/LaravelMcp/LaravelMcpLibrary.php create mode 100644 tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php create mode 100644 tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php create mode 100644 tests/Integration/LaravelMcp/ListToolsTest.php create mode 100644 tests/Support/LaravelMcp/ConformanceServer.php create mode 100644 tests/Support/LaravelMcp/DriftedServer.php create mode 100644 tests/Support/LaravelMcp/LaravelHarness.php create mode 100644 tests/Support/LaravelMcp/LaravelStdioHarness.php create mode 100644 tests/Support/LaravelMcp/LaravelTrackingFixture.php create mode 100644 tests/Support/LaravelMcp/ListingServer.php create mode 100644 tests/Support/LaravelMcp/PagedServer.php create mode 100644 tests/Support/LaravelMcp/Tools/BoomTool.php create mode 100644 tests/Support/LaravelMcp/Tools/EchoTool.php create mode 100644 tests/Support/LaravelMcp/Tools/NamedTool.php create mode 100644 tests/Support/LaravelMcp/Tools/OwnTool.php create mode 100644 tests/Support/LaravelMcp/Tools/RefusesTool.php create mode 100644 tests/Support/LaravelMcp/Tools/StructuredTool.php create mode 100644 tests/Support/RecordsEvents.php create mode 100644 tests/Unit/Internal/Json/JsonTreeTest.php create mode 100644 tests/Unit/LaravelMcp/HandshakeClientTest.php create mode 100644 tests/Unit/LaravelMcp/LaravelTreesTest.php diff --git a/rector.php b/rector.php index 942987e..951f4e3 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPublicMethodParameterRector; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector; @@ -24,5 +25,8 @@ // Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). AfterRequest // names the Octane event class, which is not a dependency either. StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Laravel/Internal/AfterRequest.php'], + // LaravelMcpAdapter::install() keeps the parameters of Library::install(): it does not wire tools/call yet, and + // that wiring takes the call interceptor. + RemoveUnusedPublicMethodParameterRector::class => [__DIR__ . '/src/LaravelMcp/LaravelMcpAdapter.php'], ]) ->withCache(__DIR__ . '/.cache/rector'); diff --git a/src/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php index 602887d..94daed0 100644 --- a/src/Internal/Engine/Libraries.php +++ b/src/Internal/Engine/Libraries.php @@ -4,6 +4,7 @@ namespace AgentCat\Internal\Engine; +use AgentCat\LaravelMcp\LaravelMcpLibrary; use AgentCat\McpSdk\McpSdkLibrary; /** @@ -17,7 +18,7 @@ final class Libraries /** @return list */ public static function all(): array { - return [new McpSdkLibrary()]; + return [new McpSdkLibrary(), new LaravelMcpLibrary()]; } public static function for(object $server): ?Library diff --git a/src/Internal/Json/JsonTree.php b/src/Internal/Json/JsonTree.php new file mode 100644 index 0000000..d48eafa --- /dev/null +++ b/src/Internal/Json/JsonTree.php @@ -0,0 +1,61 @@ + $value + */ + public static function object(array $value): \stdClass + { + try { + $tree = self::of((object) $value); + } catch (\Throwable) { + $tree = null; + } + + return $tree instanceof \stdClass ? $tree : new \stdClass(); + } + + /** + * The tree of a tool result. A library's PHP arrays cannot tell `{}` from `[]`, so an empty-list + * `structuredContent` is `{}` (C1 header conflict 4). + * + * @throws \JsonException for a value JSON cannot carry + */ + public static function result(mixed $result): mixed + { + $tree = self::of($result); + if ($tree instanceof \stdClass && ($tree->structuredContent ?? null) === []) { + $tree->structuredContent = new \stdClass(); + } + + return $tree; + } +} diff --git a/src/LaravelMcp/Internal/Delegate.php b/src/LaravelMcp/Internal/Delegate.php new file mode 100644 index 0000000..f39879f --- /dev/null +++ b/src/LaravelMcp/Internal/Delegate.php @@ -0,0 +1,30 @@ + $class + * + * @throws \UnexpectedValueException when the container resolves the class to an object that is not a method, + * which fails the request like laravel/mcp's own call would + */ + public static function of(string $class): Method + { + $method = Container::getInstance()->make($class); + + return $method instanceof Method ? $method : throw new \UnexpectedValueException(sprintf('The container resolved %s to %s, which is not a %s', $class, get_debug_type($method), Method::class)); + } +} diff --git a/src/LaravelMcp/Internal/HandshakeClient.php b/src/LaravelMcp/Internal/HandshakeClient.php new file mode 100644 index 0000000..9ece466 --- /dev/null +++ b/src/LaravelMcp/Internal/HandshakeClient.php @@ -0,0 +1,33 @@ +clientInfo = $clientInfo; + } + } + + /** The client info of the last handshake that sent one; null before. */ + public function clientInfo(): mixed + { + return $this->clientInfo; + } +} diff --git a/src/LaravelMcp/Internal/LaravelTrees.php b/src/LaravelMcp/Internal/LaravelTrees.php new file mode 100644 index 0000000..f8c5c88 --- /dev/null +++ b/src/LaravelMcp/Internal/LaravelTrees.php @@ -0,0 +1,89 @@ +|null + * + * @throws \JsonException for a value JSON cannot carry + */ + public static function tools(mixed $response): ?array + { + if (!$response instanceof JsonRpcResponse) { + return null; + } + $result = $response->content['result'] ?? null; + if (!is_array($result)) { + return null; + } + $tools = JsonTree::of($result['tools'] ?? null); + + return is_array($tools) && array_is_list($tools) ? $tools : null; + } + + /** + * The tree of a response's tool result (`JsonTree::result()`): laravel/mcp's arrays cannot tell `{}` from `[]`, + * so an empty-list `structuredContent` is `{}`. + * + * @throws \JsonException for a value JSON cannot carry + */ + public static function result(JsonRpcResponse $response): mixed + { + return JsonTree::result($response->content['result'] ?? null); + } + + /** + * laravel/mcp's own shape for a JSON tree: associative arrays, which its test helpers index, except objects that + * an array would change on the wire: `{}` and objects whose keys are 0, 1, … stay `\stdClass`. + */ + public static function native(mixed $tree): mixed + { + if (is_array($tree)) { + return array_map(self::native(...), $tree); + } + if (!$tree instanceof \stdClass) { + return $tree; + } + $fields = array_map(self::native(...), get_object_vars($tree)); + if ($fields === [] || array_is_list($fields)) { + return (object) $fields; + } + + return $fields; + } + + /** + * A tool result for laravel/mcp: an array at the top, and `_meta` an array too, because `Server::send()` writes + * `result['_meta'][…]` into it. + * + * @return array + */ + public static function nativeResult(\stdClass $tree): array + { + $result = []; + foreach (get_object_vars($tree) as $key => $value) { + $result[(string) $key] = self::native($value); // the cast types the key: PHP keeps a numeric key an int + } + if (isset($result['_meta']) && $result['_meta'] instanceof \stdClass) { + $result['_meta'] = get_object_vars($result['_meta']); + } + + return $result; + } +} diff --git a/src/LaravelMcp/Internal/MethodKey.php b/src/LaravelMcp/Internal/MethodKey.php new file mode 100644 index 0000000..ebf07da --- /dev/null +++ b/src/LaravelMcp/Internal/MethodKey.php @@ -0,0 +1,22 @@ + $original the handler the server's entry named before AgentCat's */ + public function __construct(private string $original, private HandshakeClient $handshake) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $this->handshake->record($request->params['clientInfo'] ?? null); + + return Delegate::of($this->original)->handle($request, $context); + } +} diff --git a/src/LaravelMcp/Internal/TrackedListTools.php b/src/LaravelMcp/Internal/TrackedListTools.php new file mode 100644 index 0000000..6dd3c67 --- /dev/null +++ b/src/LaravelMcp/Internal/TrackedListTools.php @@ -0,0 +1,52 @@ + $original the handler the server's entry named before AgentCat's */ + public function __construct(private TrackedServer $server, private string $original, private Publisher $publisher) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $this->publisher->pump(); // stdio servers deliver between requests + $response = Delegate::of($this->original)->handle($request, $context); // throws pass unchanged (02 AC-10) + if (!$response instanceof JsonRpcResponse || array_key_exists('error', $response->content)) { + return $response; // a stream or a JSON-RPC error passes unchanged, like the mcp/sdk adapter's + } + try { + // AgentCat changes only the result's `tools`; its other members (the cursor, `_meta`) stay. + $tools = ToolListing::apply($this->server, LaravelTrees::tools($response)); + $result = $response->content['result'] ?? null; + if ($tools === null || !is_array($result)) { + return $response; + } + + return new JsonRpcResponse([...$response->content, 'result' => [...$result, 'tools' => LaravelTrees::native($tools)]]); + } catch (\Throwable $e) { + Log::write('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - ' . ErrorText::message($e)); + + return $response; + } + } +} diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php new file mode 100644 index 0000000..1685349 --- /dev/null +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -0,0 +1,142 @@ + + */ + public static function signals(object $server): array + { + return $server instanceof Server ? ['laravelMcpServer'] : []; + } + + public static function version(): string + { + return SdkVersion::installed('laravel/mcp') ?? 'unknown'; + } + + /** + * Checks that AgentCat can instrument this server: the protected members it reads have the expected shape. + * + * @throws \UnexpectedValueException with the compatibility message, after logging the compatibility line + */ + public static function inspect(Server $server): void + { + try { + ServerAccess::probe($server); + } catch (\Throwable $e) { + Log::warning(self::COMPATIBILITY_LINE); + + throw new \UnexpectedValueException(sprintf('AgentCat SDK compatibility error: laravel/mcp %s internals changed (%s). %s', self::version(), ErrorText::of($e), self::SUPPORT_MATRIX), 0, $e); + } + } + + /** + * The server name and version, read when an event is published. Holds the server weakly: the tracking state must + * not keep a finished request's server alive. Unknown when they cannot be read. + * + * @return \Closure(): array{0: ?string, 1: ?string} + */ + public static function serverInfo(Server $server): \Closure + { + $reference = \WeakReference::create($server); + + return static function () use ($reference): array { + $server = $reference->get(); + if (!$server instanceof Server) { + return [null, null]; + } + try { + $implementation = StrictErrors::run(static fn(): Implementation => $server->createContext()->implementation); + + return [$implementation->name, $implementation->version]; + } catch (\Throwable) { + return [null, null]; + } + }; + } + + /** + * Points the server's `tools/list` and `initialize` entries at AgentCat's methods, which delegate to the classes + * the entries named. An entry that names no method class (a container key, another package's binding) stays as it + * is. Every read comes before the first change, so a read that throws leaves the server untouched. + */ + public static function install(Server $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void + { + $methods = ServerAccess::methods($server); + $list = self::entry($methods, 'tools/list'); + $initialize = self::entry($methods, 'initialize'); + $container = Container::getInstance(); + $handshake = new HandshakeClient(); // one per server: the client of a legacy handshake, for its tool calls + if ($list !== null) { + self::point($server, $container, 'tools/list', new TrackedListTools($state, $list, $publisher)); + } + if ($initialize !== null) { + self::point($server, $container, 'initialize', new TrackedInitialize($initialize, $handshake)); + } + } + + /** + * The method class an entry of the server's method map names; null when it names none. + * + * @param array $methods + * + * @return class-string|null + */ + private static function entry(array $methods, string $method): ?string + { + $entry = $methods[$method] ?? null; + + return is_string($entry) && is_a($entry, Method::class, true) ? $entry : null; + } + + /** Binds AgentCat's method under this server's own key and points the server's entry at it. */ + private static function point(Server $server, Container $container, string $method, Method $handler): void + { + $key = MethodKey::for($server, $method); + $container->instance($key, $handler); + // @phpstan-ignore argument.type (laravel/mcp resolves the entry with Container::make(), which takes any bound key) + $server->addMethod($method, $key); + } +} diff --git a/src/LaravelMcp/LaravelMcpLibrary.php b/src/LaravelMcp/LaravelMcpLibrary.php new file mode 100644 index 0000000..383b675 --- /dev/null +++ b/src/LaravelMcp/LaravelMcpLibrary.php @@ -0,0 +1,81 @@ +server($server)); + } + + #[\Override] + public function serverInfo(object $server): \Closure + { + return LaravelMcpAdapter::serverInfo($this->server($server)); + } + + #[\Override] + public function install(object $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void + { + LaravelMcpAdapter::install($this->server($server), $state, $publisher, $interceptor); + } + + #[\Override] + public function trackedServer(object $server): ?TrackedServer + { + return null; + } + + /** `track()` asks `supports()` first, so this never throws there. */ + private function server(object $server): Server + { + return $server instanceof Server ? $server : throw new \UnexpectedValueException('Not a laravel/mcp Server: ' . get_debug_type($server)); + } +} diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index d0e9b29..2b4553f 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\ThrowableStash; +use AgentCat\Internal\Json\JsonTree; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; @@ -49,7 +50,7 @@ public function toolName(): string #[\Override] public function arguments(): mixed { - return Trees::arguments($this->request->arguments); + return JsonTree::object($this->request->arguments); } #[\Override] diff --git a/src/McpSdk/Internal/Trees.php b/src/McpSdk/Internal/Trees.php index f2f7530..c992b80 100644 --- a/src/McpSdk/Internal/Trees.php +++ b/src/McpSdk/Internal/Trees.php @@ -4,8 +4,7 @@ namespace AgentCat\McpSdk\Internal; -use AgentCat\Internal\Json\Json; -use AgentCat\Internal\Wire\WireEncoder; +use AgentCat\Internal\Json\JsonTree; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ListToolsResult; @@ -18,45 +17,24 @@ final class Trees { /** - * The JSON tree of a value, as the wire would carry it. Invalid UTF-8 becomes U+FFFD. What a `jsonSerialize()` - * throws passes through. + * The JSON tree of a value, as the wire would carry it (`JsonTree::of()`). * * @throws \JsonException for a value JSON cannot carry */ public static function of(mixed $value): mixed { - return Json::decode(json_encode($value, WireEncoder::FLAGS)); + return JsonTree::of($value); } /** - * The tree of a `tools/call` request's arguments: always an object, so empty arguments are `{}` and integer - * keys stay keys (N8). Nested values are encoded as they are. Never throws: `{}` when JSON cannot carry them. - * - * @param array $arguments - */ - public static function arguments(array $arguments): mixed - { - try { - return self::of((object) $arguments); - } catch (\Throwable) { - return new \stdClass(); - } - } - - /** - * The tree of a tool result. mcp/sdk cannot tell `{}` from `[]`, so an empty-list `structuredContent` is `{}` - * (header conflict 4). + * The tree of a tool result (`JsonTree::result()`): mcp/sdk cannot tell `{}` from `[]`, so an empty-list + * `structuredContent` is `{}` (header conflict 4). * * @throws \JsonException for a value JSON cannot carry */ public static function result(mixed $result): mixed { - $tree = self::of($result); - if ($tree instanceof \stdClass && ($tree->structuredContent ?? null) === []) { - $tree->structuredContent = new \stdClass(); - } - - return $tree; + return JsonTree::result($result); } /** diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php index 0996190..fb66bd1 100644 --- a/src/McpSdk/McpSdkAdapter.php +++ b/src/McpSdk/McpSdkAdapter.php @@ -73,12 +73,6 @@ public static function signals(object $server): array return $signals; } - /** The signals, comma-joined (TS `fingerprintServerShape`); empty when none applies. */ - public static function fingerprint(object $server): string - { - return implode(',', self::signals($server)); - } - public static function version(): string { return SdkVersion::installed('mcp/sdk') ?? 'unknown'; diff --git a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php new file mode 100644 index 0000000..ade50d1 --- /dev/null +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -0,0 +1,233 @@ + */ + public static function servers(): iterable + { + yield 'a laravel/mcp server' => [new ConformanceServer(new LineTransport()), true]; + yield 'an mcp/sdk Builder' => [McpServer::builder(), false]; + yield 'anything else' => [new \stdClass(), false]; + } + + #[DataProvider('servers')] + public function testOnlyALaravelMcpServerIsSupported(object $server, bool $supported): void + { + $this->assertSame($supported, LaravelMcpAdapter::supports($server)); + $this->assertSame($supported ? ['laravelMcpServer'] : [], LaravelMcpAdapter::signals($server)); + } + + public function testALaravelServerHasTheLaravelLibrary(): void + { + $server = $this->conformanceServer(); + + $this->assertInstanceOf(LaravelMcpLibrary::class, Libraries::for($server)); + $this->assertSame('laravelMcpServer', Libraries::fingerprint($server)); + $this->assertNull(Libraries::trackedServer($server)); + } + + public function testTheVersionIsTheInstalledLaravelMcpVersion(): void + { + $this->assertSame(SdkVersion::installed('laravel/mcp'), LaravelMcpAdapter::version()); + $this->assertNotSame('unknown', LaravelMcpAdapter::version()); + } + + public function testAServerWhoseMembersReadPassesInspection(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + LaravelMcpAdapter::inspect($this->conformanceServer()); + + $this->assertSame([], $logger->messages()); + } + + public function testDriftLogsTheCompatibilityLineAndTracksNothing(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $server = $this->app?->make(DriftedServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(DriftedServer::class, $server); + + $error = Thrown::by(\UnexpectedValueException::class, static fn() => LaravelMcpAdapter::inspect($server)); + + $this->assertStringStartsWith('AgentCat SDK compatibility error: laravel/mcp ' . LaravelMcpAdapter::version() . ' internals changed (Error: ', $error->getMessage()); + $this->assertStringEndsWith('). ' . LaravelMcpAdapter::SUPPORT_MATRIX, $error->getMessage()); + $this->assertInstanceOf(\Error::class, $error->getPrevious()); + $this->assertSame(0, $error->getCode()); + $this->assertSame([['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE]], $logger->records); + } + + public function testTrackOfADriftedServerLogsAndReturnsTheServer(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $client = new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport()), static fn(): bool => true, false)); + $server = $this->app?->make(DriftedServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(DriftedServer::class, $server); + + $this->assertSame($server, $client->track($server, LaravelTrackingFixture::PROJECT)); + + $messages = $logger->messages(); + $this->assertCount(2, $messages); + $this->assertSame(LaravelMcpAdapter::COMPATIBILITY_LINE, $messages[0]); + $this->assertStringStartsWith('Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: laravel/mcp ', $messages[1]); + } + + public function testTrackGoesThroughTheLaravelLibrary(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $client = new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport()), static fn(): bool => true, false)); + $server = $this->conformanceServer(); + + $this->assertSame($server, $client->track($server, LaravelTrackingFixture::PROJECT)); + $client->track($server, LaravelTrackingFixture::PROJECT); + + $this->assertSame([ + 'AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server | signals laravelMcpServer', + 'AgentCat setup complete | project ' . LaravelTrackingFixture::PROJECT . ' | tracing=true context=true reportMissing=true exporters=0', + 'AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server | signals laravelMcpServer', + '[SESSION DEBUG] track() - Server already being tracked, skipping initialization', + ], $logger->messages()); + $this->assertSame(MethodKey::for($server, 'tools/list'), ServerAccess::methods($server)['tools/list'] ?? null); + } + + public function testInstallPointsTheEntriesAtAgentCatsMethodsUnderPerServerKeys(): void + { + $server = $this->conformanceServer(); + $other = $this->conformanceServer(); + $fixture = new LaravelTrackingFixture(); + + $fixture->track($server); + $fixture->track($other); + + $methods = ServerAccess::methods($server); + $this->assertSame('agentcat.laravel-mcp.' . spl_object_id($server) . '.tools/list', $methods['tools/list'] ?? null); + $this->assertSame('agentcat.laravel-mcp.' . spl_object_id($server) . '.initialize', $methods['initialize'] ?? null); + $this->assertSame(CallTool::class, $methods['tools/call'] ?? null, 'tools/call is not wired yet'); + $this->assertInstanceOf(TrackedListTools::class, Container::getInstance()->make(MethodKey::for($server, 'tools/list'))); + $this->assertInstanceOf(TrackedInitialize::class, Container::getInstance()->make(MethodKey::for($server, 'initialize'))); + $this->assertNotSame(Container::getInstance()->make(MethodKey::for($server, 'tools/list')), Container::getInstance()->make(MethodKey::for($other, 'tools/list'))); + $this->assertSame(MethodKey::for($other, 'tools/list'), ServerAccess::methods($other)['tools/list'] ?? null); + $this->assertSame([], $fixture->lines()); + } + + public function testEntriesThatNameNoMethodClassAreLeftAsTheyAre(): void + { + $server = new class (new LineTransport()) extends Server { + public function __construct(Transport $transport) + { + parent::__construct($transport); + // @phpstan-ignore argument.type (a container key, as another package could bind) + $this->addMethod('tools/list', 'customer.list'); + unset($this->methods['initialize']); + } + }; + + (new LaravelTrackingFixture())->track($server); + + $methods = ServerAccess::methods($server); + $this->assertSame('customer.list', $methods['tools/list'] ?? null); + $this->assertArrayNotHasKey('initialize', $methods); + } + + public function testACustomerInitializeClassIsWrapped(): void + { + $server = new class (new LineTransport()) extends Server { + public function __construct(Transport $transport) + { + parent::__construct($transport); + $this->addMethod('initialize', RecordingMethod::class); + } + }; + + (new LaravelTrackingFixture())->track($server); + + $method = Container::getInstance()->make(MethodKey::for($server, 'initialize')); + $this->assertInstanceOf(TrackedInitialize::class, $method); + $this->assertSame(MethodKey::for($server, 'initialize'), ServerAccess::methods($server)['initialize'] ?? null); + } + + public function testTheServerNameAndVersionComeFromTheServer(): void + { + $server = $this->conformanceServer(); + + $this->assertSame(['conformance-server', '2.0.0'], (LaravelMcpAdapter::serverInfo($server))()); + } + + public function testTheServerInfoHoldsTheServerWeakly(): void + { + $server = $this->conformanceServer(); + $info = LaravelMcpAdapter::serverInfo($server); + + unset($server); + + $this->assertSame([null, null], $info()); + } + + public function testAServerInfoThatCannotBeReadIsUnknown(): void + { + $server = new class (new LineTransport()) extends Server { + #[\Override] + public function createContext(): ServerContext + { + throw new \RuntimeException('no context'); + } + }; + $warning = new class (new LineTransport()) extends Server { + #[\Override] + public function createContext(): ServerContext + { + trigger_error('deprecated context', E_USER_DEPRECATED); + + return parent::createContext(); + } + }; + + $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($server))()); + $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($warning))(), 'a warning counts as a failure'); + } + + private function conformanceServer(): ConformanceServer + { + $server = $this->app?->make(ConformanceServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(ConformanceServer::class, $server); + + return $server; + } +} diff --git a/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php b/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php new file mode 100644 index 0000000..d89985d --- /dev/null +++ b/tests/Integration/LaravelMcp/LaravelMcpLibraryTest.php @@ -0,0 +1,84 @@ +assertTrue($library->supports($server)); + $this->assertFalse($library->supports(new \stdClass())); + $this->assertSame('laravel-mcp/server', $library->sdk()); + $this->assertSame(LaravelMcpAdapter::SUPPORT_MATRIX, $library->supportMatrix()); + $this->assertSame(['laravelMcpServer'], $library->signals($server)); + $this->assertSame([], $library->signals(new \stdClass())); + } + + public function testAServerHasNoLoggerAndNoTrackingStateOfItsOwn(): void + { + $library = new LaravelMcpLibrary(); + + $this->assertNull($library->logger(new ConformanceServer(new LineTransport()))); + $this->assertNull($library->trackedServer(new ConformanceServer(new LineTransport()))); + $this->assertNull($library->trackedServer(new \stdClass())); + } + + public function testTheServerStepsGoToTheAdapter(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $library = new LaravelMcpLibrary(); + $server = new ConformanceServer(new LineTransport()); + $publisher = new Publisher(new EventQueue(transport: new RecordingTransport())); + $state = new TrackedServer('', new Options(), static fn(): array => [null, null], $publisher); + + $library->inspect($server); + $library->install($server, $state, $publisher, new CallInterceptor($publisher)); + + $this->assertSame(['conformance-server', '2.0.0'], $library->serverInfo($server)()); + $this->assertSame(MethodKey::for($server, 'tools/list'), ServerAccess::methods($server)['tools/list'] ?? null); + $this->assertSame([], $logger->messages()); + Thrown::by(\UnexpectedValueException::class, static fn() => $library->inspect(new DriftedServer(new LineTransport()))); + $this->assertSame([LaravelMcpAdapter::COMPATIBILITY_LINE], $logger->messages()); + } + + public function testTheServerStepsRefuseAnyOtherObject(): void + { + $library = new LaravelMcpLibrary(); + $object = new \stdClass(); + $publisher = new Publisher(new EventQueue(transport: new RecordingTransport())); + $state = new TrackedServer('', new Options(), static fn(): array => [null, null], $publisher); + + $steps = [ + 'inspect' => static fn() => $library->inspect($object), + 'serverInfo' => static fn(): \Closure => $library->serverInfo($object), + 'install' => static fn() => $library->install($object, $state, $publisher, new CallInterceptor($publisher)), + ]; + foreach ($steps as $step => $run) { + $this->assertSame('Not a laravel/mcp Server: stdClass', Thrown::by(\UnexpectedValueException::class, $run)->getMessage(), $step); + } + } +} diff --git a/tests/Integration/LaravelMcp/ListToolsTest.php b/tests/Integration/LaravelMcp/ListToolsTest.php new file mode 100644 index 0000000..67c4d00 --- /dev/null +++ b/tests/Integration/LaravelMcp/ListToolsTest.php @@ -0,0 +1,335 @@ + '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []]; + + private const array TOOLS = ['echo', 'structured', 'refuses', 'boom', 'own']; + + #[Group('ac-02-6')] + public function testListedToolsGetTheInjectedParametersAndGetMoreToolsComesLast(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $result = $harness->listTools(); + + $this->assertSame([...self::TOOLS, GetMoreTools::NAME], $this->names($result)); + $schema = JsonAssert::at($result, 'tools', 0, 'inputSchema'); + $this->assertSame(['msg', 'session_id', 'context'], $this->keys(JsonAssert::at($schema, 'properties'))); + $this->assertSame(['msg', 'session_id', 'context'], JsonAssert::at($schema, 'required')); + $this->assertSame(Constants::SESSION_ID_PARAM_PATTERN, JsonAssert::at($schema, 'properties', 'session_id', 'pattern')); + $this->assertSame(GetMoreTools::descriptor()->description, JsonAssert::at($result, 'tools', 5, 'description')); + } + + #[Group('ac-02-8')] + public function testEveryPageGetsGetMoreToolsAndKeepsItsCursor(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedServer::class, $fixture->tracker()); + $twin = (new LaravelHarness(PagedServer::class))->listTools(); + + $first = $harness->listTools(); + $this->assertIsString($first->nextCursor); + $second = $harness->listTools($first->nextCursor); + + $this->assertCount(16, $this->names($first)); // 15 + get_more_tools (D15, B10) + $this->assertSame(GetMoreTools::NAME, $this->names($first)[15]); + $this->assertSame($twin->nextCursor, $first->nextCursor); + $this->assertSame(['t15', GetMoreTools::NAME], $this->names($second)); + $this->assertObjectNotHasProperty('nextCursor', $second); + } + + #[Group('ac-02-11')] + public function testAnEmptyToolListIsServedAsItIs(): void + { + $fixture = new LaravelTrackingFixture(); + $untracked = (new LaravelHarness(EmptyServer::class))->listTools(); + + $tracked = (new LaravelHarness(EmptyServer::class, $fixture->tracker()))->listTools(); + + $this->assertEquals($untracked, $tracked); + $this->assertSame([], $tracked->tools); + $this->assertContains(ToolListInjector::NO_TOOLS_WARNING, $fixture->lines()); + } + + #[Group('ac-02-15')] + public function testAListingPublishesNoEventAndRunsNoHook(): void + { + $fixture = new LaravelTrackingFixture(); + $hooks = 0; + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker(new Options(identify: static function () use (&$hooks): null { + ++$hooks; + + return null; + }))); + + $harness->listTools(); + + $this->assertSame([], $fixture->events()); + $this->assertSame(0, $hooks); + } + + public function testEachServerListsWithItsOwnOptions(): void + { + $fixture = new LaravelTrackingFixture(); + $plain = new LaravelStdioHarness(ConformanceServer::class, $fixture->tracker(new Options(enableReportMissing: false))); + $full = new LaravelStdioHarness(ConformanceServer::class, $fixture->tracker()); + + $tools = $this->names(JsonAssert::at($full->send(self::LIST), 'result')); + $without = $this->names(JsonAssert::at($plain->send(self::LIST), 'result')); + + $this->assertSame([...self::TOOLS, GetMoreTools::NAME], $tools); + $this->assertSame(self::TOOLS, $without); + } + + #[Group('ac-02-9')] + public function testACustomerListHandlerIsWrapped(): void + { + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::result($request->id, [ + 'tools' => [['name' => 'x', 'inputSchema' => ['type' => 'object', 'properties' => new \stdClass()]]], + 'nextCursor' => 'c', + '_meta' => ['source' => 'customer'], + ])); + $twinAnswer = (new LaravelHarness(ListingServer::class))->request('tools/list'); + + $answer = (new LaravelHarness(ListingServer::class, (new LaravelTrackingFixture())->tracker()))->request('tools/list'); + + $this->assertSame(['jsonrpc', 'id', 'result'], $this->keys($answer)); + $this->assertSame(1, $answer->id); + $twin = JsonAssert::at($twinAnswer, 'result'); + $result = JsonAssert::at($answer, 'result'); + $this->assertInstanceOf(\stdClass::class, $twin); + $this->assertInstanceOf(\stdClass::class, $result); + $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); + $this->assertSame($this->keys($twin), $this->keys($result)); + $this->assertSame('c', $result->nextCursor); + $this->assertEquals($twin->_meta, $result->_meta); + } + + #[Group('ac-02-10')] + public function testAThrowingListHandlerGivesTheSameErrorAsWithoutAgentCat(): void + { + $this->answer(static function (): never { + throw new \RuntimeException('list failed'); + }); + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ListingServer::class))->request('tools/list'); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->request('tools/list'); + + $this->assertObjectHasProperty('error', $answer); + $this->assertEquals($twin, $answer); + $this->assertSame([], $fixture->lines()); + } + + public function testAListHandlerTheContainerCannotResolveToAMethodFailsLikeWithoutAgentCat(): void + { + $this->app?->bind(RecordingMethod::class, static fn(): \stdClass => new \stdClass()); + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ListingServer::class))->request('tools/list'); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->request('tools/list'); + + $this->assertEquals((object) ['code' => -32603, 'message' => 'Something went wrong while processing the request.'], JsonAssert::at($answer, 'error')); + $this->assertEquals($twin, $answer); + $this->assertSame([], $fixture->lines()); + $error = Thrown::by(\UnexpectedValueException::class, static fn(): Method => Delegate::of(RecordingMethod::class)); + $this->assertSame('The container resolved ' . RecordingMethod::class . ' to stdClass, which is not a ' . Method::class, $error->getMessage()); + } + + public function testAJsonRpcErrorFromTheListHandlerPassesUnchanged(): void + { + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::error($request->id, -32602, 'bad cursor')); + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ListingServer::class))->request('tools/list'); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->request('tools/list'); + + $this->assertEquals((object) ['code' => -32602, 'message' => 'bad cursor'], JsonAssert::at($answer, 'error')); + $this->assertEquals($twin, $answer); + $this->assertSame([], $fixture->lines(), 'an error is not a listing without tools'); + } + + public function testAStreamedListingPassesUnchanged(): void + { + $this->answer(static fn(JsonRpcRequest $request): array => [ + JsonRpcResponse::notification('notifications/progress', ['progress' => 1]), + JsonRpcResponse::result($request->id, ['tools' => [['name' => 'x', 'inputSchema' => ['type' => 'object']]]]), + ]); + $fixture = new LaravelTrackingFixture(); + $twin = new LaravelStdioHarness(ListingServer::class); + $twin->send(self::LIST); + $harness = new LaravelStdioHarness(ListingServer::class, $fixture->tracker()); + + $harness->send(self::LIST); + + $this->assertCount(2, $harness->lines()); + $this->assertEquals($twin->lines(), $harness->lines()); + $this->assertSame([], $fixture->lines(), 'a stream is not a listing without tools'); + } + + /** @return iterable}> */ + public static function resultsWithoutAToolList(): iterable + { + yield 'no tools member' => [['items' => [['name' => 'x']]]]; + yield 'tools is an object' => [['tools' => ['x' => ['name' => 'x']]]]; + yield 'tools is a string' => [['tools' => 'x']]; + } + + /** @param array $result */ + #[DataProvider('resultsWithoutAToolList')] + public function testAnAnswerWithoutAToolListPassesUnchanged(array $result): void + { + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::result($request->id, $result)); + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ListingServer::class))->request('tools/list'); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->request('tools/list'); + + $this->assertEquals($twin, $answer); + $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $fixture->lines(), 'TS listWrap: `tools` is not an array'); + } + + public function testAFailedInjectionServesTheOriginalListing(): void + { + $tool = new class implements \JsonSerializable { + private bool $served = false; + + /** @return array */ + #[\Override] + public function jsonSerialize(): array + { + if (!$this->served) { + $this->served = true; + + throw new \RuntimeException('tree failed'); + } + + return ['name' => 'flaky', 'inputSchema' => ['type' => 'object']]; + } + }; + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::result($request->id, ['tools' => [$tool]])); + $fixture = new LaravelTrackingFixture(); + + $result = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->listTools(); + + $this->assertSame(['flaky'], $this->names($result)); + $this->assertSame(['Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - tree failed'], $fixture->lines()); + } + + public function testAListingStartsTheDeliveryOfAWaitingEvent(): void + { + $fixture = new LaravelTrackingFixture(); + $fixture->transport->complete = false; + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + $fixture->queue->add(new QueuedEvent(new Event(sessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaaa', projectId: LaravelTrackingFixture::PROJECT, eventType: Constants::TOOLS_CALL_EVENT_TYPE), null, null, [])); + $this->assertSame(['queueLength' => 1, 'activeRequests' => 0], $fixture->queue->stats()); + + $harness->listTools(); + + $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $fixture->queue->stats()); + $this->assertCount(1, $fixture->transport->deliveries); + } + + public function testALegacyInitializeKeepsTheLibrarysAnswer(): void + { + $initialize = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'desktop', 'version' => '0.9']]]; + $untracked = (new LaravelStdioHarness(ConformanceServer::class))->send($initialize); + + $tracked = (new LaravelStdioHarness(ConformanceServer::class, (new LaravelTrackingFixture())->tracker()))->send($initialize); + + $this->assertEquals($untracked, $tracked); + $this->assertSame('2025-06-18', JsonAssert::at($tracked, 'result', 'protocolVersion')); + } + + /** @return iterable */ + public static function clientInfos(): iterable + { + yield 'an object' => [['name' => 'desktop', 'version' => '0.9'], ['name' => 'desktop', 'version' => '0.9']]; + yield 'a string' => ['desktop', null]; + yield 'none' => [null, null]; + } + + #[DataProvider('clientInfos')] + public function testInitializeRecordsTheClientOfTheHandshakeAndAnswersLikeTheLibrary(mixed $clientInfo, mixed $recorded): void + { + $handshake = new HandshakeClient(); + $request = new JsonRpcRequest(1, 'initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => $clientInfo]); + $context = (new ConformanceServer(new LineTransport()))->createContext(); + + $answer = (new TrackedInitialize(Initialize::class, $handshake))->handle($request, $context); + + $this->assertEquals((new Initialize())->handle($request, $context), $answer); + $this->assertSame($recorded, $handshake->clientInfo()); + } + + /** + * Binds the `RecordingMethod` that `ListingServer` names for `tools/list`, with this answer. + * + * @param \Closure(JsonRpcRequest): (iterable|JsonRpcResponse) $answer + */ + private function answer(\Closure $answer): void + { + $this->app?->instance(RecordingMethod::class, new RecordingMethod(answer: $answer)); + } + + /** + * The names of a listing's tools, in order. + * + * @return list + */ + private function names(mixed $result): array + { + $tools = JsonAssert::at($result, 'tools'); + $this->assertIsList($tools); + + return array_map(function (mixed $tool): string { + $name = JsonAssert::at($tool, 'name'); + $this->assertIsString($name); + + return $name; + }, $tools); + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } +} diff --git a/tests/Integration/McpSdk/McpSdkAdapterTest.php b/tests/Integration/McpSdk/McpSdkAdapterTest.php index e370c50..fdc2ced 100644 --- a/tests/Integration/McpSdk/McpSdkAdapterTest.php +++ b/tests/Integration/McpSdk/McpSdkAdapterTest.php @@ -64,17 +64,6 @@ public function testSignalsNameWhatTheObjectIs(): void $this->assertSame([], McpSdkAdapter::signals(new \stdClass())); } - public function testTheFingerprintJoinsTheSignalsWithCommas(): void - { - $builder = Server::builder(); - $this->assertSame('mcpSdkBuilder', McpSdkAdapter::fingerprint($builder)); - - $builder->build(); - - $this->assertSame('mcpSdkBuilder,built', McpSdkAdapter::fingerprint($builder)); - $this->assertSame('', McpSdkAdapter::fingerprint(new \stdClass())); - } - public function testTheVersionIsTheInstalledMcpSdkVersion(): void { $this->assertSame(SdkVersion::installed('mcp/sdk'), McpSdkAdapter::version()); diff --git a/tests/Support/LaravelMcp/ConformanceServer.php b/tests/Support/LaravelMcp/ConformanceServer.php new file mode 100644 index 0000000..b6e476d --- /dev/null +++ b/tests/Support/LaravelMcp/ConformanceServer.php @@ -0,0 +1,31 @@ +tools = [EchoTool::class, StructuredTool::class, RefusesTool::class, BoomTool::class, OwnTool::class]; + } +} diff --git a/tests/Support/LaravelMcp/DriftedServer.php b/tests/Support/LaravelMcp/DriftedServer.php new file mode 100644 index 0000000..4d79d61 --- /dev/null +++ b/tests/Support/LaravelMcp/DriftedServer.php @@ -0,0 +1,22 @@ + $serverClass + * @param (\Closure(Server): void)|null $track runs on each new server before `start()` + */ + public function __construct(private readonly string $serverClass, private readonly ?\Closure $track = null) {} + + /** + * One JSON-RPC request; the whole answer (`result` or `error`). + * + * @param array $params + * @param array $headers + */ + public function request(string $method, array $params = [], array $headers = []): \stdClass + { + $body = json_encode(['jsonrpc' => '2.0', 'id' => $this->nextId++, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params], WireEncoder::FLAGS); + $server = ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'localhost']; + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + $transport = new HttpTransport(Request::create('/mcp/conformance', 'POST', server: $server, content: $body)); + $instance = Container::getInstance()->make($this->serverClass, ['transport' => $transport]); + Assert::assertInstanceOf(Server::class, $instance); + if ($this->track instanceof \Closure) { + ($this->track)($instance); + } + $instance->start(); + $answer = Json::decode((string) $transport->run()->getContent()); + Assert::assertInstanceOf(\stdClass::class, $answer); + + return $answer; + } + + /** The `result` of a `tools/list` request; fails the test on a JSON-RPC error. */ + public function listTools(?string $cursor = null): \stdClass + { + $result = $this->request('tools/list', $cursor === null ? [] : ['cursor' => $cursor])->result ?? null; + Assert::assertInstanceOf(\stdClass::class, $result); + + return $result; + } + + /** + * A 2026-07-28 `tools/call` (laravel/mcp's default era) with this client info in `_meta`. + * + * @param array $arguments + * @param array $meta more `_meta` keys + * @param array $headers + * @param array{name: string, version: string}|null $clientInfo + */ + public function callTool(string $name, array $arguments, array $meta = [], array $headers = [], ?array $clientInfo = ['name' => 'modern-client', 'version' => '2.0.0']): \stdClass + { + return $this->request('tools/call', ['name' => $name, 'arguments' => (object) $arguments, '_meta' => self::meta($meta, $clientInfo)], $headers); + } + + /** + * `_meta` of a 2026-07-28 request: the protocol version, client capabilities and client info. + * + * @param array $meta + * @param array{name: string, version: string}|null $clientInfo + * + * @return array + */ + public static function meta(array $meta, ?array $clientInfo): array + { + $meta['io.modelcontextprotocol/protocolVersion'] ??= self::MODERN_PROTOCOL_VERSION; + $meta['io.modelcontextprotocol/clientCapabilities'] ??= new \stdClass(); + if ($clientInfo !== null) { + $meta['io.modelcontextprotocol/clientInfo'] ??= $clientInfo; // a client info the test passes in $meta wins + } + + return $meta; + } +} diff --git a/tests/Support/LaravelMcp/LaravelStdioHarness.php b/tests/Support/LaravelMcp/LaravelStdioHarness.php new file mode 100644 index 0000000..9b938e3 --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelStdioHarness.php @@ -0,0 +1,61 @@ + $serverClass + * @param (\Closure(Server): void)|null $track runs on the server before `start()` + */ + public function __construct(string $serverClass, ?\Closure $track = null) + { + $this->transport = new LineTransport(); + $server = Container::getInstance()->make($serverClass, ['transport' => $this->transport]); + Assert::assertInstanceOf(Server::class, $server); + if ($track instanceof \Closure) { + $track($server); + } + $server->start(); // the transport keeps the server's handler, so the server lives as long as this harness + } + + /** + * Hands one message to the server. + * + * @param array $message + * + * @return ?\stdClass the last line the server wrote for it, decoded; null when it wrote none + */ + public function send(array $message): ?\stdClass + { + return $this->transport->deliver($message); + } + + /** + * Every line the server wrote, decoded, in order: a streamed answer writes several. + * + * @return list<\stdClass> + */ + public function lines(): array + { + return array_map(static function (string $line): \stdClass { + $message = Json::decode($line); + Assert::assertInstanceOf(\stdClass::class, $message); + + return $message; + }, $this->transport->sent); + } +} diff --git a/tests/Support/LaravelMcp/LaravelTrackingFixture.php b/tests/Support/LaravelMcp/LaravelTrackingFixture.php new file mode 100644 index 0000000..5e7a051 --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelTrackingFixture.php @@ -0,0 +1,37 @@ +publisher); + LaravelMcpAdapter::install($server, $state, $this->publisher, new CallInterceptor($this->publisher)); + + return $state; + } + + /** @return \Closure(Server): void that tracks each server of a harness with these options */ + public function tracker(Options $options = new Options()): \Closure + { + return function (Server $server) use ($options): void { + $this->track($server, $options); + }; + } +} diff --git a/tests/Support/LaravelMcp/ListingServer.php b/tests/Support/LaravelMcp/ListingServer.php new file mode 100644 index 0000000..773eea8 --- /dev/null +++ b/tests/Support/LaravelMcp/ListingServer.php @@ -0,0 +1,28 @@ +tools = [EchoTool::class]; + $this->addMethod('tools/list', RecordingMethod::class); + } +} diff --git a/tests/Support/LaravelMcp/PagedServer.php b/tests/Support/LaravelMcp/PagedServer.php new file mode 100644 index 0000000..eafcdb5 --- /dev/null +++ b/tests/Support/LaravelMcp/PagedServer.php @@ -0,0 +1,28 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 15))]; + } +} diff --git a/tests/Support/LaravelMcp/RecordingMethod.php b/tests/Support/LaravelMcp/RecordingMethod.php index f3cef1b..2c2f1af 100644 --- a/tests/Support/LaravelMcp/RecordingMethod.php +++ b/tests/Support/LaravelMcp/RecordingMethod.php @@ -9,7 +9,7 @@ use Laravel\Mcp\Transport\JsonRpcRequest; use Laravel\Mcp\Transport\JsonRpcResponse; -/** A laravel/mcp method handler that records every request and answers `{"ok": true}`. */ +/** A laravel/mcp method handler that records every request and answers `{"ok": true}`, or what `$answer` returns. */ final class RecordingMethod implements Method { /** @var list the requests, in order */ @@ -18,17 +18,21 @@ final class RecordingMethod implements Method /** @var list what `$observe` returned in each call, in order */ public array $seen = []; - /** @param (\Closure(): mixed)|null $observe runs inside every call, while laravel/mcp runs the method */ - public function __construct(private readonly ?\Closure $observe = null) {} + /** + * @param (\Closure(): mixed)|null $observe runs inside every call, while laravel/mcp runs the method + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $answer the answer to each + * request; it may throw + */ + public function __construct(private readonly ?\Closure $observe = null, private readonly ?\Closure $answer = null) {} #[\Override] - public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse { $this->requests[] = $request; if ($this->observe instanceof \Closure) { $this->seen[] = ($this->observe)(); } - return JsonRpcResponse::result($request->id, ['ok' => true]); + return $this->answer instanceof \Closure ? ($this->answer)($request) : JsonRpcResponse::result($request->id, ['ok' => true]); } } diff --git a/tests/Support/LaravelMcp/Tools/BoomTool.php b/tests/Support/LaravelMcp/Tools/BoomTool.php new file mode 100644 index 0000000..170752d --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/BoomTool.php @@ -0,0 +1,24 @@ +all()); + + throw new \RuntimeException('tool boom', 0, new \LogicException('cause')); + } +} diff --git a/tests/Support/LaravelMcp/Tools/EchoTool.php b/tests/Support/LaravelMcp/Tools/EchoTool.php new file mode 100644 index 0000000..bef284a --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/EchoTool.php @@ -0,0 +1,36 @@ +`. The attributes name the tools of these tests: overriding + * `$name` would need `#[\Override]` on a property, which PHP accepts only from 8.5 (see `EmptyServer`). + */ +#[Name('echo')] +#[Description('Echo')] +final class EchoTool extends Tool +{ + /** @return array */ + #[\Override] + public function schema(JsonSchema $schema): array + { + return ['msg' => $schema->string()->required()]; + } + + public function handle(Request $request): Response + { + ToolCalls::record('echo', $request->all()); + + return Response::text('echo: ' . (is_string($request->get('msg')) ? $request->get('msg') : '')); + } +} diff --git a/tests/Support/LaravelMcp/Tools/NamedTool.php b/tests/Support/LaravelMcp/Tools/NamedTool.php new file mode 100644 index 0000000..dad4b76 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/NamedTool.php @@ -0,0 +1,26 @@ +name = $name; + } + + public function handle(Request $request): Response + { + ToolCalls::record($this->name, $request->all()); + + return Response::text('ok'); + } +} diff --git a/tests/Support/LaravelMcp/Tools/OwnTool.php b/tests/Support/LaravelMcp/Tools/OwnTool.php new file mode 100644 index 0000000..c8e9fd9 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/OwnTool.php @@ -0,0 +1,33 @@ + */ + #[\Override] + public function schema(JsonSchema $schema): array + { + return ['session_id' => $schema->string()->required()]; + } + + public function handle(Request $request): Response + { + ToolCalls::record('own', $request->all()); + + return Response::text('ok'); + } +} diff --git a/tests/Support/LaravelMcp/Tools/RefusesTool.php b/tests/Support/LaravelMcp/Tools/RefusesTool.php new file mode 100644 index 0000000..24c623a --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/RefusesTool.php @@ -0,0 +1,25 @@ +all()); + + return Response::error('not allowed'); + } +} diff --git a/tests/Support/LaravelMcp/Tools/StructuredTool.php b/tests/Support/LaravelMcp/Tools/StructuredTool.php new file mode 100644 index 0000000..1372792 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/StructuredTool.php @@ -0,0 +1,34 @@ + */ + #[\Override] + public function outputSchema(JsonSchema $schema): array + { + return ['echoed' => $schema->string()]; + } + + public function handle(Request $request): ResponseFactory + { + ToolCalls::record('structured', $request->all()); + + return Response::make(Response::text('x'))->withStructuredContent(['echoed' => 'x']); + } +} diff --git a/tests/Support/McpSdk/TrackingFixture.php b/tests/Support/McpSdk/TrackingFixture.php index 982d861..111ec8f 100644 --- a/tests/Support/McpSdk/TrackingFixture.php +++ b/tests/Support/McpSdk/TrackingFixture.php @@ -4,20 +4,13 @@ namespace AgentCat\Tests\Support\McpSdk; -use AgentCat\Event; -use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Engine\CallInterceptor; -use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; -use AgentCat\Internal\Logging\Log; use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\Options; -use AgentCat\Tests\Support\MemoryLogger; -use AgentCat\Tests\Support\RecordingExporter; -use AgentCat\Tests\Support\RecordingTransport; +use AgentCat\Tests\Support\RecordsEvents; use Mcp\Server; use Mcp\Server\Builder; -use PHPUnit\Framework\Assert; /** * Tracks mcp/sdk Builders the way `track()` does, into a queue that records what it delivers. Constructing it routes @@ -25,27 +18,7 @@ */ final readonly class TrackingFixture { - public const string PROJECT = 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa'; - - public MemoryLogger $logger; - - public RecordingTransport $transport; - - public RecordingExporter $exporter; - - public EventQueue $queue; - - public Publisher $publisher; - - public function __construct() - { - $this->logger = new MemoryLogger(); - Log::setLogger($this->logger); - $this->transport = new RecordingTransport(); - $this->exporter = new RecordingExporter(); - $this->queue = new EventQueue(transport: $this->transport, exporters: $this->exporter); - $this->publisher = new Publisher($this->queue); - } + use RecordsEvents; /** A Builder for the server `harness-server` 1.2.3. */ public static function builder(): Builder @@ -69,37 +42,4 @@ public function harness(Builder $builder): HttpHarness return $harness; } - - /** - * Delivers everything queued, then returns every exported event, in order. - * - * @return list - */ - public function events(): array - { - Assert::assertTrue($this->queue->flush(), 'flush'); - - return $this->exporter->events; - } - - /** Delivers everything queued and returns the event at `$index`; fails unless exactly `$of` events were delivered. */ - public function event(int $index = 0, int $of = 1): Event - { - $events = $this->events(); - Assert::assertCount($of, $events); - $event = $events[$index] ?? null; - Assert::assertInstanceOf(Event::class, $event); - - return $event; - } - - /** - * The log lines, in order. - * - * @return list - */ - public function lines(): array - { - return $this->logger->messages(); - } } diff --git a/tests/Support/RecordsEvents.php b/tests/Support/RecordsEvents.php new file mode 100644 index 0000000..415c380 --- /dev/null +++ b/tests/Support/RecordsEvents.php @@ -0,0 +1,73 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + $this->transport = new RecordingTransport(); + $this->exporter = new RecordingExporter(); + $this->queue = new EventQueue(transport: $this->transport, exporters: $this->exporter); + $this->publisher = new Publisher($this->queue); + } + + /** + * Delivers everything queued, then returns every exported event, in order. + * + * @return list + */ + public function events(): array + { + Assert::assertTrue($this->queue->flush(), 'flush'); + + return $this->exporter->events; + } + + /** Delivers everything queued and returns the event at `$index`; fails unless exactly `$of` events were delivered. */ + public function event(int $index = 0, int $of = 1): Event + { + $events = $this->events(); + Assert::assertCount($of, $events); + $event = $events[$index] ?? null; + Assert::assertInstanceOf(Event::class, $event); + + return $event; + } + + /** + * The log lines, in order. + * + * @return list + */ + public function lines(): array + { + return $this->logger->messages(); + } +} diff --git a/tests/Unit/Internal/Engine/LibrariesTest.php b/tests/Unit/Internal/Engine/LibrariesTest.php index 2333492..a78153d 100644 --- a/tests/Unit/Internal/Engine/LibrariesTest.php +++ b/tests/Unit/Internal/Engine/LibrariesTest.php @@ -5,6 +5,9 @@ namespace AgentCat\Tests\Unit\Internal\Engine; use AgentCat\Internal\Engine\Libraries; +use AgentCat\LaravelMcp\LaravelMcpAdapter; +use AgentCat\LaravelMcp\LaravelMcpLibrary; +use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\McpSdk\McpSdkLibrary; use Mcp\Server; use PHPUnit\Framework\Attributes\Group; @@ -12,9 +15,13 @@ final class LibrariesTest extends TestCase { - public function testTheMcpSdkLibraryIsAskedFirst(): void + public function testTheMcpSdkLibraryIsAskedFirstAndTheLaravelOneSecond(): void { - $this->assertInstanceOf(McpSdkLibrary::class, Libraries::all()[0] ?? null); + $libraries = Libraries::all(); + + $this->assertCount(2, $libraries); + $this->assertInstanceOf(McpSdkLibrary::class, $libraries[0]); + $this->assertInstanceOf(LaravelMcpLibrary::class, $libraries[1]); } public function testABuilderGoesToTheMcpSdkLibrary(): void @@ -26,16 +33,21 @@ public function testABuilderGoesToTheMcpSdkLibrary(): void #[Group('ac-02-2')] public function testAnythingElseHasNoLibraryAndKeepsItsSignals(): void { + $built = Server::builder(); + $built->build(); + $this->assertNull(Libraries::for(new \stdClass())); $this->assertSame('', Libraries::fingerprint(new \stdClass())); $this->assertSame('mcpSdkServer', Libraries::fingerprint(Server::builder()->build())); + $this->assertSame('mcpSdkBuilder,built', Libraries::fingerprint($built)); $this->assertNull(Libraries::trackedServer(new \stdClass())); } public function testTheSupportMatrixHasOneSentencePerLibrary(): void { + $this->assertSame(McpSdkAdapter::SUPPORT_MATRIX . ' ' . LaravelMcpAdapter::SUPPORT_MATRIX, Libraries::supportMatrix()); $this->assertSame( - 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).', + 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()). AgentCat supports laravel/mcp 1.0+ (the Laravel service provider tracks every server).', Libraries::supportMatrix(), ); } diff --git a/tests/Unit/Internal/Json/JsonTreeTest.php b/tests/Unit/Internal/Json/JsonTreeTest.php new file mode 100644 index 0000000..143575c --- /dev/null +++ b/tests/Unit/Internal/Json/JsonTreeTest.php @@ -0,0 +1,108 @@ + [], 'b' => (object) []]); + + $this->assertEquals(Json::decode('{"a":[],"b":{}}'), $tree); + $this->assertSame('{"a":[],"b":{}}', json_encode($tree)); + } + + public function testATreeHasObjectsAsStdClassAndArraysAsLists(): void + { + $tree = JsonTree::of(['a' => ['b' => 1], 'list' => [1, 2], 'empty' => []]); + + $this->assertEquals((object) ['a' => (object) ['b' => 1], 'list' => [1, 2], 'empty' => []], $tree); + } + + public function testInvalidUtf8BecomesTheReplacementCharacter(): void + { + $tree = JsonTree::of(['text' => "a\xB1b"]); + + $this->assertInstanceOf(\stdClass::class, $tree); + $this->assertSame("a\u{FFFD}b", $tree->text); + } + + public function testAValueJsonCannotCarryThrows(): void + { + $error = Thrown::by(\JsonException::class, static fn(): mixed => JsonTree::of(['n' => NAN])); + + $this->assertSame('Inf and NaN cannot be JSON encoded', $error->getMessage()); + } + + public function testAnEmptyArrayIsAnEmptyObject(): void + { + $this->assertEquals(new \stdClass(), JsonTree::object([])); + } + + public function testIntegerKeysStayKeys(): void + { + $tree = JsonTree::object([0 => 'zero']); + + $this->assertSame('{"0":"zero"}', json_encode($tree)); + $this->assertSame('zero', $tree->{'0'}); + } + + public function testSequentialIntegerKeysStayAnObjectAndNestedValuesAreEncodedAsTheyAre(): void + { + $tree = JsonTree::object(['a', 'b', 'n' => ['a' => [], 'list' => [1]]]); + + $this->assertSame('{"0":"a","1":"b","n":{"a":[],"list":[1]}}', json_encode($tree)); + } + + public function testAnObjectJsonCannotCarryIsAnEmptyObject(): void + { + $this->assertEquals(new \stdClass(), JsonTree::object(['x' => NAN])); + $this->assertEquals(new \stdClass(), JsonTree::object(['x' => new class implements \JsonSerializable { + #[\Override] + public function jsonSerialize(): never + { + throw new \RuntimeException('no tree'); + } + }])); + } + + public function testAnEmptyListAsStructuredContentBecomesAnEmptyObject(): void + { + $tree = JsonTree::result(['content' => [['type' => 'text', 'text' => 'x']], 'structuredContent' => [], 'isError' => false]); + + $this->assertInstanceOf(\stdClass::class, $tree); + $this->assertSame('{"content":[{"type":"text","text":"x"}],"structuredContent":{},"isError":false}', json_encode($tree)); + } + + /** @return iterable */ + public static function results(): iterable + { + yield 'a structured list' => [['structuredContent' => [1, 2]], '{"structuredContent":[1,2]}']; + yield 'a structured object' => [['structuredContent' => ['echoed' => 'x']], '{"structuredContent":{"echoed":"x"}}']; + yield 'an empty object' => [['structuredContent' => new \stdClass()], '{"structuredContent":{}}']; + yield 'no structured content' => [['content' => []], '{"content":[]}']; + yield 'a structured null' => [['structuredContent' => null], '{"structuredContent":null}']; + yield 'an empty list' => [[], '[]']; + yield 'a list' => [[['structuredContent' => []]], '[{"structuredContent":[]}]']; + yield 'text' => ['text', '"text"']; + } + + #[DataProvider('results')] + public function testEveryOtherResultStaysAsItIs(mixed $result, string $json): void + { + $this->assertSame($json, json_encode(JsonTree::result($result))); + } + + public function testAResultJsonCannotCarryThrows(): void + { + Thrown::by(\JsonException::class, static fn(): mixed => JsonTree::result(['structuredContent' => NAN])); + } +} diff --git a/tests/Unit/LaravelMcp/HandshakeClientTest.php b/tests/Unit/LaravelMcp/HandshakeClientTest.php new file mode 100644 index 0000000..54e1230 --- /dev/null +++ b/tests/Unit/LaravelMcp/HandshakeClientTest.php @@ -0,0 +1,65 @@ +assertNull((new HandshakeClient())->clientInfo()); + } + + /** @return iterable */ + public static function clients(): iterable + { + yield 'an array' => [['name' => 'desktop', 'version' => '0.9']]; + yield 'an object' => [(object) ['name' => 'desktop']]; + yield 'an empty array' => [[]]; + } + + #[DataProvider('clients')] + public function testAnObjectLikeClientInfoIsKept(mixed $clientInfo): void + { + $handshake = new HandshakeClient(); + + $handshake->record($clientInfo); + + $this->assertSame($clientInfo, $handshake->clientInfo()); + } + + /** @return iterable */ + public static function notClients(): iterable + { + yield 'null' => [null]; + yield 'a string' => ['desktop']; + yield 'a number' => [1]; + yield 'a closure' => [static fn(): null => null]; + } + + #[DataProvider('notClients')] + public function testAnythingElseKeepsTheEarlierClient(mixed $other): void + { + $handshake = new HandshakeClient(); + $handshake->record(['name' => 'desktop', 'version' => '0.9']); + + $handshake->record($other); + + $this->assertSame(['name' => 'desktop', 'version' => '0.9'], $handshake->clientInfo()); + } + + public function testALaterHandshakeReplacesTheClient(): void + { + $handshake = new HandshakeClient(); + $handshake->record(['name' => 'desktop']); + + $handshake->record(['name' => 'cli']); + + $this->assertSame(['name' => 'cli'], $handshake->clientInfo()); + } +} diff --git a/tests/Unit/LaravelMcp/LaravelTreesTest.php b/tests/Unit/LaravelMcp/LaravelTreesTest.php new file mode 100644 index 0000000..c04244f --- /dev/null +++ b/tests/Unit/LaravelMcp/LaravelTreesTest.php @@ -0,0 +1,133 @@ + [['name' => 'echo', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]]], 'nextCursor' => 'c']); + + $tools = LaravelTrees::tools($response); + + $this->assertEquals([(object) ['name' => 'echo', 'inputSchema' => (object) ['type' => 'object', 'properties' => new \stdClass()]]], $tools); + $this->assertSame([], LaravelTrees::tools(JsonRpcResponse::result(1, ['tools' => []]))); + } + + /** @return iterable */ + public static function notListings(): iterable + { + yield 'not a response' => [['result' => ['tools' => []]]]; + yield 'an error' => [JsonRpcResponse::error(1, -32602, 'bad cursor')]; + yield 'an empty result' => [JsonRpcResponse::result(1, [])]; + yield 'a result object' => [new JsonRpcResponse(['id' => 1, 'result' => (object) ['tools' => []]])]; + yield 'no tools member' => [JsonRpcResponse::result(1, ['items' => []])]; + yield 'tools is an object' => [JsonRpcResponse::result(1, ['tools' => ['echo' => ['name' => 'echo']]])]; + yield 'tools is a string' => [JsonRpcResponse::result(1, ['tools' => 'echo'])]; + } + + #[DataProvider('notListings')] + public function testAnythingElseHasNoTools(mixed $response): void + { + $this->assertNull(LaravelTrees::tools($response)); + } + + public function testAResultIsItsJsonTreeWithAnEmptyStructuredContentAsAnObject(): void + { + $response = JsonRpcResponse::result(1, ['content' => [['type' => 'text', 'text' => 'x']], 'isError' => false, 'structuredContent' => []]); + + $this->assertSame('{"content":[{"type":"text","text":"x"}],"isError":false,"structuredContent":{}}', json_encode(LaravelTrees::result($response))); + $this->assertSame('{"structuredContent":[1]}', json_encode(LaravelTrees::result(JsonRpcResponse::result(1, ['structuredContent' => [1]])))); + $this->assertNull(LaravelTrees::result(JsonRpcResponse::error(1, -32603, 'boom'))); + } + + public function testAResultJsonCannotCarryThrows(): void + { + $this->expectException(\JsonException::class); + + LaravelTrees::result(JsonRpcResponse::result(1, ['n' => NAN])); + } + + /** @return iterable */ + public static function natives(): iterable + { + yield 'an object' => ['{"a":1,"b":{"c":[1,{"d":2}]}}', ['a' => 1, 'b' => ['c' => [1, ['d' => 2]]]]]; + yield 'a list' => ['[{"a":1},2]', [['a' => 1], 2]]; + yield 'a scalar' => ['"x"', 'x']; + yield 'null' => ['null', null]; + yield 'keys out of order' => ['{"1":"b","0":"a"}', [1 => 'b', 0 => 'a']]; + } + + #[DataProvider('natives')] + public function testATreeBecomesLaravelArrays(string $json, mixed $native): void + { + $this->assertSame($native, LaravelTrees::native(Json::decode($json))); + } + + /** @return iterable */ + public static function objectsAnArrayWouldChange(): iterable + { + yield 'an empty object' => ['{}']; + yield 'list keys' => ['{"0":"a","1":"b"}']; + yield 'nested' => ['{"a":{},"b":[{}],"c":{"0":{"x":{}}}}']; + } + + #[DataProvider('objectsAnArrayWouldChange')] + public function testObjectsAnArrayWouldChangeStayObjects(string $json): void + { + $native = LaravelTrees::native(Json::decode($json)); + + $this->assertSame($json, json_encode($native)); + } + + public function testNestedObjectsBecomeArraysUnderAnObjectThatStays(): void + { + $native = LaravelTrees::native(Json::decode('{"0":{"a":{"b":1}}}')); + + $this->assertInstanceOf(\stdClass::class, $native); + $this->assertSame(['a' => ['b' => 1]], $native->{'0'}); + } + + public function testAResultIsAnArrayWithItsMetaAnArray(): void + { + $tree = Json::decode('{"content":[{"type":"text","text":"x"}],"structuredContent":{},"_meta":{"k":{"v":1}}}'); + $this->assertInstanceOf(\stdClass::class, $tree); + + $result = LaravelTrees::nativeResult($tree); + + $this->assertSame(['type' => 'text', 'text' => 'x'], JsonAssert::at($result, 'content', 0)); + $this->assertEquals(new \stdClass(), JsonAssert::at($result, 'structuredContent')); + $meta = JsonAssert::at($result, '_meta'); + $this->assertIsArray($meta); + $meta['io.modelcontextprotocol/serverInfo'] = ['name' => 's']; // what Server::send() does with result['_meta'] + $this->assertSame(['k' => ['v' => 1], 'io.modelcontextprotocol/serverInfo' => ['name' => 's']], $meta); + } + + /** @return iterable}> */ + public static function results(): iterable + { + yield 'an empty meta' => ['{"content":[],"_meta":{}}', ['content' => [], '_meta' => []]]; + yield 'a list-keyed meta' => ['{"_meta":{"0":"a"}}', ['_meta' => [0 => 'a']]]; + yield 'a list meta' => ['{"_meta":[1]}', ['_meta' => [1]]]; + yield 'no meta' => ['{"isError":true}', ['isError' => true]]; + } + + /** @param array $expected */ + #[DataProvider('results')] + public function testAResultsMetaIsAlwaysAnArray(string $json, array $expected): void + { + $tree = Json::decode($json); + $this->assertInstanceOf(\stdClass::class, $tree); + + $this->assertSame($expected, LaravelTrees::nativeResult($tree)); + } +} diff --git a/tests/Unit/McpSdk/TreesTest.php b/tests/Unit/McpSdk/TreesTest.php index e0f15dd..9207e94 100644 --- a/tests/Unit/McpSdk/TreesTest.php +++ b/tests/Unit/McpSdk/TreesTest.php @@ -5,7 +5,6 @@ namespace AgentCat\Tests\Unit\McpSdk; use AgentCat\McpSdk\Internal\Trees; -use AgentCat\Tests\Support\Thrown; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\ResultInterface; @@ -19,50 +18,9 @@ final class TreesTest extends TestCase { public function testOfTurnsLibraryValuesIntoJsonTrees(): void { - $tree = Trees::of(['a' => ['b' => 1], 'list' => [1, 2], 'empty' => []]); + $tree = Trees::of(['content' => [new TextContent('x')], 'empty' => []]); - $this->assertEquals((object) ['a' => (object) ['b' => 1], 'list' => [1, 2], 'empty' => []], $tree); - } - - public function testOfReplacesInvalidUtf8WithTheReplacementCharacter(): void - { - $tree = Trees::of(['text' => "a\xB1b"]); - - $this->assertInstanceOf(\stdClass::class, $tree); - $this->assertSame("a\u{FFFD}b", $tree->text); - } - - public function testOfThrowsForAValueJsonCannotCarry(): void - { - Thrown::by(\JsonException::class, static fn(): mixed => Trees::of(['n' => NAN])); - } - - public function testEmptyArgumentsAreAnEmptyObject(): void - { - $this->assertEquals(new \stdClass(), Trees::arguments([])); - } - - public function testArgumentsKeepIntegerKeysAndNestedEmptyLists(): void - { - $tree = Trees::arguments(['0' => 'zero', 'n' => ['a' => []]]); - - $this->assertInstanceOf(\stdClass::class, $tree); - $this->assertSame(['0', 'n'], array_map(strval(...), array_keys(get_object_vars($tree)))); - $this->assertSame('zero', $tree->{'0'}); - $this->assertEquals((object) ['a' => []], $tree->n); - } - - public function testArgumentsWithOnlySequentialIntegerKeysStayAnObject(): void - { - $tree = Trees::arguments(['a', 'b']); - - $this->assertInstanceOf(\stdClass::class, $tree); - $this->assertSame('{"0":"a","1":"b"}', json_encode($tree)); - } - - public function testArgumentsJsonCannotCarryBecomeAnEmptyObject(): void - { - $this->assertEquals(new \stdClass(), Trees::arguments(['msg' => 'hi', 'n' => NAN])); + $this->assertEquals((object) ['content' => [(object) ['type' => 'text', 'text' => 'x']], 'empty' => []], $tree); } public function testAnEmptyListAsStructuredContentBecomesAnEmptyObject(): void From 623bef2702e6c2243fe58d6ec9f1204202d9edbb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 18:45:50 +0000 Subject: [PATCH 07/30] feat: track laravel/mcp tool calls, their exceptions and their streamed answers --- rector.php | 4 - src/Internal/Engine/CallInterceptor.php | 37 ++ src/Internal/Engine/CallSeam.php | 13 + src/Internal/Engine/CallTrees.php | 82 ++++ src/Internal/Engine/ToolListInjector.php | 2 +- src/Internal/Engine/ToolListing.php | 6 + src/LaravelMcp/Internal/CapturingCallTool.php | 46 ++ .../Internal/CapturingToolInvoker.php | 42 ++ src/LaravelMcp/Internal/Delegate.php | 21 +- src/LaravelMcp/Internal/Drift.php | 32 ++ src/LaravelMcp/Internal/LaravelCallSeam.php | 206 ++++++++ .../Internal/LaravelHttpContext.php | 31 ++ .../Internal/LaravelRequestReader.php | 79 +++ src/LaravelMcp/Internal/TrackedCallTool.php | 64 +++ src/LaravelMcp/Internal/TrackedInitialize.php | 2 +- src/LaravelMcp/Internal/TrackedListTools.php | 6 +- src/LaravelMcp/LaravelMcpAdapter.php | 59 ++- src/McpSdk/Internal/ListToolsInterceptor.php | 4 +- src/McpSdk/Internal/McpCallSeam.php | 7 + src/McpSdk/Internal/RequestReader.php | 37 +- .../LaravelMcp/ExtendedClassesTest.php | 146 ++++++ tests/Contract/ManifestTest.php | 2 + tests/Contract/McpSdk/ExtendedClassesTest.php | 20 +- tests/Contract/PrivateMemberManifest.php | 16 +- tests/Integration/LaravelMcp/CallToolTest.php | 455 ++++++++++++++++++ .../LaravelMcp/LaravelMcpAdapterTest.php | 89 +++- .../Integration/LaravelMcp/ListToolsTest.php | 25 +- tests/Support/FakeCallSeam.php | 14 + tests/Support/LaravelMcp/CustomCallServer.php | 28 ++ tests/Support/LaravelMcp/CustomCallTool.php | 43 ++ .../LaravelMcp/RecordingCallServer.php | 27 ++ .../Support/LaravelMcp/RequestEchoServer.php | 23 + tests/Support/LaravelMcp/StreamingServer.php | 24 + tests/Support/LaravelMcp/ToolCalls.php | 2 + .../LaravelMcp/Tools/FailingStreamTool.php | 27 ++ .../LaravelMcp/Tools/RequestEchoTool.php | 27 ++ .../LaravelMcp/Tools/StreamingTool.php | 29 ++ .../Internal/Engine/CallInterceptorTest.php | 97 ++++ tests/Unit/Internal/Engine/CallTreesTest.php | 78 +++ tests/Unit/LaravelMcp/LaravelCallSeamTest.php | 276 +++++++++++ .../LaravelMcp/LaravelHttpContextTest.php | 36 ++ .../LaravelMcp/LaravelRequestReaderTest.php | 102 ++++ 42 files changed, 2289 insertions(+), 77 deletions(-) create mode 100644 src/Internal/Engine/CallTrees.php create mode 100644 src/LaravelMcp/Internal/CapturingCallTool.php create mode 100644 src/LaravelMcp/Internal/CapturingToolInvoker.php create mode 100644 src/LaravelMcp/Internal/Drift.php create mode 100644 src/LaravelMcp/Internal/LaravelCallSeam.php create mode 100644 src/LaravelMcp/Internal/LaravelHttpContext.php create mode 100644 src/LaravelMcp/Internal/LaravelRequestReader.php create mode 100644 src/LaravelMcp/Internal/TrackedCallTool.php create mode 100644 tests/Contract/LaravelMcp/ExtendedClassesTest.php create mode 100644 tests/Integration/LaravelMcp/CallToolTest.php create mode 100644 tests/Support/LaravelMcp/CustomCallServer.php create mode 100644 tests/Support/LaravelMcp/CustomCallTool.php create mode 100644 tests/Support/LaravelMcp/RecordingCallServer.php create mode 100644 tests/Support/LaravelMcp/RequestEchoServer.php create mode 100644 tests/Support/LaravelMcp/StreamingServer.php create mode 100644 tests/Support/LaravelMcp/Tools/FailingStreamTool.php create mode 100644 tests/Support/LaravelMcp/Tools/RequestEchoTool.php create mode 100644 tests/Support/LaravelMcp/Tools/StreamingTool.php create mode 100644 tests/Unit/Internal/Engine/CallTreesTest.php create mode 100644 tests/Unit/LaravelMcp/LaravelCallSeamTest.php create mode 100644 tests/Unit/LaravelMcp/LaravelHttpContextTest.php create mode 100644 tests/Unit/LaravelMcp/LaravelRequestReaderTest.php diff --git a/rector.php b/rector.php index 951f4e3..942987e 100644 --- a/rector.php +++ b/rector.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPublicMethodParameterRector; use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; use Rector\PHPUnit\CodeQuality\Rector\ClassMethod\NoSetupWithParentCallOverrideRector; @@ -25,8 +24,5 @@ // Names test-runner classes as strings: PHPUnit and Pest are not dependencies of the SDK (D31). AfterRequest // names the Octane event class, which is not a dependency either. StringClassNameToClassConstantRector::class => [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Laravel/Internal/AfterRequest.php'], - // LaravelMcpAdapter::install() keeps the parameters of Library::install(): it does not wire tools/call yet, and - // that wiring takes the call interceptor. - RemoveUnusedPublicMethodParameterRector::class => [__DIR__ . '/src/LaravelMcp/LaravelMcpAdapter.php'], ]) ->withCache(__DIR__ . '/.cache/rector'); diff --git a/src/Internal/Engine/CallInterceptor.php b/src/Internal/Engine/CallInterceptor.php index 962ea23..393e6b3 100644 --- a/src/Internal/Engine/CallInterceptor.php +++ b/src/Internal/Engine/CallInterceptor.php @@ -87,9 +87,46 @@ public function intercept(TrackedServer $server, CallSeam $seam): mixed throw $e; } + $later = $this->later($call, $seam, $result); + if ($later !== null) { + return $later; + } + return $this->finish($call, $seam, $result, null); } + /** + * A result that completes later (a stream) is finished when it completes; null for an ordinary result. A seam + * that fails here leaves the result to an ordinary finish. + * + * @template TResult + * + * @param CallSeam $seam + * @param TResult $result + * + * @return TResult|null + */ + private function later(TracedCall $call, CallSeam $seam, mixed $result): mixed + { + try { + return $seam->later( + $result, + fn(mixed $final): mixed => $this->finish($call, $seam, $final, null), + function (\Throwable $e) use ($call, $seam): void { + try { + $this->publishFailure($call, $e); + } catch (\Throwable $failure) { + Log::write(sprintf(self::POST_HANDLER_FAILED, $seam->toolName(), ErrorText::of($failure))); + } + }, + ); + } catch (\Throwable $e) { + Log::write(sprintf(self::POST_HANDLER_FAILED, $seam->toolName(), ErrorText::of($e))); + + return null; + } + } + /** * The tracing-off and degraded paths: no event, no strip, no mint-back. * diff --git a/src/Internal/Engine/CallSeam.php b/src/Internal/Engine/CallSeam.php index 4699381..c7dd50e 100644 --- a/src/Internal/Engine/CallSeam.php +++ b/src/Internal/Engine/CallSeam.php @@ -70,4 +70,17 @@ public function answer(\stdClass $tree): mixed; * @return list|null */ public function listOriginal(): ?array; + + /** + * For a result that completes later (a stream), a result that calls `$complete` with the final result, and yields + * what it returns in its place, or calls `$fail` with what the stream throws; null for an ordinary result. Never + * throws. + * + * @param TResult $result + * @param \Closure(TResult): TResult $complete + * @param \Closure(\Throwable): void $fail + * + * @return TResult|null + */ + public function later(mixed $result, \Closure $complete, \Closure $fail): mixed; } diff --git a/src/Internal/Engine/CallTrees.php b/src/Internal/Engine/CallTrees.php new file mode 100644 index 0000000..0129bfc --- /dev/null +++ b/src/Internal/Engine/CallTrees.php @@ -0,0 +1,82 @@ +|null $meta + */ + public static function claimedVersion(?array $meta): ?string + { + $claim = $meta[Constants::META_PROTOCOL_VERSION_KEY] ?? null; + + return is_string($claim) && $claim !== '' ? $claim : null; + } + + /** + * `{"method":"tools/call","params":{…}}`, raw arguments included. The arguments are an object: empty arguments are + * `{}` and integer keys stay keys (N8). A sent `_meta` is an object too, so a sent `{}` stays `{}`. + * + * @param array $params the request's params, as the library holds them + * @param array $arguments + * @param array|null $meta null when the call sent none + * + * @throws \JsonException for a value JSON cannot carry; a request decoded from JSON holds none + */ + public static function request(array $params, array $arguments, ?array $meta): \stdClass + { + $params['arguments'] = (object) $arguments; + if ($meta !== null) { + $params['_meta'] = (object) $meta; + } + $tree = new \stdClass(); + $tree->method = 'tools/call'; + $tree->params = JsonTree::of((object) $params); + + return $tree; + } + + /** + * `{sessionId?, requestId, _meta?, requestInfo?, authInfo?}`, in this order (D34). + * + * @param string|null $sessionId the transport session (`Mcp-Session-Id`), when there is one + * @param array|null $meta null when the call sent none + * @param \stdClass|null $authInfo `{scopes, claims}` of the request's access token, when there is one + * + * @throws \JsonException for a value JSON cannot carry; a request decoded from JSON holds none + */ + public static function extra(?string $sessionId, int|string $requestId, ?array $meta, ?HttpContext $http, ?\stdClass $authInfo = null): \stdClass + { + $extra = new \stdClass(); + if ($sessionId !== null) { + $extra->sessionId = $sessionId; + } + $extra->requestId = $requestId; + if ($meta !== null) { + $extra->_meta = JsonTree::of((object) $meta); + } + if ($http instanceof HttpContext) { + $extra->requestInfo = $http->toRequestInfo(); + } + if ($authInfo instanceof \stdClass) { + $extra->authInfo = $authInfo; + } + + return $extra; + } +} diff --git a/src/Internal/Engine/ToolListInjector.php b/src/Internal/Engine/ToolListInjector.php index d4f5a9b..e9e8d88 100644 --- a/src/Internal/Engine/ToolListInjector.php +++ b/src/Internal/Engine/ToolListInjector.php @@ -48,7 +48,7 @@ public static function inject(array $tools, InjectionConfig $config, InjectionSt try { return (new self($config, $state))->build($tools); } catch (\Throwable $throwable) { - Log::write('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - ' . ErrorText::message($throwable)); + ToolListing::injectionFailed($throwable); return new InjectedList($tools, [], [], degraded: true); } diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index 833c4f8..8dbf1b8 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -10,6 +10,12 @@ /** @internal */ final class ToolListing { + /** The listing is served as the library answered it, after this line (TS listWrap, ErrorText::message()). */ + public static function injectionFailed(\Throwable $e): void + { + Log::write('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - ' . ErrorText::message($e)); + } + /** * TS listWrap after the original handler: a listing without a tool list or with an empty one is served as is * (`!Array.isArray(tools) || tools.length === 0`, B11, D15); a degraded injection keeps the previous registries diff --git a/src/LaravelMcp/Internal/CapturingCallTool.php b/src/LaravelMcp/Internal/CapturingCallTool.php new file mode 100644 index 0000000..d19acb6 --- /dev/null +++ b/src/LaravelMcp/Internal/CapturingCallTool.php @@ -0,0 +1,46 @@ + + * + * @throws JsonRpcException + */ + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): \Generator|JsonRpcResponse + { + $name = $request->get('name'); + if (!is_scalar($name)) { + // No name, or one that no tool has: the library answers, and fails, on its own, with its own message. + return parent::handle($request, $context); + } + $tool = $context->tools()->first( + static fn(Tool $tool): bool => $tool->name() === $name, + static fn(): never => throw new JsonRpcException(sprintf('Tool [%s] not found.', (string) $name), -32602, $request->id), + ); + + return (new CapturingToolInvoker())->capturingInto($this->stash, $request)->invoke($tool, $request); + } +} diff --git a/src/LaravelMcp/Internal/CapturingToolInvoker.php b/src/LaravelMcp/Internal/CapturingToolInvoker.php new file mode 100644 index 0000000..1430325 --- /dev/null +++ b/src/LaravelMcp/Internal/CapturingToolInvoker.php @@ -0,0 +1,42 @@ +stash = $stash; + $this->request = $request; + + return $this; + } + + #[\Override] + protected function toErrorResponse(\Throwable $e): Response + { + if ($this->stash instanceof ThrowableStash && $this->request instanceof JsonRpcRequest) { + $this->stash->put($this->request, $e); + } + + return parent::toErrorResponse($e); + } +} diff --git a/src/LaravelMcp/Internal/Delegate.php b/src/LaravelMcp/Internal/Delegate.php index f39879f..3acd972 100644 --- a/src/LaravelMcp/Internal/Delegate.php +++ b/src/LaravelMcp/Internal/Delegate.php @@ -6,25 +6,30 @@ use Illuminate\Container\Container; use Laravel\Mcp\Server\Contracts\Method; +use Laravel\Mcp\Server\ServerContext; +use Laravel\Mcp\Transport\JsonRpcRequest; +use Laravel\Mcp\Transport\JsonRpcResponse; /** - * The handler that an entry of a server's method map named before AgentCat's, resolved the way laravel/mcp resolves - * an entry: with `Container::make()`, at each request. + * The handler that an entry of a server's method map named before AgentCat's, resolved and called the way + * laravel/mcp's `Server::runMethodHandle()` does: `Container::make()` at each request, then `handle()` on what it + * returns. * * @internal */ final class Delegate { /** + * Answers the request with the handler. An object that is not a `Method` but has its own `handle()` answers too, + * and anything else fails with PHP's own `Error`, as without AgentCat. What the handler throws passes unchanged. + * * @param class-string $class * - * @throws \UnexpectedValueException when the container resolves the class to an object that is not a method, - * which fails the request like laravel/mcp's own call would + * @return iterable|JsonRpcResponse */ - public static function of(string $class): Method + public static function handle(string $class, JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse { - $method = Container::getInstance()->make($class); - - return $method instanceof Method ? $method : throw new \UnexpectedValueException(sprintf('The container resolved %s to %s, which is not a %s', $class, get_debug_type($method), Method::class)); + // @phpstan-ignore method.nonObject (laravel/mcp calls handle() on whatever the container returns), return.type (PHP checks the answer, as at laravel/mcp's runMethodHandle()) + return Container::getInstance()->make($class)->handle($request, $context); } } diff --git a/src/LaravelMcp/Internal/Drift.php b/src/LaravelMcp/Internal/Drift.php new file mode 100644 index 0000000..0d3918e --- /dev/null +++ b/src/LaravelMcp/Internal/Drift.php @@ -0,0 +1,32 @@ +|null */ + private static ?\WeakMap $reported = null; + + public static function report(Server $server): void + { + self::$reported ??= new \WeakMap(); + if (self::$reported[$server] ?? false) { + return; + } + self::$reported[$server] = true; + Log::warning(LaravelMcpAdapter::COMPATIBILITY_LINE); + } +} diff --git a/src/LaravelMcp/Internal/LaravelCallSeam.php b/src/LaravelMcp/Internal/LaravelCallSeam.php new file mode 100644 index 0000000..46adf79 --- /dev/null +++ b/src/LaravelMcp/Internal/LaravelCallSeam.php @@ -0,0 +1,206 @@ +|JsonRpcResponse> + * + * @internal + */ +final class LaravelCallSeam implements CallSeam +{ + /** The request sent to the library by `callStripped()`: the key of the stashed tool exception. */ + private ?JsonRpcRequest $sent = null; + + /** + * @param \Closure(JsonRpcRequest): (iterable|JsonRpcResponse) $delegate answers a request with + * the handler the server's + * entry named before + * AgentCat's, built at the + * call (Task 4 review M2) + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $listDelegate the same for + * the server's + * `tools/list` + * entry; null + * when it names + * no method class + */ + public function __construct( + private readonly JsonRpcRequest $request, + private readonly ServerContext $context, + private readonly \Closure $delegate, + private readonly ?\Closure $listDelegate, + private readonly ThrowableStash $stash, + private readonly LaravelRequestReader $reader, + ) {} + + #[\Override] + public function toolName(): string + { + return LaravelRequestReader::toolName($this->request); + } + + #[\Override] + public function arguments(): mixed + { + return JsonTree::object(LaravelRequestReader::arguments($this->request)); + } + + #[\Override] + public function describe(): CallContext + { + return $this->reader->read($this->request, $this->context); + } + + #[\Override] + public function callOriginal(): iterable|JsonRpcResponse + { + return ($this->delegate)($this->request); + } + + /** + * Sends a copy of the request without the names, and removes them from the `mcp.request` that laravel/mcp bound + * for this call: the tool's own `Request`, and any other the container builds now, copy its arguments (05 E5). + * The handler is built after that (Task 4 review M2). + * + * @param list $names + */ + #[\Override] + public function callStripped(array $names): iterable|JsonRpcResponse + { + $params = $this->request->params; + $arguments = LaravelRequestReader::arguments($this->request); + foreach ($names as $name) { + unset($arguments[$name]); + } + if (is_array($params['arguments'] ?? null)) { + $params['arguments'] = $arguments; + } + $this->sent = new JsonRpcRequest($this->request->id, $this->request->method, $params); + $container = Container::getInstance(); + $bound = $container->bound('mcp.request') ? $container->make('mcp.request') : null; + if ($bound instanceof McpRequest) { + /** @var array $arguments JSON member names; PHP keeps a numeric one as an int key */ + $bound->setArguments($arguments); + } + + return ($this->delegate)($this->sent); + } + + /** @param iterable|JsonRpcResponse $result */ + #[\Override] + public function observe(mixed $result): Observed + { + $thrown = $this->stash->take($this->sent); + if (!$result instanceof JsonRpcResponse) { + return new Observed(thrown: $thrown); + } + $error = $result->content['error'] ?? null; + + return is_array($error) + ? new Observed(protocolError: is_string($error['message'] ?? null) ? $error['message'] : '', thrown: $thrown) + : new Observed(LaravelTrees::result($result), thrown: $thrown); + } + + /** + * @param iterable|JsonRpcResponse $result + * + * @return iterable|JsonRpcResponse + */ + #[\Override] + public function replace(mixed $result, \stdClass $tree): iterable|JsonRpcResponse + { + // A JSON-RPC error is never decorated (C1 header conflict 3). + return $result instanceof JsonRpcResponse && isset($result->content['result']) + ? new JsonRpcResponse([...$result->content, 'result' => LaravelTrees::nativeResult($tree)]) + : $result; + } + + #[\Override] + public function answer(\stdClass $tree): JsonRpcResponse + { + return JsonRpcResponse::result($this->request->id, LaravelTrees::nativeResult($tree)); + } + + #[\Override] + public function listOriginal(): ?array + { + return $this->listDelegate instanceof \Closure + ? LaravelTrees::tools(($this->listDelegate)(new JsonRpcRequest('agentcat-rebuild', 'tools/list', []))) + : null; + } + + /** + * A streamed answer (a generator, or another iterable of messages) passes message by message. Its first result or + * JSON-RPC error completes the call and goes out as `$complete` returns it; what the stream throws before that + * fails the call, then passes on. A stream the client abandons completes nothing (plan E1 header conflict 6). + * + * @param iterable|JsonRpcResponse $result + * @param \Closure(iterable|JsonRpcResponse): (iterable|JsonRpcResponse) $complete + * @param \Closure(\Throwable): void $fail + * + * @return \Generator|null + */ + #[\Override] + public function later(mixed $result, \Closure $complete, \Closure $fail): ?\Generator + { + if ($result instanceof JsonRpcResponse) { + return null; + } + + return (static function () use ($result, $complete, $fail): \Generator { + $done = false; + try { + foreach ($result as $message) { + if ($done || !self::answers($message)) { + yield $message; + + continue; + } + $done = true; + foreach (self::messages($complete($message)) as $answer) { + yield $answer; // keys stay 0, 1, …: laravel/mcp's test helpers keep them + } + } + } catch (\Throwable $e) { + if (!$done) { + $fail($e); + } + + throw $e; + } + })(); + } + + /** A message that answers the request (a result or a JSON-RPC error), not a notification. */ + private static function answers(mixed $message): bool + { + return $message instanceof JsonRpcResponse && (array_key_exists('result', $message->content) || array_key_exists('error', $message->content)); + } + + /** + * @param iterable|JsonRpcResponse $answer + * + * @return iterable + */ + private static function messages(iterable|JsonRpcResponse $answer): iterable + { + return $answer instanceof JsonRpcResponse ? [$answer] : $answer; + } +} diff --git a/src/LaravelMcp/Internal/LaravelHttpContext.php b/src/LaravelMcp/Internal/LaravelHttpContext.php new file mode 100644 index 0000000..94daf80 --- /dev/null +++ b/src/LaravelMcp/Internal/LaravelHttpContext.php @@ -0,0 +1,31 @@ +headers->all() as $name => $values) { + $headers[$name] = implode(', ', $values); // a null value is '' + } + + return new HttpContext($request->getMethod(), $request->fullUrl(), $headers); + } +} diff --git a/src/LaravelMcp/Internal/LaravelRequestReader.php b/src/LaravelMcp/Internal/LaravelRequestReader.php new file mode 100644 index 0000000..d312bc8 --- /dev/null +++ b/src/LaravelMcp/Internal/LaravelRequestReader.php @@ -0,0 +1,79 @@ +params['name'] ?? null; + + return is_string($name) ? $name : ''; + } + + /** + * The request's `params.arguments` as laravel/mcp decoded them; empty when they are not an array. + * + * @return array + */ + public static function arguments(JsonRpcRequest $request): array + { + $arguments = $request->params['arguments'] ?? null; + + return is_array($arguments) ? $arguments : []; + } + + /** @throws \JsonException for a value JSON cannot carry; a request decoded from JSON holds none */ + public function read(JsonRpcRequest $request, ServerContext $context): CallContext + { + $meta = $request->meta(); + [$clientName, $clientVersion] = ClientIdentity::pick($meta[Constants::META_CLIENT_INFO_KEY] ?? null, $this->handshake->clientInfo()); + $protocolVersion = CallTrees::claimedVersion($meta); + $http = ($this->http)(); + $arguments = self::arguments($request); + + return new CallContext( + request: CallTrees::request($request->params, $arguments, $meta), + extra: CallTrees::extra(null, $request->id, $meta, $http), + toolCall: new ToolCall( + toolName: self::toolName($request), + arguments: $arguments, + meta: $meta ?? [], + headers: $http instanceof HttpContext ? $http->headers : [], + clientName: $clientName, + clientVersion: $clientVersion, + protocolVersion: $protocolVersion, + request: $request, + context: $context, + ), + protocolVersion: $protocolVersion, + continuation: isset($request->params['inputResponses']), + clientName: $clientName, + clientVersion: $clientVersion, + ); + } +} diff --git a/src/LaravelMcp/Internal/TrackedCallTool.php b/src/LaravelMcp/Internal/TrackedCallTool.php new file mode 100644 index 0000000..2bfeab9 --- /dev/null +++ b/src/LaravelMcp/Internal/TrackedCallTool.php @@ -0,0 +1,64 @@ + $original the handler the server's `tools/call` entry named before AgentCat's + * @param class-string|null $listOriginal the same for `tools/list`, for the registry rebuild; null when the + * entry names no method class + */ + public function __construct( + private TrackedServer $server, + private string $original, + private ?string $listOriginal, + private CallInterceptor $interceptor, + private Publisher $publisher, + private ThrowableStash $stash, + private LaravelRequestReader $reader, + ) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $this->publisher->pump(); // stdio servers deliver between requests + $original = $this->original; + $stash = $this->stash; + $delegate = $original === CallTool::class && !Container::getInstance()->bound(CallTool::class) + ? static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => (new CapturingCallTool($stash))->handle($sent, $context) + : static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => Delegate::handle($original, $sent, $context); + $list = $this->listOriginal; + $seam = new LaravelCallSeam( + $request, + $context, + $delegate, + $list === null ? null : static fn(JsonRpcRequest $listing): iterable|JsonRpcResponse => Delegate::handle($list, $listing, $context), + $stash, + $this->reader, + ); + + return $this->interceptor->intercept($this->server, $seam); + } +} diff --git a/src/LaravelMcp/Internal/TrackedInitialize.php b/src/LaravelMcp/Internal/TrackedInitialize.php index baf2a17..39df0a1 100644 --- a/src/LaravelMcp/Internal/TrackedInitialize.php +++ b/src/LaravelMcp/Internal/TrackedInitialize.php @@ -25,6 +25,6 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl { $this->handshake->record($request->params['clientInfo'] ?? null); - return Delegate::of($this->original)->handle($request, $context); + return Delegate::handle($this->original, $request, $context); } } diff --git a/src/LaravelMcp/Internal/TrackedListTools.php b/src/LaravelMcp/Internal/TrackedListTools.php index 6dd3c67..48efaa9 100644 --- a/src/LaravelMcp/Internal/TrackedListTools.php +++ b/src/LaravelMcp/Internal/TrackedListTools.php @@ -7,8 +7,6 @@ use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Engine\TrackedServer; -use AgentCat\Internal\Logging\Log; -use AgentCat\Internal\Support\ErrorText; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Transport\JsonRpcRequest; @@ -30,7 +28,7 @@ public function __construct(private TrackedServer $server, private string $origi public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse { $this->publisher->pump(); // stdio servers deliver between requests - $response = Delegate::of($this->original)->handle($request, $context); // throws pass unchanged (02 AC-10) + $response = Delegate::handle($this->original, $request, $context); // throws pass unchanged (02 AC-10) if (!$response instanceof JsonRpcResponse || array_key_exists('error', $response->content)) { return $response; // a stream or a JSON-RPC error passes unchanged, like the mcp/sdk adapter's } @@ -44,7 +42,7 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl return new JsonRpcResponse([...$response->content, 'result' => [...$result, 'tools' => LaravelTrees::native($tools)]]); } catch (\Throwable $e) { - Log::write('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - ' . ErrorText::message($e)); + ToolListing::injectionFailed($e); return $response; } diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php index 1685349..ff0cd2a 100644 --- a/src/LaravelMcp/LaravelMcpAdapter.php +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -5,21 +5,30 @@ namespace AgentCat\LaravelMcp; use AgentCat\Internal\Engine\CallInterceptor; +use AgentCat\Internal\Engine\HttpContext; use AgentCat\Internal\Engine\Publisher; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Support\StrictErrors; +use AgentCat\LaravelMcp\Internal\Access\HttpTransportAccess; use AgentCat\LaravelMcp\Internal\Access\ServerAccess; +use AgentCat\LaravelMcp\Internal\Drift; use AgentCat\LaravelMcp\Internal\HandshakeClient; +use AgentCat\LaravelMcp\Internal\LaravelHttpContext; +use AgentCat\LaravelMcp\Internal\LaravelRequestReader; use AgentCat\LaravelMcp\Internal\MethodKey; +use AgentCat\LaravelMcp\Internal\TrackedCallTool; use AgentCat\LaravelMcp\Internal\TrackedInitialize; use AgentCat\LaravelMcp\Internal\TrackedListTools; use Illuminate\Container\Container; +use Illuminate\Http\Request; use Laravel\Mcp\Schema\Implementation; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Method; +use Laravel\Mcp\Server\Transport\HttpTransport; /** * Detects and instruments laravel/mcp servers. AgentCat changes a server only through its public `addMethod()`: the @@ -74,7 +83,8 @@ public static function inspect(Server $server): void /** * The server name and version, read when an event is published. Holds the server weakly: the tracking state must - * not keep a finished request's server alive. Unknown when they cannot be read. + * not keep a finished request's server alive. When they cannot be read (the server's `createContext()` fails or + * warns), they are unknown, and the compatibility line is logged once per server. * * @return \Closure(): array{0: ?string, 1: ?string} */ @@ -92,26 +102,67 @@ public static function serverInfo(Server $server): \Closure return [$implementation->name, $implementation->version]; } catch (\Throwable) { + Drift::report($server); + return [null, null]; } }; } /** - * Points the server's `tools/list` and `initialize` entries at AgentCat's methods, which delegate to the classes - * the entries named. An entry that names no method class (a container key, another package's binding) stays as it - * is. Every read comes before the first change, so a read that throws leaves the server untouched. + * The HTTP request the server answers, read from its own transport when a call is recorded (plan E1 header + * conflict 9); null on stdio and once the server is gone. Holds the server weakly, like `serverInfo()`. When the + * transport or its request cannot be read (drift), the call is recorded without HTTP context, and the + * compatibility line is logged once per server. Never throws. + * + * @return \Closure(): ?HttpContext + */ + public static function httpContext(Server $server): \Closure + { + $reference = \WeakReference::create($server); + + return static function () use ($reference): ?HttpContext { + $server = $reference->get(); + if (!$server instanceof Server) { + return null; + } + try { + // Warnings count as drift for the protected-member reads only, not for Laravel's own request methods. + $request = StrictErrors::run(static function () use ($server): ?Request { + $transport = ServerAccess::transport($server); + + return $transport instanceof HttpTransport ? HttpTransportAccess::request($transport) : null; + }); + + return $request instanceof Request ? LaravelHttpContext::of($request) : null; + } catch (\Throwable) { + Drift::report($server); + + return null; + } + }; + } + + /** + * Points the server's `tools/list`, `tools/call` and `initialize` entries at AgentCat's methods, which delegate to + * the classes the entries named. An entry that names no method class (a container key, another package's binding) + * stays as it is. Every read comes before the first change, so a read that throws leaves the server untouched. */ public static function install(Server $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void { $methods = ServerAccess::methods($server); $list = self::entry($methods, 'tools/list'); + $call = self::entry($methods, 'tools/call'); $initialize = self::entry($methods, 'initialize'); $container = Container::getInstance(); $handshake = new HandshakeClient(); // one per server: the client of a legacy handshake, for its tool calls if ($list !== null) { self::point($server, $container, 'tools/list', new TrackedListTools($state, $list, $publisher)); } + if ($call !== null) { + $reader = new LaravelRequestReader(self::httpContext($server), $handshake); + self::point($server, $container, 'tools/call', new TrackedCallTool($state, $call, $list, $interceptor, $publisher, new ThrowableStash(), $reader)); + } if ($initialize !== null) { self::point($server, $container, 'initialize', new TrackedInitialize($initialize, $handshake)); } diff --git a/src/McpSdk/Internal/ListToolsInterceptor.php b/src/McpSdk/Internal/ListToolsInterceptor.php index 744d8e7..a4f7209 100644 --- a/src/McpSdk/Internal/ListToolsInterceptor.php +++ b/src/McpSdk/Internal/ListToolsInterceptor.php @@ -7,8 +7,6 @@ use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Engine\TrackedServer; -use AgentCat\Internal\Logging\Log; -use AgentCat\Internal\Support\ErrorText; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -64,7 +62,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er return new Response($response->getId(), new WireListToolsResult($original, $tree)); } catch (\Throwable $e) { - Log::write('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - ' . ErrorText::message($e)); + ToolListing::injectionFailed($e); return $response; } diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index 2b4553f..7b76491 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -131,4 +131,11 @@ public function listOriginal(): ?array return $answer instanceof Response ? (Trees::listing($answer->result)[2] ?? null) : null; } + + /** mcp/sdk answers are complete when `handle()` returns: a tool that waits suspends its fiber inside it. */ + #[\Override] + public function later(mixed $result, \Closure $complete, \Closure $fail): null + { + return null; + } } diff --git a/src/McpSdk/Internal/RequestReader.php b/src/McpSdk/Internal/RequestReader.php index 166b16e..c64c5c3 100644 --- a/src/McpSdk/Internal/RequestReader.php +++ b/src/McpSdk/Internal/RequestReader.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Constants; use AgentCat\Internal\Engine\CallContext; +use AgentCat\Internal\Engine\CallTrees; use AgentCat\Internal\Engine\ClientIdentity; use AgentCat\Internal\Engine\HttpContext; use AgentCat\ToolCall; @@ -48,33 +49,11 @@ public function read(CallToolRequest $request, SessionInterface $session): CallC $session->get('client_info'), ); // Never the session's negotiated `protocol_version` (02 §3.3): only what the call itself claims. - $protocolVersion = $requestMeta instanceof RequestMeta ? $requestMeta->protocolVersion : $this->claimedVersion($meta ?? []); + $protocolVersion = $requestMeta instanceof RequestMeta ? $requestMeta->protocolVersion : CallTrees::claimedVersion($meta); $transportSessionId = $requestMeta instanceof RequestMeta ? null : (string) $session->getId(); // the Mcp-Session-Id $http = ($this->http)(); - - $params = $request->jsonSerialize()['params'] ?? []; - $params['arguments'] = (object) $request->arguments; // integer keys stay keys (N8); empty arguments are {} - if ($meta !== null) { - $params['_meta'] = (object) $meta; // a sent `_meta: {}` stays {} - } - $extra = new \stdClass(); - if ($transportSessionId !== null) { - $extra->sessionId = $transportSessionId; - } - $extra->requestId = $request->getId(); - if ($meta !== null) { - $extra->_meta = Trees::of((object) $meta); - } - if ($http instanceof HttpContext) { - $extra->requestInfo = $http->toRequestInfo(); - } - $authInfo = $this->authInfo($session->get(self::ACCESS_TOKEN)); - if ($authInfo instanceof \stdClass) { - $extra->authInfo = $authInfo; - } - $tree = new \stdClass(); - $tree->method = 'tools/call'; - $tree->params = Trees::of((object) $params); + $tree = CallTrees::request($request->jsonSerialize()['params'] ?? [], $request->arguments, $meta); + $extra = CallTrees::extra($transportSessionId, $request->getId(), $meta, $http, $this->authInfo($session->get(self::ACCESS_TOKEN))); return new CallContext( request: $tree, @@ -98,14 +77,6 @@ public function read(CallToolRequest $request, SessionInterface $session): CallC ); } - /** @param array $meta */ - private function claimedVersion(array $meta): ?string - { - $claim = $meta[Constants::META_PROTOCOL_VERSION_KEY] ?? null; - - return is_string($claim) && $claim !== '' ? $claim : null; - } - /** * `{scopes, claims}` of an access token (mcp/sdk main), read by duck typing so the class need not exist. * diff --git a/tests/Contract/LaravelMcp/ExtendedClassesTest.php b/tests/Contract/LaravelMcp/ExtendedClassesTest.php new file mode 100644 index 0000000..9ecd8a6 --- /dev/null +++ b/tests/Contract/LaravelMcp/ExtendedClassesTest.php @@ -0,0 +1,146 @@ + str_starts_with($class, 'Laravel\\'); + + $this->assertSame([CallTool::class, ToolInvoker::class], array_values(array_filter(PrivateMemberManifest::extendedClasses(), $laravel))); + $this->assertSame([CallTool::class, ToolInvoker::class], array_values(array_filter(array_keys(PrivateMemberManifest::EXTENDED), $laravel))); + } + + /** @return \Iterator */ + public static function laravelClasses(): \Iterator + { + foreach (PrivateMemberManifest::EXTENDED as $class => $signature) { + if (str_starts_with($class, 'Laravel\\')) { + yield $class => [$class, $signature]; + } + } + } + + /** @param class-string $class */ + #[DataProvider('laravelClasses')] + public function testTheParentStillHasTheMethodAgentCatOverrides(string $class, string $signature): void + { + $parent = new \ReflectionClass($class); + $name = strstr($signature, '(', true); + $this->assertIsString($name); + + $this->assertFalse($parent->isFinal(), $class . ' is final'); + $method = $parent->getMethod($name); + $this->assertFalse($method->isFinal(), $class . '::' . $name . '() is final'); + $this->assertFalse($method->isPrivate()); + $this->assertSame($signature, $this->signature($method)); + } + + public function testTheInvokerCanBeBuiltWithoutArguments(): void + { + $constructor = (new \ReflectionClass(ToolInvoker::class))->getConstructor(); + + $this->assertTrue($constructor === null || $constructor->getNumberOfRequiredParameters() === 0); + } + + /** @return iterable}> */ + public static function calls(): iterable + { + yield 'a tool' => [['name' => 'echo', 'arguments' => ['msg' => 'hi']]]; + yield 'an unknown tool' => [['name' => 'nope']]; + yield 'no name' => [[]]; + yield 'a null name' => [['name' => null]]; + yield 'a number as the name' => [['name' => 7]]; + yield 'a boolean as the name' => [['name' => true]]; + yield 'a list as the name' => [['name' => ['echo']]]; + yield 'a tool that throws' => [['name' => 'boom']]; + yield 'a tool that refuses' => [['name' => 'refuses']]; + yield 'structured content' => [['name' => 'structured']]; + } + + /** @param array $params */ + #[DataProvider('calls')] + public function testCapturingCallToolAnswersExactlyLikeCallTool(array $params): void + { + $server = $this->app?->make(ConformanceServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(ConformanceServer::class, $server); + $context = $server->createContext(); + $request = new JsonRpcRequest(3, 'tools/call', $params); + + $expected = $this->answer(static fn(): mixed => (new CallTool())->handle($request, $context)); + $actual = $this->answer(static fn(): mixed => (new CapturingCallTool(new ThrowableStash()))->handle($request, $context)); + + $this->assertSame($expected, $actual); + } + + public function testCapturingCallToolKeepsTheToolsExceptionForItsRequest(): void + { + $server = $this->app?->make(ConformanceServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(ConformanceServer::class, $server); + $stash = new ThrowableStash(); + $request = new JsonRpcRequest(3, 'tools/call', ['name' => 'boom']); + + (new CapturingCallTool($stash))->handle($request, $server->createContext()); + + $thrown = $stash->take($request); + $this->assertInstanceOf(\RuntimeException::class, $thrown); + $this->assertSame('tool boom', $thrown->getMessage()); + } + + /** + * The JSON of an answer, or of the JSON-RPC exception it throws, or the class and message of another throwable. + * + * @param \Closure(): mixed $handle + */ + private function answer(\Closure $handle): string + { + try { + $answer = $handle(); + } catch (JsonRpcException $e) { + $answer = $e->toJsonRpcResponse(); + } catch (\Throwable $e) { + return $e::class . ': ' . $e->getMessage(); + } + + return $answer instanceof JsonRpcResponse ? $answer->toJson() : get_debug_type($answer); + } + + /** `name(type $parameter = default, ...): return type`, defaults as JSON. */ + private function signature(\ReflectionMethod $method): string + { + $parameters = array_map(static function (\ReflectionParameter $parameter): string { + $text = $parameter->getType() . ' $' . $parameter->getName(); + + return $parameter->isDefaultValueAvailable() + ? $text . ' = ' . json_encode($parameter->getDefaultValue(), JSON_THROW_ON_ERROR) + : $text; + }, $method->getParameters()); + $return = $method->getReturnType(); + + return $method->getName() . '(' . implode(', ', $parameters) . ')' . ($return === null ? '' : ': ' . $return); + } +} diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php index 0fdca46..fd359e0 100644 --- a/tests/Contract/ManifestTest.php +++ b/tests/Contract/ManifestTest.php @@ -20,6 +20,8 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void [ 'Laravel\Mcp\Server::$methods', 'Laravel\Mcp\Server::$transport', + 'Laravel\Mcp\Server\Methods\CallTool::handle(Laravel\Mcp\Transport\JsonRpcRequest $request, Laravel\Mcp\Server\ServerContext $context): Generator|Laravel\Mcp\Transport\JsonRpcResponse', + 'Laravel\Mcp\Server\ToolInvoker::toErrorResponse(Throwable $e): Laravel\Mcp\Response', 'Laravel\Mcp\Server\Transport\HttpTransport::$request', 'Mcp\Schema\Result\CallToolResult::__construct(array $content, bool $isError = false, mixed $structuredContent = null, ?array $meta = null)', 'Mcp\Schema\Result\ListToolsResult::__construct(array $tools, ?string $nextCursor = null)', diff --git a/tests/Contract/McpSdk/ExtendedClassesTest.php b/tests/Contract/McpSdk/ExtendedClassesTest.php index 457ba8c..9ebacb8 100644 --- a/tests/Contract/McpSdk/ExtendedClassesTest.php +++ b/tests/Contract/McpSdk/ExtendedClassesTest.php @@ -21,14 +21,14 @@ final class ExtendedClassesTest extends TestCase { public function testTheManifestListsEveryLibraryClassAgentCatExtends(): void { - $this->assertSame([CallToolResult::class, ListToolsResult::class], PrivateMemberManifest::extendedClasses()); - $this->assertSame(PrivateMemberManifest::extendedClasses(), array_keys(PrivateMemberManifest::EXTENDED)); + $this->assertSame([CallToolResult::class, ListToolsResult::class], self::mcpClasses(PrivateMemberManifest::extendedClasses())); + $this->assertSame([CallToolResult::class, ListToolsResult::class], self::mcpClasses(array_keys(PrivateMemberManifest::EXTENDED))); } - /** @return \Iterator one row per extended class */ + /** @return \Iterator one row per extended mcp/sdk class */ public static function extendedClasses(): \Iterator { - foreach (array_keys(PrivateMemberManifest::EXTENDED) as $class) { + foreach (self::mcpClasses(array_keys(PrivateMemberManifest::EXTENDED)) as $class) { yield $class => [$class]; } } @@ -53,6 +53,18 @@ public function testTheParentCanStillBeExtendedWithTheSameConstructor(string $cl } } + /** + * The mcp/sdk classes of a list of classes, in order. + * + * @param list $classes + * + * @return list + */ + private static function mcpClasses(array $classes): array + { + return array_values(array_filter($classes, static fn(string $class): bool => str_starts_with($class, 'Mcp\\'))); + } + /** `name(type $parameter = default, ...)`, defaults as JSON. */ private function signature(\ReflectionMethod $method): string { diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php index 198958d..02c4605 100644 --- a/tests/Contract/PrivateMemberManifest.php +++ b/tests/Contract/PrivateMemberManifest.php @@ -5,6 +5,8 @@ namespace AgentCat\Tests\Contract; use AgentCat\Internal\Access\TouchesPrivateMember; +use Laravel\Mcp\Server\Methods\CallTool; +use Laravel\Mcp\Server\ToolInvoker; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ListToolsResult; @@ -15,10 +17,12 @@ final class PrivateMemberManifest { /** - * The library classes that classes in src//Internal extend, each with the constructor they call. A parent - * that turns final or changes this constructor breaks them. + * The library classes that classes in src//Internal extend, each with the method they depend on: the + * constructor they call, or the method they override. A parent that turns final or changes this method breaks them. */ public const array EXTENDED = [ + CallTool::class => 'handle(Laravel\\Mcp\\Transport\\JsonRpcRequest $request, Laravel\\Mcp\\Server\\ServerContext $context): Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse', + ToolInvoker::class => 'toErrorResponse(Throwable $e): Laravel\\Mcp\\Response', CallToolResult::class => '__construct(array $content, bool $isError = false, mixed $structuredContent = null, ?array $meta = null)', ListToolsResult::class => '__construct(array $tools, ?string $nextCursor = null)', ]; @@ -79,8 +83,8 @@ public static function extendedClasses(): array } /** - * The private members AgentCat reads (`Class::$member`) and the constructors of the classes it extends - * (`Class::__construct(...)`), sorted. + * The private members AgentCat reads (`Class::$member`) and the methods of the classes it extends that it depends + * on (`Class::__construct(...)`, `Class::handle(...): …`), sorted. * * @return list */ @@ -90,8 +94,8 @@ public static function members(): array static fn(array $entry): string => $entry['member']->class . '::$' . $entry['member']->member, self::entries(), ); - foreach (self::EXTENDED as $class => $constructor) { - $members[] = $class . '::' . $constructor; + foreach (self::EXTENDED as $class => $method) { + $members[] = $class . '::' . $method; } $members = array_values(array_unique($members)); sort($members); diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php new file mode 100644 index 0000000..4bf32b1 --- /dev/null +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -0,0 +1,455 @@ +tracker()); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $event = $fixture->event(); + $this->assertSame('echo', $event->resourceName); + $this->assertSame(Constants::TOOLS_CALL_EVENT_TYPE, $event->eventType); + $this->assertSame('why', $event->userIntent); + $this->assertSame(self::SESSION, $event->sessionId); + $this->assertSame('supplied', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('2026-07-28', $event->tags[Constants::TAG_PROTOCOL_VERSION] ?? null); + $this->assertSame(['conformance-server', '2.0.0'], [$event->serverName, $event->serverVersion]); + $this->assertSame(['modern-client', '2.0.0'], [$event->clientName, $event->clientVersion]); + $this->assertSame(['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION], (array) JsonAssert::at($event->parameters, 'request', 'params', 'arguments')); + $this->assertSame('echo', JsonAssert::at($event->parameters, 'request', 'params', 'name')); + $this->assertSame('tools/call', JsonAssert::at($event->parameters, 'request', 'method')); + $this->assertSame(1, JsonAssert::at($event->parameters, 'extra', 'requestId')); + $this->assertSame(['name' => 'modern-client', 'version' => '2.0.0'], (array) JsonAssert::at($event->parameters, 'extra', '_meta', Constants::META_CLIENT_INFO_KEY)); + } + + public function testTheToolsRequestObjectNeverSeesTheInjectedArguments(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(RequestEchoServer::class, $fixture->tracker()); + + $harness->callTool('request-echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start']); + + $this->assertSame([['request' => ['msg' => 'hi'], 'container' => ['msg' => 'hi']]], ToolCalls::of('request-echo')); + } + + #[Group('ac-02-20')] + public function testAFreshServerRebuildsTheRegistriesOnItsFirstCall(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start']); + + $this->assertContains('Rebuilt injection registries on demand (tools/call before tools/list on this instance)', $fixture->lines()); + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); + } + + public function testAServerThatListsWithoutAMethodClassStripsHeuristically(): void + { + $fixture = new LaravelTrackingFixture(); + $transport = new LineTransport(); + $server = new class ($transport) extends Server { + public function __construct(Transport $transport) + { + parent::__construct($transport); + $this->tools = [EchoTool::class]; + // @phpstan-ignore argument.type (a container key, as another package could bind) + $this->addMethod('tools/list', 'customer.list'); + } + }; + $fixture->track($server); + $server->start(); + + $transport->deliver(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'agent_id' => 'a', 'session_id' => 'start']]]); + + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); // the heuristic strip: no listing names echo + $this->assertSame([], $fixture->lines(), 'no rebuild without a listing'); + $this->assertCount(1, $fixture->events()); + } + + #[Group('ac-02-24')] + #[Group('ac-05-E5')] + public function testAThrownToolExceptionIsRecordedAsItself(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $answer = $harness->callTool('boom', ['session_id' => 'start', 'context' => 'why']); + + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $this->assertStringContainsString('An internal server error occurred.', json_encode(JsonAssert::at($answer, 'result', 'content'), JSON_THROW_ON_ERROR)); + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); + $this->assertSame('tool boom', JsonAssert::at($event->error, 'message')); + $this->assertSame('LogicException', JsonAssert::at($event->error, 'chained_errors', 0, 'type')); + } + + #[Group('ac-02-26')] + public function testAnUnknownToolIsOneErrorEventAndTheLibrarysAnswer(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + $twin = (new LaravelHarness(ConformanceServer::class))->callTool('nope', ['session_id' => 'start']); + + $answer = $harness->callTool('nope', ['session_id' => 'start']); + + $this->assertSame(-32602, JsonAssert::at($answer, 'error', 'code')); + $this->assertSame('Tool [nope] not found.', JsonAssert::at($answer, 'error', 'message')); + $this->assertEquals($twin, $answer); + $event = $fixture->event(); + $this->assertSame('nope', $event->resourceName); + $this->assertTrue($event->isError); + $this->assertSame('Tool [nope] not found.', JsonAssert::at($event->error, 'message')); + } + + #[Group('ac-02-30')] + #[Group('ac-02-33')] + public function testTheFirstCallGetsItsSessionInATextBlock(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $answer = $harness->callTool('refuses', ['context' => 'why']); + + $event = $fixture->event(); + $this->assertSame( + Constants::MINT_BACK_HEADER_ISSUED . "\nsession_id: " . $event->sessionId . "\n" . Constants::MINT_BACK_ISSUED_BODY, + JsonAssert::at($answer, 'result', 'content', 0, 'text'), + ); + $this->assertSame('minted', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $this->assertSame('not allowed', JsonAssert::at($event->error, 'message')); + } + + #[Group('ac-02-36')] + #[Group('ac-02-38')] + public function testStructuredContentGetsTheSessionMirrorFirst(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); // the call's own rebuild finds the outputSchema + + $answer = $harness->callTool('structured', ['session_id' => 'start', 'context' => 'why']); + + $structured = JsonAssert::at($answer, 'result', 'structuredContent'); + $this->assertInstanceOf(\stdClass::class, $structured); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], array_keys(get_object_vars($structured))); + $this->assertSame('issued', JsonAssert::at($structured, Constants::MCP_SESSION_KEY, 'status')); + $this->assertEquals((object) ['echoed' => 'x'], JsonAssert::at($fixture->event()->response, 'structuredContent')); + } + + #[Group('ac-02-40')] + public function testGetMoreToolsIsAnsweredByAgentCat(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $answer = $harness->callTool(GetMoreTools::NAME, ['context' => 'need X', 'session_id' => self::SESSION]); + + $content = JsonAssert::at($answer, 'result', 'content'); + $this->assertIsList($content); + $this->assertSame('Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.', JsonAssert::at(end($content), 'text')); + $this->assertSame('need X', $fixture->event()->userIntent); + $this->assertContains('Missing tool reported (context length: 6)', $fixture->lines()); + } + + #[Group('ac-02-43')] + public function testHeadersComeFromTheServersOwnRequest(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], headers: ['X-AgentCat-Health-Run' => 'S']); + + $info = JsonAssert::at($fixture->event()->parameters, 'extra', 'requestInfo'); + $this->assertSame('S', JsonAssert::at($info, 'headers', 'x-agentcat-health-run')); + $this->assertSame('http://localhost/mcp/conformance', JsonAssert::at($info, 'url')); + } + + #[Group('ac-02-44')] + public function testALegacyStdioClientIsKnownFromItsInitialize(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(ConformanceServer::class, $fixture->tracker()); + $stdio->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'desktop', 'version' => '0.9']]]); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]]); + + $event = $fixture->event(); + $this->assertSame(['desktop', '0.9'], [$event->clientName, $event->clientVersion]); + $this->assertArrayNotHasKey(Constants::TAG_PROTOCOL_VERSION, $event->tags ?? []); + $extra = JsonAssert::at($event->parameters, 'extra'); + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertObjectNotHasProperty('requestInfo', $extra, 'stdio has no HTTP request'); + } + + #[Group('ac-02-52')] + public function testTracingOffPassesTheCallUnchanged(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker(new Options(enableTracing: false))); + + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why']); + + $this->assertSame([['msg' => 'hi', 'context' => 'why']], ToolCalls::of('echo')); // D15, B06: unstripped + $this->assertSame([], $fixture->events()); + } + + public function testACustomToolsCallHandlerIsWrapped(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(CustomCallServer::class, $fixture->tracker()); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame(1, CustomCallTool::$calls); + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertCount(1, $fixture->events()); + } + + /** Task 4 review M2: AgentCat builds the handler after laravel/mcp binds `mcp.request`, and after the strip. */ + public function testACustomHandlersConstructorRequestSeesTheStrippedArguments(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(CustomCallServer::class, $fixture->tracker()); + + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame([['msg' => 'hi']], CustomCallTool::$constructed); + } + + /** A container binding of `CallTool` answers, as without AgentCat; AgentCat then keeps no tool exception. */ + public function testACallToolBindingOfTheContainerStillAnswers(): void + { + $calls = new \ArrayObject(); + $this->app?->bind(CallTool::class, static fn(): CallTool => new class ($calls) extends CallTool { + /** @param \ArrayObject $calls */ + public function __construct(private readonly \ArrayObject $calls) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): \Generator|JsonRpcResponse + { + $this->calls->append('handle'); + + return parent::handle($request, $context); + } + }); + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $answer = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertSame(['handle'], $calls->getArrayCopy()); + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('An internal server error occurred.', JsonAssert::at($event->error, 'message'), 'the result text: AgentCat keeps no exception for a handler it did not build'); + $error = $event->error; + $this->assertInstanceOf(\stdClass::class, $error); + $this->assertObjectNotHasProperty('type', $error); + } + + public function testAJsonRpcErrorFromACustomHandlerIsOneErrorEventAndPassesUnchanged(): void + { + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::error($request->id, -32602, 'bad arguments')); + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(RecordingCallServer::class))->callTool('echo', ['session_id' => 'start']); + + $answer = (new LaravelHarness(RecordingCallServer::class, $fixture->tracker()))->callTool('echo', ['session_id' => 'start']); + + $this->assertEquals($twin, $answer); + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('bad arguments', JsonAssert::at($event->error, 'message')); + } + + #[MaximumDuration(2000)] + public function testAStreamedAnswerIsRecordedWhenItEnds(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(StreamingServer::class, $fixture->tracker()); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 7, 'method' => 'tools/call', 'params' => ['name' => 'stream', 'arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]]); + + $lines = $stdio->lines(); + $this->assertCount(3, $lines); // two progress notifications, then the result + $this->assertSame('notifications/progress', JsonAssert::at($lines[0], 'method')); + $this->assertSame(1, JsonAssert::at($lines[0], 'params', 'progress')); + $this->assertSame('notifications/progress', JsonAssert::at($lines[1], 'method')); + $this->assertSame(2, JsonAssert::at($lines[1], 'params', 'progress')); + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $this->text(JsonAssert::at($lines[2], 'result', 'content', 0, 'text'))); + $content = JsonAssert::at($lines[2], 'result', 'content'); + $this->assertIsList($content); + $this->assertSame('done', JsonAssert::at(end($content), 'text')); + $this->assertSame(7, JsonAssert::at($lines[2], 'id')); + $event = $fixture->event(); + $this->assertGreaterThanOrEqual(100, $event->duration); // the stream sleeps 2 × 50 ms + $this->assertSame([[]], ToolCalls::of('stream')); + } + + #[Group('ac-05-E5')] + public function testAStreamThatThrowsIsOneErrorEvent(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(StreamingServer::class, $fixture->tracker()); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 8, 'method' => 'tools/call', 'params' => ['name' => 'stream-fails', 'arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]]); + + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('DomainException', JsonAssert::at($event->error, 'type')); + $this->assertSame('stream broke', JsonAssert::at($event->error, 'message')); + $this->assertTrue(JsonAssert::at($stdio->lines()[1] ?? null, 'result', 'isError')); + } + + /** Controller ruling P9: a handler's own stream that throws midway reaches `later()`'s catch. */ + #[Group('ac-05-E5')] + public function testARawStreamThatThrowsMidwayIsOneErrorEventAndTheLibrarysError(): void + { + $this->answer(static function (): \Generator { + yield JsonRpcResponse::notification('notifications/progress', ['progress' => 1]); + + throw new \DomainException('raw stream broke'); + }); + $message = ['jsonrpc' => '2.0', 'id' => 9, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]]; + $twin = new LaravelStdioHarness(RecordingCallServer::class); + $twin->send($message); + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(RecordingCallServer::class, $fixture->tracker()); + + $stdio->send($message); + + $this->assertCount(2, $stdio->lines()); + $this->assertEquals($twin->lines(), $stdio->lines()); + $this->assertSame(-32603, JsonAssert::at($stdio->lines()[1], 'error', 'code')); + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('DomainException', JsonAssert::at($event->error, 'type')); + $this->assertSame('raw stream broke', JsonAssert::at($event->error, 'message')); + } + + public function testAStreamOfAListIsRecordedFromItsResult(): void + { + $this->answer(static fn(JsonRpcRequest $request): array => [ + JsonRpcResponse::notification('notifications/progress', ['progress' => 1]), + JsonRpcResponse::result($request->id, ['content' => [['type' => 'text', 'text' => 'listed']], 'isError' => false]), + JsonRpcResponse::notification('notifications/progress', ['progress' => 2]), + ]); + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(RecordingCallServer::class, $fixture->tracker()); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 10, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]]); + + $lines = $stdio->lines(); + $this->assertCount(3, $lines); + $this->assertSame(1, JsonAssert::at($lines[0], 'params', 'progress')); + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $this->text(JsonAssert::at($lines[1], 'result', 'content', 0, 'text'))); + $this->assertSame('listed', JsonAssert::at($lines[1], 'result', 'content', 1, 'text')); + $this->assertSame(2, JsonAssert::at($lines[2], 'params', 'progress')); + $this->assertSame('listed', JsonAssert::at($fixture->event()->response, 'content', 0, 'text')); + } + + public function testAStreamWhoseLastAnswerIsAJsonRpcErrorIsOneErrorEvent(): void + { + $this->answer(static function (JsonRpcRequest $request): \Generator { + yield JsonRpcResponse::notification('notifications/progress', ['progress' => 1]); + yield JsonRpcResponse::error($request->id, -32602, 'bad stream'); + }); + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(RecordingCallServer::class, $fixture->tracker()); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 11, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]]); + + $this->assertSame('bad stream', JsonAssert::at($stdio->lines()[1] ?? null, 'error', 'message')); + $event = $fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('bad stream', JsonAssert::at($event->error, 'message')); + } + + public function testLaravelTestHelpersStillReadArrays(): void + { + $fixture = new LaravelTrackingFixture(); + $this->app?->afterResolving(ConformanceServer::class, $fixture->tracker()); + + ConformanceServer::tool(EchoTool::class, ['msg' => 'hi', 'session_id' => 'start'])->assertSee('echo: hi')->assertHasNoErrors(); + ConformanceServer::tool(StructuredTool::class, ['session_id' => self::SESSION])->assertStructuredContent([ + Constants::MCP_SESSION_KEY => ['session_id' => self::SESSION, 'status' => 'active'], + 'echoed' => 'x', + ]); + + $this->assertCount(2, $fixture->events()); + } + + public function testACallStartsTheDeliveryOfAWaitingEvent(): void + { + $fixture = new LaravelTrackingFixture(); + $fixture->transport->complete = false; + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker(new Options(enableTracing: false))); + $fixture->queue->add(new QueuedEvent(new Event(sessionId: self::SESSION, projectId: LaravelTrackingFixture::PROJECT, eventType: Constants::TOOLS_CALL_EVENT_TYPE), null, null, [])); + + $harness->callTool('echo', ['msg' => 'hi']); + + $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $fixture->queue->stats()); + } + + private function text(mixed $value): string + { + $this->assertIsString($value); + + return $value; + } + + /** + * Binds the `RecordingMethod` that `RecordingCallServer` names for `tools/call`, with this answer. + * + * @param \Closure(JsonRpcRequest): (iterable|JsonRpcResponse) $answer + */ + private function answer(\Closure $answer): void + { + $this->app?->instance(RecordingMethod::class, new RecordingMethod(answer: $answer)); + } +} diff --git a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php index ade50d1..308ae19 100644 --- a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -7,11 +7,13 @@ use AgentCat\Client; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\HttpContext; use AgentCat\Internal\Engine\Libraries; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\LaravelMcp\Internal\Access\ServerAccess; use AgentCat\LaravelMcp\Internal\MethodKey; +use AgentCat\LaravelMcp\Internal\TrackedCallTool; use AgentCat\LaravelMcp\Internal\TrackedInitialize; use AgentCat\LaravelMcp\Internal\TrackedListTools; use AgentCat\LaravelMcp\LaravelMcpAdapter; @@ -22,16 +24,19 @@ use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; use AgentCat\Tests\Support\LaravelMcp\LineTransport; use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; +use AgentCat\Tests\Support\Members; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\Tests\Support\Thrown; use Illuminate\Container\Container; +use Illuminate\Http\Request; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Transport; -use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\ServerContext; +use Laravel\Mcp\Server\Transport\HttpTransport; use Mcp\Server as McpServer; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Psr\Log\LogLevel; final class LaravelMcpAdapterTest extends LaravelTestCase @@ -139,8 +144,9 @@ public function testInstallPointsTheEntriesAtAgentCatsMethodsUnderPerServerKeys( $methods = ServerAccess::methods($server); $this->assertSame('agentcat.laravel-mcp.' . spl_object_id($server) . '.tools/list', $methods['tools/list'] ?? null); $this->assertSame('agentcat.laravel-mcp.' . spl_object_id($server) . '.initialize', $methods['initialize'] ?? null); - $this->assertSame(CallTool::class, $methods['tools/call'] ?? null, 'tools/call is not wired yet'); + $this->assertSame('agentcat.laravel-mcp.' . spl_object_id($server) . '.tools/call', $methods['tools/call'] ?? null); $this->assertInstanceOf(TrackedListTools::class, Container::getInstance()->make(MethodKey::for($server, 'tools/list'))); + $this->assertInstanceOf(TrackedCallTool::class, Container::getInstance()->make(MethodKey::for($server, 'tools/call'))); $this->assertInstanceOf(TrackedInitialize::class, Container::getInstance()->make(MethodKey::for($server, 'initialize'))); $this->assertNotSame(Container::getInstance()->make(MethodKey::for($server, 'tools/list')), Container::getInstance()->make(MethodKey::for($other, 'tools/list'))); $this->assertSame(MethodKey::for($other, 'tools/list'), ServerAccess::methods($other)['tools/list'] ?? null); @@ -155,6 +161,8 @@ public function __construct(Transport $transport) parent::__construct($transport); // @phpstan-ignore argument.type (a container key, as another package could bind) $this->addMethod('tools/list', 'customer.list'); + // @phpstan-ignore argument.type (see above) + $this->addMethod('tools/call', 'customer.call'); unset($this->methods['initialize']); } }; @@ -163,6 +171,7 @@ public function __construct(Transport $transport) $methods = ServerAccess::methods($server); $this->assertSame('customer.list', $methods['tools/list'] ?? null); + $this->assertSame('customer.call', $methods['tools/call'] ?? null); $this->assertArrayNotHasKey('initialize', $methods); } @@ -200,8 +209,11 @@ public function testTheServerInfoHoldsTheServerWeakly(): void $this->assertSame([null, null], $info()); } - public function testAServerInfoThatCannotBeReadIsUnknown(): void + /** Task 4 review M3: the events of such a server have no name and version, and one line says so. */ + public function testAServerInfoThatCannotBeReadIsUnknownAndLoggedOncePerServer(): void { + $logger = new MemoryLogger(); + Log::setLogger($logger); $server = new class (new LineTransport()) extends Server { #[\Override] public function createContext(): ServerContext @@ -218,14 +230,79 @@ public function createContext(): ServerContext return parent::createContext(); } }; + $info = LaravelMcpAdapter::serverInfo($server); - $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($server))()); + $this->assertSame([null, null], $info()); + $this->assertSame([null, null], $info()); $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($warning))(), 'a warning counts as a failure'); + $this->assertSame([ + ['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE], + ['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE], + ], $logger->records); + } + + #[Group('ac-02-43')] + public function testTheHttpContextIsTheRequestOfTheServersOwnTransport(): void + { + $request = Request::create('/mcp/weather?unit=c', 'POST', server: ['HTTP_HOST' => 'example.test', 'HTTP_X_TRACE' => 't1']); + $server = $this->conformanceServer(new HttpTransport($request)); + + $http = (LaravelMcpAdapter::httpContext($server))(); + + $this->assertInstanceOf(HttpContext::class, $http); + $this->assertSame('POST', $http->method); + $this->assertSame('http://example.test/mcp/weather?unit=c', $http->url); + $this->assertSame('t1', $http->headers['x-trace'] ?? null); + $this->assertSame('example.test', $http->headers['host'] ?? null); + } + + public function testAServerOnAnotherTransportOrGoneHasNoHttpContext(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $stdio = LaravelMcpAdapter::httpContext($this->conformanceServer()); + $server = $this->conformanceServer(new HttpTransport(Request::create('/mcp', 'POST'))); + $gone = LaravelMcpAdapter::httpContext($server); + unset($server); + + $this->assertNull($stdio()); + $this->assertNull($gone()); + $this->assertSame([], $logger->messages()); + } + + /** @return iterable */ + public static function unreadableTransports(): iterable + { + yield 'the server\'s transport' => [static function (Server $server): void { + Members::uninitialize(Server::class, 'transport')($server); + }]; + yield 'the transport\'s request' => [static function (Server $server): void { + Members::uninitialize(HttpTransport::class, 'request')(ServerAccess::transport($server)); + }]; + } + + /** + * A drifted transport costs the HTTP context, never the event: one line per server (plan E1 Task 4 carry, D32). + * + * @param \Closure(Server): void $drift + */ + #[DataProvider('unreadableTransports')] + public function testAnUnreadableTransportIsNoHttpContextAndLoggedOncePerServer(\Closure $drift): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $server = $this->conformanceServer(new HttpTransport(Request::create('/mcp', 'POST'))); + $drift($server); + $http = LaravelMcpAdapter::httpContext($server); + + $this->assertNull($http()); + $this->assertNull($http()); + $this->assertSame([['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE]], $logger->records); } - private function conformanceServer(): ConformanceServer + private function conformanceServer(Transport $transport = new LineTransport()): ConformanceServer { - $server = $this->app?->make(ConformanceServer::class, ['transport' => new LineTransport()]); + $server = $this->app?->make(ConformanceServer::class, ['transport' => $transport]); $this->assertInstanceOf(ConformanceServer::class, $server); return $server; diff --git a/tests/Integration/LaravelMcp/ListToolsTest.php b/tests/Integration/LaravelMcp/ListToolsTest.php index 67c4d00..fece5c5 100644 --- a/tests/Integration/LaravelMcp/ListToolsTest.php +++ b/tests/Integration/LaravelMcp/ListToolsTest.php @@ -25,8 +25,8 @@ use AgentCat\Tests\Support\LaravelMcp\PagedServer; use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; use AgentCat\Tests\Support\Thrown; -use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Methods\Initialize; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Transport\JsonRpcRequest; use Laravel\Mcp\Transport\JsonRpcResponse; use PHPUnit\Framework\Attributes\DataProvider; @@ -156,6 +156,7 @@ public function testAThrowingListHandlerGivesTheSameErrorAsWithoutAgentCat(): vo $this->assertSame([], $fixture->lines()); } + /** Task 4 review M1: PHP's own `Error`, not an AgentCat exception, reaches laravel/mcp's `report()`. */ public function testAListHandlerTheContainerCannotResolveToAMethodFailsLikeWithoutAgentCat(): void { $this->app?->bind(RecordingMethod::class, static fn(): \stdClass => new \stdClass()); @@ -167,8 +168,26 @@ public function testAListHandlerTheContainerCannotResolveToAMethodFailsLikeWitho $this->assertEquals((object) ['code' => -32603, 'message' => 'Something went wrong while processing the request.'], JsonAssert::at($answer, 'error')); $this->assertEquals($twin, $answer); $this->assertSame([], $fixture->lines()); - $error = Thrown::by(\UnexpectedValueException::class, static fn(): Method => Delegate::of(RecordingMethod::class)); - $this->assertSame('The container resolved ' . RecordingMethod::class . ' to stdClass, which is not a ' . Method::class, $error->getMessage()); + $context = (new ListingServer(new LineTransport()))->createContext(); + $error = Thrown::by(\Error::class, static fn(): mixed => Delegate::handle(RecordingMethod::class, new JsonRpcRequest(1, 'tools/list', []), $context)); + $this->assertSame(\Error::class, $error::class); + $this->assertSame('Call to undefined method stdClass::handle()', $error->getMessage()); + } + + /** Task 4 review M1: laravel/mcp calls `handle()` on whatever the container returns, so AgentCat does too. */ + public function testAListHandlerThatIsNoMethodButHasHandleIsCalledAndWrapped(): void + { + $this->app?->bind(RecordingMethod::class, static fn(): object => new class { + public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpcResponse + { + return JsonRpcResponse::result($request->id, ['tools' => [['name' => 'duck', 'inputSchema' => ['type' => 'object']]]]); + } + }); + $fixture = new LaravelTrackingFixture(); + + $result = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->listTools(); + + $this->assertSame(['duck', GetMoreTools::NAME], $this->names($result)); } public function testAJsonRpcErrorFromTheListHandlerPassesUnchanged(): void diff --git a/tests/Support/FakeCallSeam.php b/tests/Support/FakeCallSeam.php index c06f78e..87797a6 100644 --- a/tests/Support/FakeCallSeam.php +++ b/tests/Support/FakeCallSeam.php @@ -32,6 +32,14 @@ final class FakeCallSeam implements CallSeam public ?\Throwable $replaceFailure = null; + /** + * What `later()` does with a result: null answers null (an ordinary result); a closure gets the result and the + * interceptor's two callbacks and returns the result in its place. + * + * @var (\Closure(\stdClass, \Closure(\stdClass): \stdClass, \Closure(\Throwable): void): \stdClass)|null + */ + public ?\Closure $later = null; + public function __construct( public string $name, public mixed $arguments, @@ -120,6 +128,12 @@ public function listOriginal(): ?array return $this->listing; } + #[\Override] + public function later(mixed $result, \Closure $complete, \Closure $fail): ?\stdClass + { + return $this->later instanceof \Closure ? ($this->later)($result, $complete, $fail) : null; + } + private function respond(): \stdClass { if ($this->result instanceof \Throwable) { diff --git a/tests/Support/LaravelMcp/CustomCallServer.php b/tests/Support/LaravelMcp/CustomCallServer.php new file mode 100644 index 0000000..971449b --- /dev/null +++ b/tests/Support/LaravelMcp/CustomCallServer.php @@ -0,0 +1,28 @@ +tools = [EchoTool::class]; + $this->addMethod('tools/call', CustomCallTool::class); + } +} diff --git a/tests/Support/LaravelMcp/CustomCallTool.php b/tests/Support/LaravelMcp/CustomCallTool.php new file mode 100644 index 0000000..cae4dd3 --- /dev/null +++ b/tests/Support/LaravelMcp/CustomCallTool.php @@ -0,0 +1,43 @@ +> the arguments of the constructor's `Request`, one entry per call */ + public static array $constructed = []; + + public function __construct(Request $request) + { + self::$constructed[] = $request->all(); + } + + public static function reset(): void + { + self::$calls = 0; + self::$constructed = []; + } + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + ++self::$calls; + return Container::getInstance()->make(CallTool::class)->handle($request, $context); + } +} diff --git a/tests/Support/LaravelMcp/RecordingCallServer.php b/tests/Support/LaravelMcp/RecordingCallServer.php new file mode 100644 index 0000000..50df0cd --- /dev/null +++ b/tests/Support/LaravelMcp/RecordingCallServer.php @@ -0,0 +1,27 @@ +tools = [EchoTool::class]; + $this->addMethod('tools/call', RecordingMethod::class); + } +} diff --git a/tests/Support/LaravelMcp/RequestEchoServer.php b/tests/Support/LaravelMcp/RequestEchoServer.php new file mode 100644 index 0000000..1902d13 --- /dev/null +++ b/tests/Support/LaravelMcp/RequestEchoServer.php @@ -0,0 +1,23 @@ +tools = [RequestEchoTool::class]; + } +} diff --git a/tests/Support/LaravelMcp/StreamingServer.php b/tests/Support/LaravelMcp/StreamingServer.php new file mode 100644 index 0000000..ca0049c --- /dev/null +++ b/tests/Support/LaravelMcp/StreamingServer.php @@ -0,0 +1,24 @@ +tools = [StreamingTool::class, FailingStreamTool::class]; + } +} diff --git a/tests/Support/LaravelMcp/ToolCalls.php b/tests/Support/LaravelMcp/ToolCalls.php index 0942c51..58957fe 100644 --- a/tests/Support/LaravelMcp/ToolCalls.php +++ b/tests/Support/LaravelMcp/ToolCalls.php @@ -22,8 +22,10 @@ public static function of(string $tool): array return self::$calls[$tool] ?? []; } + /** Forgets every call, `CustomCallTool`'s too. */ public static function reset(): void { self::$calls = []; + CustomCallTool::reset(); } } diff --git a/tests/Support/LaravelMcp/Tools/FailingStreamTool.php b/tests/Support/LaravelMcp/Tools/FailingStreamTool.php new file mode 100644 index 0000000..0d9305a --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/FailingStreamTool.php @@ -0,0 +1,27 @@ + */ + public function handle(Request $request): \Generator + { + ToolCalls::record('stream-fails', $request->all()); + yield Response::notification('notifications/progress', ['progress' => 1]); + + throw new \DomainException('stream broke'); + } +} diff --git a/tests/Support/LaravelMcp/Tools/RequestEchoTool.php b/tests/Support/LaravelMcp/Tools/RequestEchoTool.php new file mode 100644 index 0000000..71d9237 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/RequestEchoTool.php @@ -0,0 +1,27 @@ +make('mcp.request'); + ToolCalls::record('request-echo', ['request' => $request->all(), 'container' => $bound instanceof Request ? $bound->all() : null]); + + return Response::text('ok'); + } +} diff --git a/tests/Support/LaravelMcp/Tools/StreamingTool.php b/tests/Support/LaravelMcp/Tools/StreamingTool.php new file mode 100644 index 0000000..cf85a04 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/StreamingTool.php @@ -0,0 +1,29 @@ + */ + public function handle(Request $request): \Generator + { + ToolCalls::record('stream', $request->all()); + yield Response::notification('notifications/progress', ['progress' => 1]); + usleep(50_000); + yield Response::notification('notifications/progress', ['progress' => 2]); + usleep(50_000); + yield Response::text('done'); + } +} diff --git a/tests/Unit/Internal/Engine/CallInterceptorTest.php b/tests/Unit/Internal/Engine/CallInterceptorTest.php index a167438..1364d11 100644 --- a/tests/Unit/Internal/Engine/CallInterceptorTest.php +++ b/tests/Unit/Internal/Engine/CallInterceptorTest.php @@ -463,6 +463,103 @@ public function testAFailedErrorPublishStillRethrowsTheLibraryException(): void $this->assertSame([self::POST_HANDLER . 'LogicException: info'], $this->logger->messages()); } + public function testAResultThatCompletesLaterIsPublishedWhenItCompletes(): void + { + $seam = $this->seam('echo', ['msg' => 'hi']); + $finish = null; + $seen = null; + $seam->later = static function (\stdClass $result, \Closure $complete, \Closure $fail) use (&$finish, &$seen): \stdClass { + $seen = $result; + $finish = $complete; + + return (object) ['pending' => true]; + }; + + $answer = $this->interceptor->intercept($this->server(), $seam); + + $this->assertEquals((object) ['pending' => true], $answer); + $this->assertSame($seam->result, $seen); + $this->assertSame([], $this->events()); // nothing until the stream ends + $this->assertInstanceOf(\Closure::class, $finish); + $final = $finish(FakeCallSeam::ok()); + $event = $this->onlyEvent(); + $block = JsonAssert::at($final, 'content', 0, 'text'); + $this->assertIsString($block); + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $block); + $this->assertStringContainsString('session_id: ' . $event->sessionId, $block); + $this->assertEquals(FakeCallSeam::ok(), $event->response); + $this->assertSame([['stripped', self::HEURISTIC], ['replace']], $seam->calls); + $this->assertSame([], $this->logger->messages()); + } + + #[Group('ac-05-E5')] + public function testAStreamThatFailsIsOneErrorEvent(): void + { + $seam = $this->seam('echo', ['msg' => 'hi']); + $fail = null; + $seam->later = static function (\stdClass $result, \Closure $complete, \Closure $failed) use (&$fail): \stdClass { + $fail = $failed; + + return (object) []; + }; + + $this->interceptor->intercept($this->server(), $seam); + $this->assertInstanceOf(\Closure::class, $fail); + $fail(new \DomainException('stream broke')); + + $event = $this->onlyEvent(); + $this->assertTrue($event->isError); + $this->assertSame('DomainException', JsonAssert::at($event->error, 'type')); + $this->assertSame('stream broke', JsonAssert::at($event->error, 'message')); + $this->assertNull($event->response); + $this->assertSame([], $this->logger->messages()); + } + + /** D32: a failure while recording a failed stream is logged; the stream's own exception stays the seam's. */ + public function testAFailedStreamWhosePublishFailsIsLogged(): void + { + $seam = $this->seam('echo', ['msg' => 'hi']); + $fail = null; + $seam->later = static function (\stdClass $result, \Closure $complete, \Closure $failed) use (&$fail): \stdClass { + $fail = $failed; + + return (object) []; + }; + + $this->interceptor->intercept($this->server(serverInfo: $this->failingServerInfo()), $seam); + $this->assertInstanceOf(\Closure::class, $fail); + $fail(new \DomainException('stream broke')); + + $this->assertSame([], $this->events()); + $this->assertSame([self::POST_HANDLER . 'LogicException: info'], $this->logger->messages()); + } + + public function testALaterThatThrowsFallsBackToAnOrdinaryFinish(): void + { + $seam = $this->seam('echo', ['msg' => 'hi']); + $seam->later = static fn(): never => throw new \LogicException('later broke'); + + $answer = $this->interceptor->intercept($this->server(), $seam); + + $this->assertCount(1, $this->events()); + $this->assertSame([self::POST_HANDLER . 'LogicException: later broke'], $this->logger->messages()); + $block = JsonAssert::at($answer, 'content', 0, 'text'); + $this->assertIsString($block); + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $block); + } + + public function testWithTracingOffAResultIsNeverAskedToCompleteLater(): void + { + $seam = $this->seam('echo', ['msg' => 'hi']); + $seam->later = static fn(): never => throw new \LogicException('later asked'); + + $answer = $this->interceptor->intercept($this->server(new Options(enableTracing: false)), $seam); + + $this->assertSame($seam->result, $answer); + $this->assertSame([], $this->events()); + $this->assertSame([], $this->logger->messages()); + } + #[Group('ac-02-40')] #[Group('ac-04-46')] public function testGetMoreToolsIsAnsweredWithoutTheCustomer(): void diff --git a/tests/Unit/Internal/Engine/CallTreesTest.php b/tests/Unit/Internal/Engine/CallTreesTest.php new file mode 100644 index 0000000..2a81fde --- /dev/null +++ b/tests/Unit/Internal/Engine/CallTreesTest.php @@ -0,0 +1,78 @@ +|null, ?string}> */ + public static function claims(): iterable + { + yield 'a version' => [[Constants::META_PROTOCOL_VERSION_KEY => '2026-07-28'], '2026-07-28']; + yield 'an empty string' => [[Constants::META_PROTOCOL_VERSION_KEY => ''], null]; + yield 'a number' => [[Constants::META_PROTOCOL_VERSION_KEY => 20260728], null]; + yield 'no claim' => [['progressToken' => 'p'], null]; + yield 'no _meta' => [null, null]; + } + + /** + * 02 §3.3: only what the call itself claims, as a non-empty string. + * + * @param array|null $meta + */ + #[DataProvider('claims')] + public function testTheClaimedVersionIsANonEmptyStringInTheMeta(?array $meta, ?string $expected): void + { + $this->assertSame($expected, CallTrees::claimedVersion($meta)); + } + + public function testTheRequestTreeHasTheRawArgumentsAndTheMetaAsObjects(): void + { + $tree = CallTrees::request(['name' => 'echo', 'arguments' => ['old' => true], 'extra' => [1, 2]], ['msg' => 'hi', 'session_id' => 'start'], ['progressToken' => 'p1']); + + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","session_id":"start"},"extra":[1,2],"_meta":{"progressToken":"p1"}}}', $this->json($tree)); + } + + #[Group('ac-02-47')] + public function testEmptyArgumentsAndAnEmptyMetaAreObjectsAndIntegerKeysStayKeys(): void + { + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{},"_meta":{}}}', $this->json(CallTrees::request(['name' => 'echo'], [], []))); + $this->assertSame('{"method":"tools/call","params":{"arguments":{"0":"a","b":1}}}', $this->json(CallTrees::request([], [0 => 'a', 'b' => 1], null))); + } + + public function testTheExtraTreeHasTheD34KeysInOrder(): void + { + $http = new HttpContext('POST', 'http://x/mcp', ['x-agentcat-health-run' => 'S']); + $auth = (object) ['scopes' => ['read'], 'claims' => new \stdClass()]; + + $extra = CallTrees::extra('mcp-session', 7, ['progressToken' => 'p1'], $http, $auth); + + $this->assertSame('{"sessionId":"mcp-session","requestId":7,"_meta":{"progressToken":"p1"},"requestInfo":{"headers":{"x-agentcat-health-run":"S"},"url":"http://x/mcp"},"authInfo":{"scopes":["read"],"claims":{}}}', $this->json($extra)); + } + + public function testTheExtraTreeLeavesOutWhatTheCallDoesNotHave(): void + { + $this->assertSame('{"requestId":"r1"}', $this->json(CallTrees::extra(null, 'r1', null, null))); + $this->assertSame('{"requestId":1,"_meta":{}}', $this->json(CallTrees::extra(null, 1, [], null))); + } + + public function testAValueJsonCannotCarryThrows(): void + { + $this->expectException(\JsonException::class); + + CallTrees::request([], ['n' => NAN], null); + } + + private function json(mixed $tree): string + { + return json_encode($tree, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Unit/LaravelMcp/LaravelCallSeamTest.php b/tests/Unit/LaravelMcp/LaravelCallSeamTest.php new file mode 100644 index 0000000..03a5513 --- /dev/null +++ b/tests/Unit/LaravelMcp/LaravelCallSeamTest.php @@ -0,0 +1,276 @@ + the requests the delegate answered, in order */ + private array $sent = []; + + #[\Override] + protected function setUp(): void + { + $this->context = (new ConformanceServer(new LineTransport()))->createContext(); + } + + public function testTheNameAndArgumentsComeFromTheParams(): void + { + $seam = $this->seam(['name' => 'echo', 'arguments' => ['msg' => 'hi', 3 => 'three']]); + $odd = $this->seam(['name' => 7, 'arguments' => 'x']); + + $this->assertSame('echo', $seam->toolName()); + $this->assertSame('{"msg":"hi","3":"three"}', json_encode($seam->arguments())); + $this->assertSame('', $odd->toolName()); + $this->assertEquals(new \stdClass(), $odd->arguments()); + } + + public function testTheOriginalCallSendsTheRequestItself(): void + { + $request = new JsonRpcRequest(5, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'context' => 'why']]); + + $this->seamFor($request)->callOriginal(); + + $this->assertSame([$request], $this->sent); + } + + public function testTheStrippedCallSendsACopyWithoutTheNames(): void + { + $params = ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start'], '_meta' => ['progressToken' => 'p']]; + $request = new JsonRpcRequest('r5', 'tools/call', $params); + + $this->seamFor($request)->callStripped(['context', 'session_id']); + + $sent = $this->sent[0] ?? null; + $this->assertInstanceOf(JsonRpcRequest::class, $sent); + $this->assertNotSame($request, $sent); + $this->assertSame('r5', $sent->id); + $this->assertSame('tools/call', $sent->method); + $this->assertSame(['name' => 'echo', 'arguments' => ['msg' => 'hi'], '_meta' => ['progressToken' => 'p']], $sent->params); + $this->assertSame($params, $request->params, 'the request as received stays untouched'); + } + + public function testAStrippedCallWithoutArgumentsSendsNone(): void + { + $this->seam(['name' => 'echo'])->callStripped(['context']); + + $this->assertSame(['name' => 'echo'], ($this->sent[0] ?? null)?->params); + } + + public function testAResultIsObservedAsItsTreeWithTheToolsException(): void + { + $stash = new ThrowableStash(); + $error = new \RuntimeException('tool boom'); + $seam = $this->seam(['name' => 'echo'], static function (JsonRpcRequest $request) use ($stash, $error): JsonRpcResponse { + $stash->put($request, $error); + + return JsonRpcResponse::result($request->id, ['content' => [], 'isError' => true]); + }, stash: $stash); + + $observed = $seam->observe($seam->callStripped([])); + + $this->assertSame('{"content":[],"isError":true}', json_encode($observed->tree)); + $this->assertNull($observed->protocolError); + $this->assertSame($error, $observed->thrown); + $this->assertNull($seam->observe(JsonRpcResponse::result(5, ['content' => []]))->thrown, 'taken once'); + } + + public function testAJsonRpcErrorIsObservedAsItsMessage(): void + { + $seam = $this->seam(['name' => 'echo']); + + $this->assertSame('bad', $seam->observe(JsonRpcResponse::error(5, -32602, 'bad'))->protocolError); + $this->assertSame('', $seam->observe(new JsonRpcResponse(['id' => 5, 'error' => ['code' => 1]]))->protocolError); + $this->assertNull($seam->observe(JsonRpcResponse::error(5, -32602, 'bad'))->tree); + } + + public function testAStreamIsObservedWithoutATree(): void + { + $observed = $this->seam(['name' => 'echo'])->observe([JsonRpcResponse::result(5, ['content' => []])]); + + $this->assertNull($observed->tree); + $this->assertNull($observed->protocolError); + $this->assertNull($observed->thrown); + } + + public function testReplaceSwapsTheResultOnly(): void + { + $seam = $this->seam(['name' => 'echo']); + $result = new JsonRpcResponse(['id' => 5, 'result' => ['content' => []], 'extra' => 'kept']); + + $replaced = $seam->replace($result, (object) ['content' => [(object) ['type' => 'text', 'text' => 'x']], '_meta' => (object) ['k' => 'v']]); + + $this->assertInstanceOf(JsonRpcResponse::class, $replaced); + $this->assertNotSame($result, $replaced); + $this->assertSame(['id' => 5, 'result' => ['content' => [['type' => 'text', 'text' => 'x']], '_meta' => ['k' => 'v']], 'extra' => 'kept'], $replaced->content); + $this->assertSame(['id' => 5, 'result' => ['content' => []], 'extra' => 'kept'], $result->content); + } + + public function testReplaceLeavesAnErrorAndAStreamAlone(): void + { + $seam = $this->seam(['name' => 'echo']); + $error = JsonRpcResponse::error(5, -32602, 'bad'); + $stream = [JsonRpcResponse::result(5, ['content' => []])]; + + $this->assertSame($error, $seam->replace($error, new \stdClass())); + $this->assertSame($stream, $seam->replace($stream, new \stdClass())); + } + + public function testAnAnswerIsAResultForTheRequest(): void + { + $answer = $this->seam(['name' => 'get_more_tools'])->answer((object) ['content' => [(object) ['type' => 'text', 'text' => 'x']]]); + + $this->assertSame(['id' => 5, 'result' => ['content' => [['type' => 'text', 'text' => 'x']]]], $answer->content); + $this->assertSame([], $this->sent); + } + + public function testTheRebuildListsTheFirstPageWithTheListHandler(): void + { + $listed = []; + $seam = $this->seam(['name' => 'echo'], list: static function (JsonRpcRequest $request) use (&$listed): JsonRpcResponse { + $listed[] = $request; + + return JsonRpcResponse::result($request->id, ['tools' => [['name' => 'echo']]]); + }); + + $this->assertEquals([(object) ['name' => 'echo']], $seam->listOriginal()); + $this->assertCount(1, $listed); + $this->assertSame(['agentcat-rebuild', 'tools/list', []], [$listed[0]->id, $listed[0]->method, $listed[0]->params]); + $this->assertNull($this->seam(['name' => 'echo'])->listOriginal(), 'no list handler'); + $this->assertNull($this->seam(['name' => 'echo'], list: static fn(JsonRpcRequest $request): array => [JsonRpcResponse::result($request->id, ['tools' => []])])->listOriginal(), 'a streamed listing'); + } + + public function testAnOrdinaryResultDoesNotCompleteLater(): void + { + $this->assertNull($this->seam(['name' => 'echo'])->later(JsonRpcResponse::result(5, []), $this->unexpected(...), $this->unexpected(...))); + } + + public function testAStreamCompletesWithItsFirstAnswerAndPassesTheRest(): void + { + $completed = new \ArrayObject(); + $first = JsonRpcResponse::result(5, ['content' => []]); + $second = JsonRpcResponse::result(5, ['content' => [['type' => 'text', 'text' => 'again']]]); + $decorated = JsonRpcResponse::result(5, ['content' => [['type' => 'text', 'text' => 'decorated']]]); + $note = JsonRpcResponse::notification('notifications/progress', ['progress' => 1]); + $complete = static function (mixed $final) use ($completed, $decorated): JsonRpcResponse { + $completed->append($final); + + return $decorated; + }; + + $stream = $this->seam(['name' => 'echo'])->later([$note, $first, $note, $second], $complete, $this->unexpected(...)); + + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertSame([], $completed->getArrayCopy(), 'nothing completes before the stream runs'); + $this->assertSame([0 => $note, 1 => $decorated, 2 => $note, 3 => $second], iterator_to_array($stream)); + $this->assertSame([$first], $completed->getArrayCopy()); + } + + public function testAnErrorAnswerCompletesAStreamToo(): void + { + $completed = new \ArrayObject(); + $error = JsonRpcResponse::error(5, -32602, 'bad'); + $complete = static function (mixed $final) use ($completed): mixed { + $completed->append($final); + + return $final; + }; + + $stream = $this->seam(['name' => 'echo'])->later([$error], $complete, $this->unexpected(...)); + + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertSame([$error], iterator_to_array($stream)); + $this->assertSame([$error], $completed->getArrayCopy()); + } + + public function testACompletionThatGivesAStreamIsSentMessageByMessage(): void + { + $note = JsonRpcResponse::notification('notifications/progress', ['progress' => 1]); + $final = JsonRpcResponse::result(5, ['content' => []]); + + $stream = $this->seam(['name' => 'echo'])->later([$note, $final], static fn(): array => [$final, $note], $this->unexpected(...)); + + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertSame([0 => $note, 1 => $final, 2 => $note], iterator_to_array($stream)); + } + + public function testAStreamThatThrowsBeforeItsAnswerFailsThenRethrows(): void + { + $failures = new \ArrayObject(); + $broke = new \DomainException('stream broke'); + $fail = static function (\Throwable $e) use ($failures): void { + $failures->append($e); + }; + $source = (static function () use ($broke): \Generator { + yield JsonRpcResponse::notification('notifications/progress', ['progress' => 1]); + + throw $broke; + })(); + + $stream = $this->seam(['name' => 'echo'])->later($source, $this->unexpected(...), $fail); + + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertSame($broke, Thrown::by(\DomainException::class, static fn(): array => iterator_to_array($stream))); + $this->assertSame([$broke], $failures->getArrayCopy()); + } + + public function testAStreamThatThrowsAfterItsAnswerDoesNotFailTheCall(): void + { + $broke = new \DomainException('after'); + $source = (static function () use ($broke): \Generator { + yield JsonRpcResponse::result(5, ['content' => []]); + + throw $broke; + })(); + + $stream = $this->seam(['name' => 'echo'])->later($source, static fn(mixed $final): mixed => $final, $this->unexpected(...)); + + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertSame($broke, Thrown::by(\DomainException::class, static fn(): array => iterator_to_array($stream))); + } + + private function unexpected(): never + { + self::fail('not expected to run'); + } + + /** + * @param array $params + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $answer + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $list + */ + private function seam(array $params, ?\Closure $answer = null, ?\Closure $list = null, ThrowableStash $stash = new ThrowableStash()): LaravelCallSeam + { + return $this->seamFor(new JsonRpcRequest(5, 'tools/call', $params), $answer, $list, $stash); + } + + /** + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $answer + * @param (\Closure(JsonRpcRequest): (iterable|JsonRpcResponse))|null $list + */ + private function seamFor(JsonRpcRequest $request, ?\Closure $answer = null, ?\Closure $list = null, ThrowableStash $stash = new ThrowableStash()): LaravelCallSeam + { + $delegate = function (JsonRpcRequest $sent) use ($answer): iterable|JsonRpcResponse { + $this->sent[] = $sent; + + return $answer instanceof \Closure ? $answer($sent) : JsonRpcResponse::result($sent->id, ['content' => []]); + }; + + return new LaravelCallSeam($request, $this->context, $delegate, $list, $stash, new LaravelRequestReader(static fn(): null => null, new HandshakeClient())); + } +} diff --git a/tests/Unit/LaravelMcp/LaravelHttpContextTest.php b/tests/Unit/LaravelMcp/LaravelHttpContextTest.php new file mode 100644 index 0000000..f02247c --- /dev/null +++ b/tests/Unit/LaravelMcp/LaravelHttpContextTest.php @@ -0,0 +1,36 @@ + 'S', 'HTTP_AUTHORIZATION' => 'Bearer t']); + + $context = LaravelHttpContext::of($request); + + $this->assertSame('POST', $context->method); + $this->assertSame('https://api.test/mcp/weather?unit=c', $context->url); + $this->assertSame('S', $context->headers['x-agentcat-health-run'] ?? null); + $this->assertSame('Bearer t', $context->headers['authorization'] ?? null); + } + + public function testRepeatedValuesAreJoinedAndNamesAreLowerCase(): void + { + $request = Request::create('/mcp', 'GET'); + $request->headers->replace([]); + $request->headers->set('X-Multi', ['a', 'b']); + $request->headers->set('X-Empty', null); + + $this->assertSame(['x-multi' => 'a, b', 'x-empty' => ''], LaravelHttpContext::of($request)->headers); + } +} diff --git a/tests/Unit/LaravelMcp/LaravelRequestReaderTest.php b/tests/Unit/LaravelMcp/LaravelRequestReaderTest.php new file mode 100644 index 0000000..9c30ddd --- /dev/null +++ b/tests/Unit/LaravelMcp/LaravelRequestReaderTest.php @@ -0,0 +1,102 @@ +context = (new ConformanceServer(new LineTransport()))->createContext(); + } + + #[Group('ac-02-43')] + public function testTheReaderBuildsTheTreesAndTheToolCall(): void + { + $http = new HttpContext('POST', 'http://localhost/mcp', ['x-agentcat-health-run' => 'S']); + $meta = [Constants::META_PROTOCOL_VERSION_KEY => '2026-07-28', Constants::META_CLIENT_INFO_KEY => ['name' => 'modern', 'version' => '2'], 'progressToken' => 'p1']; + $request = new JsonRpcRequest(4, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'context' => 'why'], '_meta' => $meta]); + + $context = (new LaravelRequestReader(static fn(): HttpContext => $http, new HandshakeClient()))->read($request, $this->context); + + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","context":"why"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"modern","version":"2"},"progressToken":"p1"}}}', $this->json($context->request)); + $this->assertSame('{"requestId":4,"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"modern","version":"2"},"progressToken":"p1"},"requestInfo":{"headers":{"x-agentcat-health-run":"S"},"url":"http://localhost/mcp"}}', $this->json($context->extra)); + $this->assertSame(['2026-07-28', 'modern', '2', false], [$context->protocolVersion, $context->clientName, $context->clientVersion, $context->continuation]); + $call = $context->toolCall; + $this->assertSame('echo', $call->toolName); + $this->assertSame(['msg' => 'hi', 'context' => 'why'], $call->arguments); + $this->assertSame($meta, $call->meta); + $this->assertSame(['x-agentcat-health-run' => 'S'], $call->headers); + $this->assertSame(['modern', '2', '2026-07-28', null], [$call->clientName, $call->clientVersion, $call->protocolVersion, $call->transportSessionId]); + $this->assertSame($request, $call->request); + $this->assertSame($this->context, $call->context); + } + + #[Group('ac-02-44')] + public function testWithoutMetaTheClientIsTheHandshakesAndThereIsNoProtocolClaim(): void + { + $handshake = new HandshakeClient(); + $handshake->record(['name' => 'desktop', 'version' => '0.9']); + $request = new JsonRpcRequest('r', 'tools/call', ['name' => 'echo', 'arguments' => []]); + + $context = (new LaravelRequestReader(static fn(): null => null, $handshake))->read($request, $this->context); + + $this->assertSame(['desktop', '0.9', null], [$context->clientName, $context->clientVersion, $context->protocolVersion]); + $this->assertSame('{"requestId":"r"}', $this->json($context->extra)); + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{}}}', $this->json($context->request)); + $this->assertSame([], $context->toolCall->meta); + $this->assertSame([], $context->toolCall->headers); + } + + public function testTheCallsClientInfoBeatsTheHandshake(): void + { + $handshake = new HandshakeClient(); + $handshake->record(['name' => 'desktop', 'version' => '0.9']); + $request = new JsonRpcRequest(1, 'tools/call', ['name' => 'echo', '_meta' => [Constants::META_CLIENT_INFO_KEY => ['name' => 'modern']]]); + + $context = (new LaravelRequestReader(static fn(): null => null, $handshake))->read($request, $this->context); + + $this->assertSame(['modern', null], [$context->clientName, $context->clientVersion]); + } + + public function testAnOddNameAndOddArgumentsAreReadAsNone(): void + { + $request = new JsonRpcRequest(1, 'tools/call', ['name' => ['echo'], 'arguments' => 'x', '_meta' => []]); + + $context = (new LaravelRequestReader(static fn(): null => null, new HandshakeClient()))->read($request, $this->context); + + $this->assertSame('', $context->toolCall->toolName); + $this->assertSame([], $context->toolCall->arguments); + $this->assertSame('{"method":"tools/call","params":{"name":["echo"],"arguments":{},"_meta":{}}}', $this->json($context->request)); + $this->assertSame('{"requestId":1,"_meta":{}}', $this->json($context->extra)); + } + + public function testARoundWithInputResponsesIsAContinuation(): void + { + $request = new JsonRpcRequest(1, 'tools/call', ['name' => 'echo', 'inputResponses' => ['a' => ['ok' => true]]]); + + $context = (new LaravelRequestReader(static fn(): null => null, new HandshakeClient()))->read($request, $this->context); + + $this->assertTrue($context->continuation); + } + + private function json(mixed $tree): string + { + return json_encode($tree, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} From 4bc9cf2e6fbca5798163bbeef8e7e4a559d0eafb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 19:17:46 +0000 Subject: [PATCH 08/30] fix: share the once-per-object warning and keep customer and client errors out of the laravel/mcp drift line --- src/Internal/Logging/OncePerObject.php | 30 ++++ src/LaravelMcp/Internal/Drift.php | 32 ---- src/LaravelMcp/Internal/TrackedCallTool.php | 6 +- src/LaravelMcp/LaravelMcpAdapter.php | 53 ++++--- src/McpSdk/Internal/Drift.php | 15 +- tests/Integration/LaravelMcp/CallToolTest.php | 147 ++++++++++++++++++ .../LaravelMcp/LaravelMcpAdapterTest.php | 36 +++-- tests/Support/LaravelMcp/StreamingServer.php | 13 ++ .../Internal/Logging/OncePerObjectTest.php | 52 +++++++ 9 files changed, 304 insertions(+), 80 deletions(-) create mode 100644 src/Internal/Logging/OncePerObject.php delete mode 100644 src/LaravelMcp/Internal/Drift.php create mode 100644 tests/Unit/Internal/Logging/OncePerObjectTest.php diff --git a/src/Internal/Logging/OncePerObject.php b/src/Internal/Logging/OncePerObject.php new file mode 100644 index 0000000..956a9ac --- /dev/null +++ b/src/Internal/Logging/OncePerObject.php @@ -0,0 +1,30 @@ +>|null the lines logged for each object */ + private static ?\WeakMap $logged = null; + + public static function warn(object $subject, string $line): void + { + self::$logged ??= new \WeakMap(); + $lines = self::$logged[$subject] ?? []; + if (isset($lines[$line])) { + return; + } + $lines[$line] = true; + self::$logged[$subject] = $lines; + Log::warning($line); + } +} diff --git a/src/LaravelMcp/Internal/Drift.php b/src/LaravelMcp/Internal/Drift.php deleted file mode 100644 index 0d3918e..0000000 --- a/src/LaravelMcp/Internal/Drift.php +++ /dev/null @@ -1,32 +0,0 @@ -|null */ - private static ?\WeakMap $reported = null; - - public static function report(Server $server): void - { - self::$reported ??= new \WeakMap(); - if (self::$reported[$server] ?? false) { - return; - } - self::$reported[$server] = true; - Log::warning(LaravelMcpAdapter::COMPATIBILITY_LINE); - } -} diff --git a/src/LaravelMcp/Internal/TrackedCallTool.php b/src/LaravelMcp/Internal/TrackedCallTool.php index 2bfeab9..8992624 100644 --- a/src/LaravelMcp/Internal/TrackedCallTool.php +++ b/src/LaravelMcp/Internal/TrackedCallTool.php @@ -19,7 +19,8 @@ * Answers `tools/call` (TS callWrap): `CallInterceptor` resolves the handles, strips them, lets the handler the * server's entry named before AgentCat's run the tool, decorates the answer and queues one event. For laravel/mcp's * own `CallTool`, AgentCat answers with `CapturingCallTool`, which keeps the tool's exception (plan E1 header - * conflict 5); a customer's handler, or a container binding of `CallTool`, answers itself (header conflict 4). + * conflict 5); a customer's handler, or a container binding of `CallTool`, answers itself (header conflict 4). A call + * without a string tool name goes to the handler untraced. * * @internal */ @@ -49,6 +50,9 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl $delegate = $original === CallTool::class && !Container::getInstance()->bound(CallTool::class) ? static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => (new CapturingCallTool($stash))->handle($sent, $context) : static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => Delegate::handle($original, $sent, $context); + if (!is_string($request->get('name'))) { + return $delegate($request); // TypeScript and mcp/sdk reject it before any handler: no event (D15) + } $list = $this->listOriginal; $seam = new LaravelCallSeam( $request, diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php index ff0cd2a..14d4d9f 100644 --- a/src/LaravelMcp/LaravelMcpAdapter.php +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -10,12 +10,12 @@ use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\OncePerObject; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Support\StrictErrors; use AgentCat\LaravelMcp\Internal\Access\HttpTransportAccess; use AgentCat\LaravelMcp\Internal\Access\ServerAccess; -use AgentCat\LaravelMcp\Internal\Drift; use AgentCat\LaravelMcp\Internal\HandshakeClient; use AgentCat\LaravelMcp\Internal\LaravelHttpContext; use AgentCat\LaravelMcp\Internal\LaravelRequestReader; @@ -25,7 +25,6 @@ use AgentCat\LaravelMcp\Internal\TrackedListTools; use Illuminate\Container\Container; use Illuminate\Http\Request; -use Laravel\Mcp\Schema\Implementation; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Transport\HttpTransport; @@ -83,8 +82,8 @@ public static function inspect(Server $server): void /** * The server name and version, read when an event is published. Holds the server weakly: the tracking state must - * not keep a finished request's server alive. When they cannot be read (the server's `createContext()` fails or - * warns), they are unknown, and the compatibility line is logged once per server. + * not keep a finished request's server alive. `createContext()` is public code that a server can override, not a + * protected-member read: when it throws, they are unknown, and AgentCat logs nothing. * * @return \Closure(): array{0: ?string, 1: ?string} */ @@ -98,12 +97,10 @@ public static function serverInfo(Server $server): \Closure return [null, null]; } try { - $implementation = StrictErrors::run(static fn(): Implementation => $server->createContext()->implementation); + $implementation = $server->createContext()->implementation; return [$implementation->name, $implementation->version]; } catch (\Throwable) { - Drift::report($server); - return [null, null]; } }; @@ -111,9 +108,11 @@ public static function serverInfo(Server $server): \Closure /** * The HTTP request the server answers, read from its own transport when a call is recorded (plan E1 header - * conflict 9); null on stdio and once the server is gone. Holds the server weakly, like `serverInfo()`. When the - * transport or its request cannot be read (drift), the call is recorded without HTTP context, and the - * compatibility line is logged once per server. Never throws. + * conflict 9); null on stdio and once the server is gone. Holds the server weakly, like `serverInfo()`. Never + * throws; the call is then recorded without HTTP context: + * - when the transport or its request cannot be read (drift), and the compatibility line is logged once per server; + * - when Laravel cannot read the request (Symfony refuses an invalid `Host` header, which the client sends), and + * nothing is logged. * * @return \Closure(): ?HttpContext */ @@ -123,22 +122,11 @@ public static function httpContext(Server $server): \Closure return static function () use ($reference): ?HttpContext { $server = $reference->get(); - if (!$server instanceof Server) { - return null; - } + $request = $server instanceof Server ? self::request($server) : null; try { - // Warnings count as drift for the protected-member reads only, not for Laravel's own request methods. - $request = StrictErrors::run(static function () use ($server): ?Request { - $transport = ServerAccess::transport($server); - - return $transport instanceof HttpTransport ? HttpTransportAccess::request($transport) : null; - }); - return $request instanceof Request ? LaravelHttpContext::of($request) : null; } catch (\Throwable) { - Drift::report($server); - - return null; + return null; // Laravel cannot read the request, which the client controls: this is no drift } }; } @@ -182,6 +170,25 @@ private static function entry(array $methods, string $method): ?string return is_string($entry) && is_a($entry, Method::class, true) ? $entry : null; } + /** + * The request of the server's HTTP transport; null on another transport, and when the transport or its request + * cannot be read (drift), which logs the compatibility line once per server. + */ + private static function request(Server $server): ?Request + { + try { + return StrictErrors::run(static function () use ($server): ?Request { + $transport = ServerAccess::transport($server); + + return $transport instanceof HttpTransport ? HttpTransportAccess::request($transport) : null; + }); + } catch (\Throwable) { + OncePerObject::warn($server, self::COMPATIBILITY_LINE); + + return null; + } + } + /** Binds AgentCat's method under this server's own key and points the server's entry at it. */ private static function point(Server $server, Container $container, string $method, Method $handler): void { diff --git a/src/McpSdk/Internal/Drift.php b/src/McpSdk/Internal/Drift.php index 0670878..31bd424 100644 --- a/src/McpSdk/Internal/Drift.php +++ b/src/McpSdk/Internal/Drift.php @@ -4,30 +4,21 @@ namespace AgentCat\McpSdk\Internal; -use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\OncePerObject; use AgentCat\McpSdk\McpSdkAdapter; use Mcp\Server; use Mcp\Server\Builder; /** * Runtime drift in what a Builder's build() assembled, or in a built server (C6, README §4.5 rule 4). The first drift - * on an object logs the compatibility line; later ones on the same object log nothing. Entries go away with their - * object. + * on an object logs the compatibility line; later ones on the same object log nothing. * * @internal */ final class Drift { - /** @var \WeakMap|null */ - private static ?\WeakMap $reported = null; - public static function report(Builder|Server $object): void { - self::$reported ??= new \WeakMap(); - if (self::$reported[$object] ?? false) { - return; - } - self::$reported[$object] = true; - Log::warning(McpSdkAdapter::COMPATIBILITY_LINE); + OncePerObject::warn($object, McpSdkAdapter::COMPATIBILITY_LINE); } } diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 4bf32b1..197fdb7 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -5,9 +5,11 @@ namespace AgentCat\Tests\Integration\LaravelMcp; use AgentCat\Event; +use AgentCat\Identity; use AgentCat\Internal\Constants; use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Tools\GetMoreTools; +use AgentCat\LaravelMcp\LaravelMcpAdapter; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; @@ -25,13 +27,17 @@ use AgentCat\Tests\Support\LaravelMcp\ToolCalls; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; +use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; +use Illuminate\Http\Request; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Transport; use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\ServerContext; +use Laravel\Mcp\Server\Transport\HttpTransport; use Laravel\Mcp\Transport\JsonRpcRequest; use Laravel\Mcp\Transport\JsonRpcResponse; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; final class CallToolTest extends LaravelTestCase @@ -209,6 +215,62 @@ public function testHeadersComeFromTheServersOwnRequest(): void $this->assertSame('http://localhost/mcp/conformance', JsonAssert::at($info, 'url')); } + /** Task 5 review minor 2: the client controls the `Host` header; Symfony refuses an invalid one. */ + public function testAnInvalidHostHeaderCostsTheHttpContextOnly(): void + { + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + + $answer = (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + + $this->assertEquals($twin, $answer); + $extra = JsonAssert::at($fixture->event()->parameters, 'extra'); + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertObjectNotHasProperty('requestInfo', $extra); + $this->assertNotContains(LaravelMcpAdapter::COMPATIBILITY_LINE, $fixture->lines()); + } + + /** @return iterable}> */ + public static function callsWithoutAToolName(): iterable + { + yield 'no name' => [['arguments' => ['msg' => 'hi']]]; + yield 'a number' => [['name' => 7, 'arguments' => ['msg' => 'hi']]]; + } + + /** + * TypeScript and mcp/sdk reject such a call before any handler, so none is recorded (D15, Task 5 review minor 4). + * + * @param array $params + */ + #[DataProvider('callsWithoutAToolName')] + public function testACallWithoutAToolNameIsLaravelsAnswerAndNoEvent(array $params): void + { + $fixture = new LaravelTrackingFixture(); + $params['_meta'] = LaravelHarness::meta([], null); + $twin = (new LaravelHarness(ConformanceServer::class))->request('tools/call', $params); + + $answer = (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->request('tools/call', $params); + + $this->assertEquals($twin, $answer); + $this->assertObjectHasProperty('error', $answer); + $this->assertSame([], $fixture->events()); + $this->assertSame([], $fixture->lines()); + } + + public function testACustomHandlersAnswerToACallWithoutAToolNameIsNoEvent(): void + { + $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::result($request->id, ['content' => [['type' => 'text', 'text' => 'nameless']]])); + $params = ['arguments' => ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], null)]; + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(RecordingCallServer::class))->request('tools/call', $params); + + $answer = (new LaravelHarness(RecordingCallServer::class, $fixture->tracker()))->request('tools/call', $params); + + $this->assertEquals($twin, $answer); + $this->assertSame('nameless', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([], $fixture->events()); + } + #[Group('ac-02-44')] public function testALegacyStdioClientIsKnownFromItsInitialize(): void { @@ -331,6 +393,58 @@ public function testAStreamedAnswerIsRecordedWhenItEnds(): void $this->assertSame([[]], ToolCalls::of('stream')); } + /** + * Over HTTP laravel/mcp sends a stream from `StreamedResponse::sendContent()`, after `Server::handle()` returned + * (Review Focus 5): the SSE bytes are the library's, the event comes from the final result, and the hooks run later + * (D04). + */ + #[Group('ac-02-43')] + #[Group('ac-05-E5')] + #[MaximumDuration(2000)] + public function testAStreamedAnswerOverHttpIsTheLibrarysAndRecordedWhenItEnds(): void + { + $hooks = new \ArrayObject(); + $fixture = new LaravelTrackingFixture(); + $twin = $this->httpStream(null, 'stream'); + + $tracked = $this->httpStream($fixture->tracker(new Options(identify: static function (ToolCall $call) use ($hooks): Identity { + $hooks->append($call->headers['x-one'] ?? null); + + return new Identity('u1'); + })), 'stream'); + + $this->assertSame([], $hooks->getArrayCopy(), 'no hook ran during the request'); + $this->assertSame($twin, $tracked); + $this->assertSame(3, substr_count($tracked, 'data: ')); + $event = $fixture->event(); + $this->assertSame(['a'], $hooks->getArrayCopy()); + $this->assertGreaterThanOrEqual(100, $event->duration); // the stream sleeps 2 × 50 ms + $this->assertSame('u1', $event->identifyActorGivenId); + $this->assertSame('a', JsonAssert::at($event->parameters, 'extra', 'requestInfo', 'headers', 'x-one')); + $failing = new LaravelTrackingFixture(); + $this->assertSame($this->httpStream(null, 'stream-fails'), $this->httpStream($failing->tracker(), 'stream-fails')); + $this->assertSame('DomainException', JsonAssert::at($failing->event()->error, 'type')); + } + + /** A stream the client abandons publishes nothing (plan E1 header conflict 6). */ + public function testAStreamTheClientAbandonsPublishesNothing(): void + { + $fixture = new LaravelTrackingFixture(); + $server = $this->app?->make(StreamingServer::class, ['transport' => new LineTransport()]); + $this->assertInstanceOf(StreamingServer::class, $server); + $fixture->track($server); + $stream = $server->answer(new JsonRpcRequest(3, 'tools/call', ['name' => 'stream', 'arguments' => ['session_id' => 'start']])); + $this->assertInstanceOf(\Generator::class, $stream); + $this->assertInstanceOf(JsonRpcResponse::class, $stream->current()); // the first progress notification + + unset($stream); + gc_collect_cycles(); + + $this->assertSame([], $fixture->events()); + $this->assertSame([[]], ToolCalls::of('stream')); + $this->assertFalse($this->app?->bound('mcp.request')); + } + #[Group('ac-05-E5')] public function testAStreamThatThrowsIsOneErrorEvent(): void { @@ -436,6 +550,39 @@ public function testACallStartsTheDeliveryOfAWaitingEvent(): void $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $fixture->queue->stats()); } + /** + * The SSE bytes of one streamed `tools/call` over laravel/mcp's HTTP transport, sent the way Laravel sends a + * `StreamedResponse`, with a supplied session (no mint-back). + * + * @param (\Closure(Server): void)|null $track + */ + private function httpStream(?\Closure $track, string $tool): string + { + $body = json_encode(['jsonrpc' => '2.0', 'id' => 7, 'method' => 'tools/call', 'params' => ['name' => $tool, 'arguments' => ['session_id' => self::SESSION], '_meta' => LaravelHarness::meta([], ['name' => 'c', 'version' => '1'])]], JSON_THROW_ON_ERROR); + $transport = new HttpTransport(Request::create('/mcp/s', 'POST', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'localhost', 'HTTP_X_ONE' => 'a'], content: $body)); + $server = $this->app?->make(StreamingServer::class, ['transport' => $transport]); + $this->assertInstanceOf(StreamingServer::class, $server); + if ($track instanceof \Closure) { + $track($server); + } + $server->start(); + $response = $transport->run(); + $this->assertSame('text/event-stream', $response->headers->get('Content-Type')); // a StreamedResponse + $sent = new \ArrayObject(); + ob_start(static function (string $chunk) use ($sent): string { + $sent->append($chunk); + + return ''; + }, 1); + try { + $response->sendContent(); + } finally { + ob_end_clean(); + } + + return implode('', $sent->getArrayCopy()); + } + private function text(mixed $value): string { $this->assertIsString($value); diff --git a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php index 308ae19..d304e81 100644 --- a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -209,8 +209,11 @@ public function testTheServerInfoHoldsTheServerWeakly(): void $this->assertSame([null, null], $info()); } - /** Task 4 review M3: the events of such a server have no name and version, and one line says so. */ - public function testAServerInfoThatCannotBeReadIsUnknownAndLoggedOncePerServer(): void + /** + * `createContext()` is public customer code, not a protected-member read (Task 5 review minor 3): what it throws + * leaves the name and version unknown and logs nothing; a deprecation it triggers costs nothing. + */ + public function testAServerInfoThatCannotBeReadIsUnknownWithoutALine(): void { $logger = new MemoryLogger(); Log::setLogger($logger); @@ -221,24 +224,19 @@ public function createContext(): ServerContext throw new \RuntimeException('no context'); } }; - $warning = new class (new LineTransport()) extends Server { + $deprecated = new class (new LineTransport()) extends Server { #[\Override] public function createContext(): ServerContext { - trigger_error('deprecated context', E_USER_DEPRECATED); + @trigger_error('deprecated context', E_USER_DEPRECATED); return parent::createContext(); } }; - $info = LaravelMcpAdapter::serverInfo($server); - $this->assertSame([null, null], $info()); - $this->assertSame([null, null], $info()); - $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($warning))(), 'a warning counts as a failure'); - $this->assertSame([ - ['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE], - ['level' => LogLevel::WARNING, 'message' => LaravelMcpAdapter::COMPATIBILITY_LINE], - ], $logger->records); + $this->assertSame([null, null], (LaravelMcpAdapter::serverInfo($server))()); + $this->assertSame(['Laravel MCP Server', '0.0.1'], (LaravelMcpAdapter::serverInfo($deprecated))(), 'a deprecation is no failure'); + $this->assertSame([], $logger->records); } #[Group('ac-02-43')] @@ -256,6 +254,20 @@ public function testTheHttpContextIsTheRequestOfTheServersOwnTransport(): void $this->assertSame('example.test', $http->headers['host'] ?? null); } + /** + * Symfony refuses an invalid `Host` header, which the client controls: no HTTP context, and no compatibility line + * (Task 5 review minor 2). + */ + public function testARequestLaravelCannotReadHasNoHttpContextAndLogsNothing(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $server = $this->conformanceServer(new HttpTransport(Request::create('/mcp', 'POST', server: ['HTTP_HOST' => 'bad host!']))); + + $this->assertNull((LaravelMcpAdapter::httpContext($server))()); + $this->assertSame([], $logger->records); + } + public function testAServerOnAnotherTransportOrGoneHasNoHttpContext(): void { $logger = new MemoryLogger(); diff --git a/tests/Support/LaravelMcp/StreamingServer.php b/tests/Support/LaravelMcp/StreamingServer.php index ca0049c..5831fa8 100644 --- a/tests/Support/LaravelMcp/StreamingServer.php +++ b/tests/Support/LaravelMcp/StreamingServer.php @@ -10,6 +10,8 @@ use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Version; use Laravel\Mcp\Server\Contracts\Transport; +use Laravel\Mcp\Transport\JsonRpcRequest; +use Laravel\Mcp\Transport\JsonRpcResponse; /** `streaming-server` with two streamed tools: `stream` and `stream-fails`. */ #[Name('streaming-server')] @@ -21,4 +23,15 @@ public function __construct(Transport $transport) parent::__construct($transport); $this->tools = [StreamingTool::class, FailingStreamTool::class]; } + + /** + * The answer to one request, as `handleMessage()` gets it, without sending it: a test drives a stream itself, like + * a client that goes away midway. + * + * @return iterable|JsonRpcResponse + */ + public function answer(JsonRpcRequest $request): iterable|JsonRpcResponse + { + return $this->runMethodHandle($request, $this->createContext()); + } } diff --git a/tests/Unit/Internal/Logging/OncePerObjectTest.php b/tests/Unit/Internal/Logging/OncePerObjectTest.php new file mode 100644 index 0000000..dd58328 --- /dev/null +++ b/tests/Unit/Internal/Logging/OncePerObjectTest.php @@ -0,0 +1,52 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testALineIsLoggedAsAWarningOncePerObject(): void + { + $server = new \stdClass(); + + OncePerObject::warn($server, 'drift'); + OncePerObject::warn($server, 'drift'); + + $this->assertSame([['level' => LogLevel::WARNING, 'message' => 'drift']], $this->logger->records); + } + + public function testEachObjectAndEachLineCountsOnItsOwn(): void + { + $one = new \stdClass(); + $two = new \stdClass(); + + OncePerObject::warn($one, 'drift'); + OncePerObject::warn($two, 'drift'); + OncePerObject::warn($one, 'other'); + OncePerObject::warn($two, 'drift'); + + $this->assertSame(['drift', 'drift', 'other'], $this->logger->messages()); + } +} From 61ed20c5182e836a8412f7d4aa50e4f0ff41a2bb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 19:53:01 +0000 Subject: [PATCH 09/30] test: run one adapter conformance suite on mcp/sdk and laravel/mcp --- .../Conformance/AdapterConformance.php | 322 ++++++++++++++++++ .../Conformance/ConformanceHarness.php | 48 +++ .../Conformance/LaravelMcpConformanceTest.php | 166 +++++++++ .../Conformance/McpSdkConformanceTest.php | 156 +++++++++ tests/Support/LaravelMcp/FullPageServer.php | 25 ++ .../LaravelMcp/LaravelConformanceHarness.php | 139 ++++++++ .../LaravelMcp/LaravelStdioHarness.php | 12 +- tests/Support/LaravelMcp/Tools/EchoTool.php | 7 +- .../McpSdk/McpSdkConformanceHarness.php | 171 ++++++++++ tests/Support/McpSdk/Tools.php | 9 + tests/Support/ReusedId.php | 52 +++ 11 files changed, 1100 insertions(+), 7 deletions(-) create mode 100644 tests/Integration/Conformance/AdapterConformance.php create mode 100644 tests/Integration/Conformance/ConformanceHarness.php create mode 100644 tests/Integration/Conformance/LaravelMcpConformanceTest.php create mode 100644 tests/Integration/Conformance/McpSdkConformanceTest.php create mode 100644 tests/Support/LaravelMcp/FullPageServer.php create mode 100644 tests/Support/LaravelMcp/LaravelConformanceHarness.php create mode 100644 tests/Support/McpSdk/McpSdkConformanceHarness.php create mode 100644 tests/Support/ReusedId.php diff --git a/tests/Integration/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php new file mode 100644 index 0000000..735503c --- /dev/null +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -0,0 +1,322 @@ +listedTools($this->conformance()->listTools()); + + $this->assertSame('echo', JsonAssert::at($tools, 0, 'name')); + $this->assertSame(['msg', 'session_id', 'context'], $this->memberNames(JsonAssert::at($tools, 0, 'inputSchema', 'properties'))); + $this->assertSame(['msg', 'session_id', 'context'], JsonAssert::at($tools, 0, 'inputSchema', 'required')); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + #[Group('ac-02-15')] + public function testConformanceListingPublishesNothing(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $this->assertSame([], $harness->events()); + } + + #[Group('ac-02-16')] + #[Group('ac-02-47')] + public function testConformanceCall(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([['msg' => 'hi']], $harness->received('echo')); + $event = $this->onlyEvent($harness); + $this->assertSame(['echo', 'why', self::SESSION], [$event->resourceName, $event->userIntent, $event->sessionId]); + $this->assertSame('supplied', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame(['conformance-server', '2.0.0'], [$event->serverName, $event->serverVersion]); + $this->assertEquals((object) ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION], JsonAssert::at($event->parameters, 'request', 'params', 'arguments')); + } + + #[Group('ac-02-19')] + public function testConformanceOwnSessionParameter(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'theirs']); + + $event = $this->onlyEvent($harness); + $this->assertSame([['session_id' => 'theirs']], $harness->received('own')); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + + #[Group('ac-02-20')] + public function testConformanceFreshInstance(): void + { + $harness = $this->conformance(fresh: true); + + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start']); + + $this->assertSame([['msg' => 'hi']], $harness->received('echo')); + $this->assertContains('Rebuilt injection registries on demand (tools/call before tools/list on this instance)', $harness->lines()); + } + + #[Group('ac-02-23')] + public function testConformanceErrorResult(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('refuses', ['session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $this->assertTrue($event->isError); + $this->assertSame('not allowed', JsonAssert::at($event->error, 'message')); + } + + #[Group('ac-02-24')] + public function testConformanceThrownException(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('boom', ['session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $this->assertTrue($event->isError); + $this->assertSame(['RuntimeException', 'tool boom'], [JsonAssert::at($event->error, 'type'), JsonAssert::at($event->error, 'message')]); + } + + #[Group('ac-02-26')] + public function testConformanceUnknownTool(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('nope', ['session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $this->assertSame(['nope', true], [$event->resourceName, $event->isError]); + } + + #[Group('ac-02-30')] + public function testConformanceMintBack(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why']); + + $event = $this->onlyEvent($harness); + $this->assertSame(Constants::MINT_BACK_HEADER_ISSUED . "\nsession_id: " . $event->sessionId . "\n" . Constants::MINT_BACK_ISSUED_BODY, JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame('minted', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + + #[Group('ac-02-36')] + public function testConformanceStructuredMirror(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $answer = $harness->callTool('structured', ['session_id' => 'start', 'context' => 'why']); + + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->memberNames(JsonAssert::at($answer, 'result', 'structuredContent'))); + $event = $this->onlyEvent($harness); + $this->assertEquals((object) ['echoed' => 'x'], JsonAssert::at($event->response, 'structuredContent')); + } + + #[Group('ac-02-40')] + public function testConformanceGetMoreTools(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $answer = $harness->callTool(GetMoreTools::NAME, ['context' => 'need X', 'session_id' => self::SESSION]); + + $content = JsonAssert::at($answer, 'result', 'content'); + $this->assertIsList($content); + $this->assertSame('Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.', JsonAssert::at(end($content), 'text')); + $this->assertContains('Missing tool reported (context length: 6)', $harness->lines()); + } + + #[Group('ac-02-44')] + #[Group('ac-02-45')] + public function testConformanceClientAndProtocolFromMeta(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], [Constants::META_CLIENT_INFO_KEY => ['name' => 'meta-client', 'version' => '3']]); + + $event = $this->onlyEvent($harness); + $this->assertSame(['meta-client', '3'], [$event->clientName, $event->clientVersion]); + $this->assertSame('2026-07-28', $event->tags[Constants::TAG_PROTOCOL_VERSION] ?? null); + } + + /** A call without `_meta` names the client of the instance's handshake, whose protocol version is no tag. */ + #[Group('ac-02-44')] + #[Group('ac-02-45')] + public function testConformanceClientFromTheHandshake(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $this->assertSame(['harness-client', '1.0.0'], [$event->clientName, $event->clientVersion]); + $this->assertArrayNotHasKey(Constants::TAG_PROTOCOL_VERSION, $event->tags ?? []); + } + + #[Group('ac-02-52')] + public function testConformanceTracingOff(): void + { + $harness = $this->conformance(new Options(enableTracing: false)); + $tools = $this->listedTools($harness->listTools()); + + $harness->callTool('echo', ['msg' => 'hi']); + + $this->assertNotContains('session_id', $this->memberNames(JsonAssert::at($tools, 0, 'inputSchema', 'properties'))); + $this->assertSame([], $harness->events()); + } + + /** Plan E1 header conflict 3: a reused object ID belongs to a dead server, so the new server's state replaces it. */ + #[Group('ac-02-5')] + public function testConformanceReusedObjectId(): void + { + $harness = $this->reusedObjectId(); + $tools = $this->listedTools($harness->listTools()); + + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame(['msg', 'session_id', 'context'], $this->memberNames(JsonAssert::at($tools, 0, 'inputSchema', 'properties'))); + $this->assertSame([['msg' => 'hi']], $harness->received('echo')); + $event = $this->onlyEvent($harness); + $this->assertSame(['echo', 'why', self::SESSION], [$event->resourceName, $event->userIntent, $event->sessionId]); + $this->assertSame(['conformance-server', '2.0.0'], [$event->serverName, $event->serverVersion]); + $this->assertNotContains(self::ALREADY_TRACKED, $harness->lines()); + } + + /** Plan E1 header conflict 1: a full page has no `nextCursor`, and `get_more_tools` comes after its 15 tools. */ + #[Group('ac-02-8')] + public function testConformanceOneFullPage(): void + { + $listing = $this->fullPage()->listTools(); + + $tools = $this->listedTools($listing); + $this->assertObjectNotHasProperty('nextCursor', $listing); + $named = array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14)); + $this->assertSame(['echo', ...$named, GetMoreTools::NAME], array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools)); + $this->assertSame(array_fill(0, 14, ['session_id', 'context']), array_map(fn(mixed $tool): array => $this->memberNames(JsonAssert::at($tool, 'inputSchema', 'properties')), array_slice($tools, 1, 14))); + } + + /** The library validates the tool's own arguments, after the strip (ac-02-17); the event records the failure. */ + #[Group('ac-05-E6')] + #[Group('ac-02-17')] + public function testConformanceValidationError(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('echo', ['context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame([], $harness->received('echo')); + $event = $this->onlyEvent($harness); + $this->assertSame(['echo', true, 'why', self::SESSION], [$event->resourceName, $event->isError, $event->userIntent, $event->sessionId]); + $message = JsonAssert::at($event->error, 'message'); + $this->assertIsString($message); + $this->assertStringContainsString('msg', $message); + } + + /** Each call of a long-lived server sees and records only its own arguments. */ + #[Group('ac-02-51')] + public function testConformanceConsecutiveCalls(): void + { + $harness = $this->conformance(); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'one', 'context' => 'first', 'session_id' => self::SESSION]); + $answer = $harness->callTool('echo', ['msg' => 'two', 'session_id' => self::SESSION]); + + $this->assertSame('echo: two', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([['msg' => 'one'], ['msg' => 'two']], $harness->received('echo')); + $events = $harness->events(); + $this->assertCount(2, $events); + [$first, $second] = $events; + $this->assertSame(['first', null], [$first->userIntent, $second->userIntent]); + $this->assertEquals((object) ['msg' => 'one', 'context' => 'first', 'session_id' => self::SESSION], JsonAssert::at($first->parameters, 'request', 'params', 'arguments')); + $this->assertEquals((object) ['msg' => 'two', 'session_id' => self::SESSION], JsonAssert::at($second->parameters, 'request', 'params', 'arguments')); + } + + /** The one event the harness delivered; fails unless it delivered exactly one. */ + private function onlyEvent(ConformanceHarness $harness): Event + { + $events = $harness->events(); + $this->assertCount(1, $events); + [$event] = $events; + + return $event; + } + + /** @return list the listing's `tools` */ + private function listedTools(\stdClass $listing): array + { + $tools = JsonAssert::at($listing, 'tools'); + $this->assertIsList($tools); + + return $tools; + } + + /** @return list the member names of a JSON object, in order */ + private function memberNames(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } +} diff --git a/tests/Integration/Conformance/ConformanceHarness.php b/tests/Integration/Conformance/ConformanceHarness.php new file mode 100644 index 0000000..d982cb8 --- /dev/null +++ b/tests/Integration/Conformance/ConformanceHarness.php @@ -0,0 +1,48 @@ + $arguments + * @param array $meta + */ + public function callTool(string $name, array $arguments, array $meta = []): \stdClass; + + /** + * Delivers everything queued, then returns every event delivered, in order. + * + * @return list + */ + public function events(): array; + + /** + * The arguments that each call of the tool received, in order. + * + * @return list> + */ + public function received(string $tool): array; + + /** + * The SDK log lines, in order. + * + * @return list + */ + public function lines(): array; +} diff --git a/tests/Integration/Conformance/LaravelMcpConformanceTest.php b/tests/Integration/Conformance/LaravelMcpConformanceTest.php new file mode 100644 index 0000000..8bd319d --- /dev/null +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -0,0 +1,166 @@ + true]); + $twin = LaravelConformanceHarness::untracked(); + $twin->listTools(); + $harness = LaravelConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertEquals($twin->callTool('boom', [])->result ?? null, $answer->result ?? null); + $this->assertSame('tool boom', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $event = $this->onlyEvent($harness); + $this->assertTrue($event->isError); + $this->assertSame(['RuntimeException', 'tool boom'], [JsonAssert::at($event->error, 'type'), JsonAssert::at($event->error, 'message')]); + $this->assertSame('LogicException', JsonAssert::at($event->error, 'chained_errors', 0, 'type')); + } + + /** + * Library difference: a laravel/mcp tool validates its own arguments (`$request->validate()`), and the library + * answers the `ValidationException` with an error result; the event keeps that exception (plan E1 header + * conflict 5). + */ + #[Group('ac-05-E6')] + public function testAValidationErrorIsTheToolsExceptionAndAnErrorResult(): void + { + $twin = LaravelConformanceHarness::untracked(); + $twin->listTools(); + $harness = LaravelConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['context' => 'why', 'session_id' => self::SESSION]); + + $this->assertEquals($twin->callTool('echo', [])->result ?? null, $answer->result ?? null); + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $this->assertSame('The msg field is required.', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $event = $this->onlyEvent($harness); + $this->assertSame([ValidationException::class, 'The msg field is required.'], [JsonAssert::at($event->error, 'type'), JsonAssert::at($event->error, 'message')]); + } + + /** laravel/mcp binds each call's `mcp.request` in the container: no call sees the arguments of the one before. */ + #[Group('ac-02-51')] + #[Group('ac-05-E5')] + public function testConsecutiveCallsSeeOnlyTheirOwnArgumentsThroughMcpRequest(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(RequestEchoServer::class, $fixture->tracker()); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'request-echo', 'arguments' => ['msg' => 'one', 'context' => 'first', 'session_id' => self::SESSION], '_meta' => LaravelHarness::meta([], null)]]); + $stdio->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'request-echo', 'arguments' => ['msg' => 'two'], '_meta' => LaravelHarness::meta([], null)]]); + + $this->assertSame([ + ['request' => ['msg' => 'one'], 'container' => ['msg' => 'one']], + ['request' => ['msg' => 'two'], 'container' => ['msg' => 'two']], + ], ToolCalls::of('request-echo')); + $this->assertFalse($this->app?->bound('mcp.request'), 'laravel/mcp forgets each call\'s request'); + $this->assertCount(2, $fixture->events()); + } + + /** + * Library difference: laravel/mcp answers an uncaught tool exception with an error result, which the event keeps as + * its response next to the exception (plan E1 header conflict 5); mcp/sdk answers with a JSON-RPC error. + */ + #[Group('ac-02-24')] + public function testAnUncaughtToolExceptionIsAnErrorResultThatTheEventKeeps(): void + { + $twin = LaravelConformanceHarness::untracked(); + $twin->listTools(); + $harness = LaravelConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertEquals($twin->callTool('boom', [])->result ?? null, $answer->result ?? null); + $event = $this->onlyEvent($harness); + $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); + $this->assertEquals((object) ['content' => [(object) ['type' => 'text', 'text' => 'An internal server error occurred.']], 'isError' => true], $event->response); + } + + /** + * Library difference: laravel/mcp throws its `JsonRpcException` for an unknown tool through AgentCat's handler, so + * the event keeps that exception; mcp/sdk answers with an error and throws nothing that AgentCat sees. + */ + #[Group('ac-02-26')] + #[Group('ac-05-E6')] + public function testAnUnknownToolIsTheLibrarysJsonRpcExceptionWhichTheEventKeeps(): void + { + $harness = LaravelConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('nope', ['session_id' => self::SESSION]); + + $this->assertEquals((object) ['code' => -32602, 'message' => 'Tool [nope] not found.'], $answer->error ?? null); + $event = $this->onlyEvent($harness); + $this->assertSame([JsonRpcException::class, 'Tool [nope] not found.'], [JsonAssert::at($event->error, 'type'), JsonAssert::at($event->error, 'message')]); + } + + /** + * Library difference: laravel/mcp 1.x has no sessions, so `extra` has no transport session (plan E1 header conflict + * 8); over HTTP the event has the server's own request (header conflict 9), whatever the SAPI. + */ + #[Group('ac-02-43')] + #[Group('ac-02-46')] + public function testExtraHasNoTransportSessionAndTheServersOwnHttpRequest(): void + { + $harness = LaravelConformanceHarness::of(); + $harness->listTools(); + $fresh = LaravelConformanceHarness::of(fresh: true); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $fresh->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $this->assertSame(['requestId'], array_keys((array) JsonAssert::at($event->parameters, 'extra'))); + $http = $this->onlyEvent($fresh); + $extra = JsonAssert::at($http->parameters, 'extra'); + $this->assertSame(['requestId', 'requestInfo'], array_keys((array) $extra)); + $this->assertSame('http://localhost/mcp/conformance', JsonAssert::at($extra, 'requestInfo', 'url')); + } + + #[\Override] + protected function conformance(Options $options = new Options(), bool $fresh = false): ConformanceHarness + { + return LaravelConformanceHarness::of($options, $fresh); + } + + #[\Override] + protected function reusedObjectId(): ConformanceHarness + { + return LaravelConformanceHarness::reusedObjectId(); + } + + #[\Override] + protected function fullPage(): ConformanceHarness + { + return LaravelConformanceHarness::fullPage(); + } +} diff --git a/tests/Integration/Conformance/McpSdkConformanceTest.php b/tests/Integration/Conformance/McpSdkConformanceTest.php new file mode 100644 index 0000000..0df9ed9 --- /dev/null +++ b/tests/Integration/Conformance/McpSdkConformanceTest.php @@ -0,0 +1,156 @@ +listTools(); + $harness = McpSdkConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['context' => 'why', 'session_id' => self::SESSION]); + + $this->assertEquals($twin->callTool('echo', [])->error ?? null, $answer->error ?? null); + $this->assertSame(Error::INVALID_PARAMS, JsonAssert::at($answer, 'error', 'code')); + $message = JsonAssert::at($answer, 'error', 'message'); + $this->assertIsString($message); + $this->assertStringStartsWith("Invalid parameters for tool 'echo'", $message); + $event = $this->onlyEvent($harness); + $this->assertSame($message, JsonAssert::at($event->error, 'message')); + $error = $event->error; + $this->assertInstanceOf(\stdClass::class, $error); + $this->assertObjectNotHasProperty('type', $error, 'nothing was thrown'); + } + + /** + * Library difference: mcp/sdk answers an uncaught tool exception with a JSON-RPC error, so the event has the + * exception and no response; laravel/mcp answers with an error result. + */ + #[Group('ac-02-25')] + public function testAnUncaughtToolExceptionIsTheLibrarysJsonRpcErrorAndAnEventWithoutResponse(): void + { + $twin = McpSdkConformanceHarness::untracked(); + $twin->listTools(); + $harness = McpSdkConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertEquals($twin->callTool('boom', [])->error ?? null, $answer->error ?? null); + $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $answer->error ?? null); + $event = $this->onlyEvent($harness); + $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); + $this->assertNull($event->response); + } + + /** + * Library difference: mcp/sdk answers an unknown tool with its own error message and throws nothing that AgentCat + * sees, so the event has the message only; laravel/mcp throws its `JsonRpcException` through AgentCat's handler. + */ + #[Group('ac-02-26')] + #[Group('ac-05-E6')] + public function testAnUnknownToolIsTheLibrarysErrorAndAnEventWithItsMessageOnly(): void + { + $harness = McpSdkConformanceHarness::of(); + $harness->listTools(); + + $answer = $harness->callTool('nope', ['session_id' => self::SESSION]); + + $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'Tool not found: "nope".'], $answer->error ?? null); + $event = $this->onlyEvent($harness); + $this->assertEquals((object) ['message' => 'Tool not found: "nope".', 'platform' => 'php'], $event->error); + } + + /** + * Library difference: a handshake-era call has mcp/sdk's transport session in `extra`, never as the event's session + * (ac-02-46); laravel/mcp 1.x has no sessions (plan E1 header conflict 8). The adapter reads the HTTP request from + * the SAPI (`$_SERVER`, plan C1 conflict 8), which a request in this process does not set: no `requestInfo`. + */ + #[Group('ac-02-46')] + public function testExtraHasTheTransportSessionOfTheHandshakeAndNoRequestInfoInProcess(): void + { + $harness = McpSdkConformanceHarness::of(); + $harness->listTools(); + $fresh = McpSdkConformanceHarness::of(fresh: true); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $fresh->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $extra = JsonAssert::at($event->parameters, 'extra'); + $this->assertSame(['sessionId', 'requestId'], array_keys((array) $extra)); + $this->assertIsString(JsonAssert::at($extra, 'sessionId')); + $this->assertNotSame(self::SESSION, JsonAssert::at($extra, 'sessionId')); + $modern = $this->onlyEvent($fresh); + $this->assertSame(['requestId', '_meta'], array_keys((array) JsonAssert::at($modern->parameters, 'extra'))); + } + + /** mcp/sdk rejects a `tools/call` without a name before any handler: nothing is recorded, as on laravel/mcp (D15). */ + public function testACallWithoutAToolNameIsTheLibrarysErrorAndNoEvent(): void + { + $fixture = new TrackingFixture(); + $untracked = TrackingFixture::builder(); + Tools::echo($untracked); + $twin = $fixture->harness($untracked)->request('tools/call', ['arguments' => ['msg' => 'hi']]); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $fixture->track($builder); + + $answer = $fixture->harness($builder)->request('tools/call', ['arguments' => ['msg' => 'hi']]); + + $this->assertObjectHasProperty('error', $answer); + $this->assertEquals($twin, $answer); + $this->assertSame([], $fixture->events()); + $this->assertSame([], $fixture->lines()); + } + + #[\Override] + protected function conformance(Options $options = new Options(), bool $fresh = false): ConformanceHarness + { + return McpSdkConformanceHarness::of($options, $fresh); + } + + #[\Override] + protected function reusedObjectId(): ConformanceHarness + { + return McpSdkConformanceHarness::reusedObjectId(); + } + + #[\Override] + protected function fullPage(): ConformanceHarness + { + return McpSdkConformanceHarness::fullPage(); + } +} diff --git a/tests/Support/LaravelMcp/FullPageServer.php b/tests/Support/LaravelMcp/FullPageServer.php new file mode 100644 index 0000000..4360eac --- /dev/null +++ b/tests/Support/LaravelMcp/FullPageServer.php @@ -0,0 +1,25 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 14))]; + } +} diff --git a/tests/Support/LaravelMcp/LaravelConformanceHarness.php b/tests/Support/LaravelMcp/LaravelConformanceHarness.php new file mode 100644 index 0000000..e0644ce --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelConformanceHarness.php @@ -0,0 +1,139 @@ +request('initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'harness-client', 'version' => '1.0.0']]); + $server->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + } + } + + /** The conformance server, tracked with `$options`; a new instance per request when `$fresh`. */ + public static function of(Options $options = new Options(), bool $fresh = false): self + { + $fixture = new LaravelTrackingFixture(); + $track = $fixture->tracker($options); + + return new self($fixture, $fresh ? new LaravelHarness(ConformanceServer::class, $track) : new LaravelStdioHarness(ConformanceServer::class, $track)); + } + + /** The conformance server without AgentCat: the library's own answers. */ + public static function untracked(): self + { + return new self(new LaravelTrackingFixture(), new LaravelStdioHarness(ConformanceServer::class)); + } + + /** `FullPageServer`: exactly one default page of tools. */ + public static function fullPage(): self + { + $fixture = new LaravelTrackingFixture(); + + return new self($fixture, new LaravelStdioHarness(FullPageServer::class, $fixture->tracker())); + } + + /** + * The conformance server, tracked by `Client::track()` after an earlier one, which the client had tracked with + * `enableTracing: false`, was freed. Asserts that the new server has the old one's object ID, so the same + * container keys, and that its binding replaced the old one's (plan E1 header conflict 3). + */ + public static function reusedObjectId(): self + { + $fixture = new LaravelTrackingFixture(); + $client = new Client(ClientSeams::of($fixture->queue, static fn(): bool => true, false)); + $track = static function (Server $server) use ($client): void { + $client->track($server, LaravelTrackingFixture::PROJECT); + }; + $old = new ConformanceServer(new LineTransport()); + $client->track($old, LaravelTrackingFixture::PROJECT, new Options(enableTracing: false)); + $key = MethodKey::for($old, 'tools/call'); + $binding = Container::getInstance()->make($key); + $id = spl_object_id($old); + unset($old); + $held = ReusedId::hold($id); + + $stdio = new LaravelStdioHarness(static fn(LineTransport $transport): Server => $held->reuse(static fn(): ConformanceServer => new ConformanceServer($transport)), $track); + + Assert::assertNotSame($binding, Container::getInstance()->make($key), 'the new server\'s binding replaced the old one\'s'); + + return new self($fixture, $stdio); + } + + #[\Override] + public function listTools(): \stdClass + { + $result = $this->request('tools/list', [])->result ?? null; + Assert::assertInstanceOf(\stdClass::class, $result); + + return $result; + } + + #[\Override] + public function callTool(string $name, array $arguments, array $meta = []): \stdClass + { + $params = ['name' => $name, 'arguments' => (object) $arguments]; + if ($meta !== []) { + $params['_meta'] = LaravelHarness::meta($meta, null); + } + + return $this->request('tools/call', $params); + } + + #[\Override] + public function events(): array + { + return $this->fixture->events(); + } + + #[\Override] + public function received(string $tool): array + { + return ToolCalls::of($tool); + } + + #[\Override] + public function lines(): array + { + return $this->fixture->lines(); + } + + /** + * One JSON-RPC request; the whole answer. + * + * @param array $params + */ + private function request(string $method, array $params): \stdClass + { + if ($this->server instanceof LaravelHarness) { + return $this->server->request($method, $params); + } + $answer = $this->server->send(['jsonrpc' => '2.0', 'id' => $this->nextId++, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params]); + Assert::assertInstanceOf(\stdClass::class, $answer, 'the server answered'); + + return $answer; + } +} diff --git a/tests/Support/LaravelMcp/LaravelStdioHarness.php b/tests/Support/LaravelMcp/LaravelStdioHarness.php index 9b938e3..b21d722 100644 --- a/tests/Support/LaravelMcp/LaravelStdioHarness.php +++ b/tests/Support/LaravelMcp/LaravelStdioHarness.php @@ -10,21 +10,23 @@ use PHPUnit\Framework\Assert; /** - * Drives one laravel/mcp server instance the way `artisan mcp:start` does: one server, built by the container, answers - * every message, one line each, on a `LineTransport`. + * Drives one laravel/mcp server instance the way `artisan mcp:start` does: one server, built by the container or by the + * test, answers every message, one line each, on a `LineTransport`. */ final readonly class LaravelStdioHarness { private LineTransport $transport; /** - * @param class-string $serverClass + * @param class-string|(\Closure(LineTransport): Server) $server the server class, which the container + * builds on the harness's transport, or a + * function that builds the server on it * @param (\Closure(Server): void)|null $track runs on the server before `start()` */ - public function __construct(string $serverClass, ?\Closure $track = null) + public function __construct(string|\Closure $server, ?\Closure $track = null) { $this->transport = new LineTransport(); - $server = Container::getInstance()->make($serverClass, ['transport' => $this->transport]); + $server = $server instanceof \Closure ? $server($this->transport) : Container::getInstance()->make($server, ['transport' => $this->transport]); Assert::assertInstanceOf(Server::class, $server); if ($track instanceof \Closure) { $track($server); diff --git a/tests/Support/LaravelMcp/Tools/EchoTool.php b/tests/Support/LaravelMcp/Tools/EchoTool.php index bef284a..d7ee05d 100644 --- a/tests/Support/LaravelMcp/Tools/EchoTool.php +++ b/tests/Support/LaravelMcp/Tools/EchoTool.php @@ -13,8 +13,10 @@ use Laravel\Mcp\Server\Tool; /** - * `echo`: `{msg: string}`, required; answers `echo: `. The attributes name the tools of these tests: overriding - * `$name` would need `#[\Override]` on a property, which PHP accepts only from 8.5 (see `EmptyServer`). + * `echo`: `{msg: string}`, required; answers `echo: `. Like mcp/sdk, which checks a tool's input schema, it + * validates its arguments before it records them: without `msg`, laravel/mcp answers the `ValidationException` with an + * error result. The attributes name the tools of these tests: overriding `$name` would need `#[\Override]` on a + * property, which PHP accepts only from 8.5 (see `EmptyServer`). */ #[Name('echo')] #[Description('Echo')] @@ -29,6 +31,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response { + $request->validate(['msg' => 'required|string']); ToolCalls::record('echo', $request->all()); return Response::text('echo: ' . (is_string($request->get('msg')) ? $request->get('msg') : '')); diff --git a/tests/Support/McpSdk/McpSdkConformanceHarness.php b/tests/Support/McpSdk/McpSdkConformanceHarness.php new file mode 100644 index 0000000..8b43d98 --- /dev/null +++ b/tests/Support/McpSdk/McpSdkConformanceHarness.php @@ -0,0 +1,171 @@ +> the tools of each Builder, by name, in the order of the builds */ + private array $tools = []; + + /** The one server; null when fresh. */ + private ?HttpHarness $server = null; + + /** @param Options|null $options the tracking options; null tracks nothing */ + private function __construct(private readonly ?Options $options) + { + $this->fixture = new TrackingFixture(); + } + + /** The conformance server, tracked with `$options`; a new instance per request when `$fresh`. */ + public static function of(Options $options = new Options(), bool $fresh = false): self + { + $harness = new self($options); + if (!$fresh) { + $harness->server = $harness->fixture->harness($harness->tracked($harness->conformanceTools(self::builder()))); + } + + return $harness; + } + + /** The conformance server without AgentCat: the library's own answers. */ + public static function untracked(): self + { + $harness = new self(null); + $harness->server = $harness->fixture->harness($harness->conformanceTools(self::builder())); + + return $harness; + } + + /** `echo`, then `t01` to `t14`, with a page size of 15: laravel/mcp's default page, one full page here too. */ + public static function fullPage(): self + { + $harness = new self(new Options()); + $builder = self::builder()->setPaginationLimit(15); + $tools = ['echo' => Tools::echo($builder)]; + foreach (range(1, 14) as $i) { + $tools[sprintf('t%02d', $i)] = Tools::named($builder, sprintf('t%02d', $i)); + } + $harness->tools[] = $tools; + $harness->server = $harness->fixture->harness($harness->tracked($builder)); + + return $harness; + } + + /** + * The conformance server, tracked by `Client::track()` after an earlier one, which the client had tracked with + * `enableTracing: false` and built, was freed. Asserts that the new Builder has the old one's object ID. + */ + public static function reusedObjectId(): self + { + $harness = new self(null); + $client = new Client(ClientSeams::of($harness->fixture->queue, static fn(): bool => true, false)); + $old = $harness->conformanceTools(self::builder()); + $client->track($old, TrackingFixture::PROJECT, new Options(enableTracing: false)); + $old->build(); + $id = spl_object_id($old); + $gone = \WeakReference::create($old); + $harness->tools = []; // received() reads the new server's tools only + unset($old); + gc_collect_cycles(); // AgentCat's handlers and the Builder hold each other + Assert::assertNull($gone->get(), 'the old Builder is freed'); + $held = ReusedId::hold($id); + + $builder = $held->reuse(self::builder(...)); + + $client->track($harness->conformanceTools($builder), TrackingFixture::PROJECT); + $harness->server = $harness->fixture->harness($builder); + + return $harness; + } + + #[\Override] + public function listTools(): \stdClass + { + return $this->server instanceof HttpHarness ? $this->server->listTools() : HttpHarness::result($this->fresh()->modern('tools/list', [])); + } + + #[\Override] + public function callTool(string $name, array $arguments, array $meta = []): \stdClass + { + $server = $this->server ?? $this->fresh(); + if ($meta !== []) { + // modern() adds the protocol version and the client capabilities; the client info is in $meta, if anywhere. + return $server->modern('tools/call', ['name' => $name, 'arguments' => (object) $arguments, '_meta' => $meta], $name, null); + } + + return $this->server instanceof HttpHarness ? $server->callTool($name, $arguments) : $server->callToolModern($name, $arguments); + } + + #[\Override] + public function events(): array + { + return $this->fixture->events(); + } + + #[\Override] + public function received(string $tool): array + { + return array_merge(...array_map(static fn(array $tools): array => isset($tools[$tool]) ? $tools[$tool]->calls : [], $this->tools)); + } + + #[\Override] + public function lines(): array + { + return $this->fixture->lines(); + } + + /** A Builder of `conformance-server` 2.0.0, without tools. */ + private static function builder(): Builder + { + return Server::builder()->setServerInfo('conformance-server', '2.0.0'); + } + + /** Adds the five conformance tools, which `received()` reads. */ + private function conformanceTools(Builder $builder): Builder + { + $this->tools[] = [ + 'echo' => Tools::echo($builder), + 'structured' => Tools::structured($builder), + 'refuses' => Tools::refuses($builder), + 'boom' => Tools::boom($builder), + 'own' => Tools::own($builder), + ]; + + return $builder; + } + + /** Tracks the Builder into the fixture with the harness's options, unless the harness tracks nothing. */ + private function tracked(Builder $builder): Builder + { + if ($this->options instanceof Options) { + $this->fixture->track($builder, $this->options); + } + + return $builder; + } + + /** A new server for one request: a new Builder, tracked, then built; no handshake. */ + private function fresh(): HttpHarness + { + return new HttpHarness($this->tracked($this->conformanceTools(self::builder()))->build()); + } +} diff --git a/tests/Support/McpSdk/Tools.php b/tests/Support/McpSdk/Tools.php index b757edd..c208f2a 100644 --- a/tests/Support/McpSdk/Tools.php +++ b/tests/Support/McpSdk/Tools.php @@ -102,6 +102,15 @@ public static function boom(Builder $builder): RecordingTool return $tool; } + /** A tool without input, named `$name`. Answers `ok`. */ + public static function named(Builder $builder, string $name): RecordingTool + { + $tool = new RecordingTool(); + $builder->add(new Tool(name: $name, title: null, inputSchema: self::NO_ARGUMENTS, description: $name, annotations: null), $tool); + + return $tool; + } + /** `own`: declares its own required `session_id`. Answers `ok`. */ public static function own(Builder $builder): RecordingTool { diff --git a/tests/Support/ReusedId.php b/tests/Support/ReusedId.php new file mode 100644 index 0000000..bab3ef8 --- /dev/null +++ b/tests/Support/ReusedId.php @@ -0,0 +1,52 @@ +holder ?? throw new \LogicException('The ID was reused already')); + $this->holder = null; + + $object = $make(); + + Assert::assertSame($id, spl_object_id($object), 'the new object has the ID of the freed one'); + + return $object; + } +} From 401577c08c7cb77f2d5c63d79af0439a0143e95d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 20:12:42 +0000 Subject: [PATCH 10/30] test: tag the conformance validation scenario only with what it proves --- tests/Integration/Conformance/AdapterConformance.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Integration/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php index 735503c..a7fb28f 100644 --- a/tests/Integration/Conformance/AdapterConformance.php +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -255,9 +255,8 @@ public function testConformanceOneFullPage(): void $this->assertSame(array_fill(0, 14, ['session_id', 'context']), array_map(fn(mixed $tool): array => $this->memberNames(JsonAssert::at($tool, 'inputSchema', 'properties')), array_slice($tools, 1, 14))); } - /** The library validates the tool's own arguments, after the strip (ac-02-17); the event records the failure. */ + /** The library's validation of the tool's own arguments fails: the tool does not run, and the event records it. */ #[Group('ac-05-E6')] - #[Group('ac-02-17')] public function testConformanceValidationError(): void { $harness = $this->conformance(); From 13a6d64fe39822d56e7d9d6a73e13cc73fb1afe6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 20:21:55 +0000 Subject: [PATCH 11/30] fix: record the laravel/mcp request URL as sent and leave out the headers Symfony makes up from the credentials --- src/Internal/Engine/HttpContext.php | 9 +++- .../Internal/LaravelHttpContext.php | 22 ++++++++-- src/LaravelMcp/LaravelMcpAdapter.php | 4 +- .../Conformance/AdapterConformance.php | 40 +++++++++++++++++ .../Conformance/LaravelMcpConformanceTest.php | 14 ++++++ .../Conformance/McpSdkConformanceTest.php | 18 ++++++++ tests/Integration/LaravelMcp/CallToolTest.php | 25 +++++++++-- .../LaravelMcp/LaravelMcpAdapterTest.php | 4 +- tests/Support/LaravelMcp/LaravelHarness.php | 30 ++++++++++++- .../LaravelMcp/LaravelHttpContextTest.php | 43 ++++++++++++++++++- 10 files changed, 192 insertions(+), 17 deletions(-) diff --git a/src/Internal/Engine/HttpContext.php b/src/Internal/Engine/HttpContext.php index cae53cf..ceb7b40 100644 --- a/src/Internal/Engine/HttpContext.php +++ b/src/Internal/Engine/HttpContext.php @@ -66,8 +66,13 @@ public function toRequestInfo(): \stdClass return $info; } - /** @param array $server */ - private static function url(array $server): ?string + /** + * The full URL as a web server passes it to PHP: the scheme `HTTPS` gives, the `HTTP_HOST` and the raw + * `REQUEST_URI`, query and trailing slash as sent. Null without a host (C15). + * + * @param array $server + */ + public static function url(array $server): ?string { $host = $server['HTTP_HOST'] ?? null; if (!is_string($host) || $host === '') { diff --git a/src/LaravelMcp/Internal/LaravelHttpContext.php b/src/LaravelMcp/Internal/LaravelHttpContext.php index 94daf80..5ea1a65 100644 --- a/src/LaravelMcp/Internal/LaravelHttpContext.php +++ b/src/LaravelMcp/Internal/LaravelHttpContext.php @@ -16,16 +16,30 @@ final class LaravelHttpContext { /** - * 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 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. */ public static function of(Request $request): HttpContext { $headers = []; foreach ($request->headers->all() as $name => $values) { - $headers[$name] = implode(', ', $values); // a null value is '' + 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(), $request->fullUrl(), $headers); + return new HttpContext($request->getMethod(), $url, $headers); } } diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php index 14d4d9f..efa9af9 100644 --- a/src/LaravelMcp/LaravelMcpAdapter.php +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -111,8 +111,8 @@ public static function serverInfo(Server $server): \Closure * conflict 9); null on stdio and once the server is gone. Holds the server weakly, like `serverInfo()`. Never * throws; the call is then recorded without HTTP context: * - when the transport or its request cannot be read (drift), and the compatibility line is logged once per server; - * - when Laravel cannot read the request (Symfony refuses an invalid `Host` header, which the client sends), and - * nothing is logged. + * - when Laravel cannot read the request (Symfony refuses an invalid `X-HTTP-Method-Override` header, which the + * client sends), and nothing is logged. * * @return \Closure(): ?HttpContext */ diff --git a/tests/Integration/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php index a7fb28f..d1e9e77 100644 --- a/tests/Integration/Conformance/AdapterConformance.php +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Tools\GetMoreTools; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -43,6 +44,28 @@ abstract protected function reusedObjectId(): ConformanceHarness; */ abstract protected function fullPage(): ConformanceHarness; + /** + * The `parameters.extra.requestInfo` that the leg records for one `tools/call` of `echo` (a supplied session) that + * a web server hands to PHP with these server variables. The mcp/sdk adapter reads the SAPI's variables, so its + * leg reads them as `fpm-fcgi` would give them; a leg whose library takes a request object builds it from them. + * + * @param array $server + */ + abstract protected function requestInfoOf(array $server): \stdClass; + + /** + * Two authenticated requests to `/mcp/?b=2&a=1`: Basic as PHP-FPM passes it (with `PHP_AUTH_USER` and + * `PHP_AUTH_PW`), and Digest as php-cgi behind a rewrite passes it (without `PHP_AUTH_DIGEST`). + * + * @return iterable}> + */ + public static function authenticatedRequests(): iterable + { + $request = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/mcp/?b=2&a=1', 'QUERY_STRING' => 'b=2&a=1', 'SCRIPT_NAME' => '/index.php', 'SERVER_NAME' => 'example.test', 'SERVER_PORT' => '80', 'HTTP_HOST' => 'example.test']; + yield 'Basic' => [$request + ['HTTP_AUTHORIZATION' => 'Basic dTpzZWNyZXQ=', 'CONTENT_TYPE' => 'application/json', 'PHP_AUTH_USER' => 'u', 'PHP_AUTH_PW' => 'secret']]; + yield 'Digest' => [$request + ['HTTP_AUTHORIZATION' => 'Digest username="u", realm="r", nonce="n", uri="/mcp/?b=2&a=1", response="x"', 'CONTENT_TYPE' => 'application/json']]; + } + #[Group('ac-02-6')] public function testConformanceListing(): void { @@ -225,6 +248,23 @@ public function testConformanceTracingOff(): void $this->assertSame([], $harness->events()); } + /** + * The HTTP request as the client sent it (D06, D34): the URL with its own query order and trailing slash, and the + * headers it sent, `Authorization` included, but none that a library makes up from them. + * + * @param array $server + */ + #[DataProvider('authenticatedRequests')] + #[Group('ac-02-43')] + public function testConformanceRequestInfo(array $server): void + { + $info = $this->requestInfoOf($server); + + $this->assertSame('http://example.test/mcp/?b=2&a=1', JsonAssert::at($info, 'url')); + $this->assertSame(['host', 'authorization', 'content-type'], $this->memberNames(JsonAssert::at($info, 'headers'))); + $this->assertSame($server['HTTP_AUTHORIZATION'] ?? null, JsonAssert::at($info, 'headers', 'authorization')); + } + /** Plan E1 header conflict 3: a reused object ID belongs to a dead server, so the new server's state replaces it. */ #[Group('ac-02-5')] public function testConformanceReusedObjectId(): void diff --git a/tests/Integration/Conformance/LaravelMcpConformanceTest.php b/tests/Integration/Conformance/LaravelMcpConformanceTest.php index 8bd319d..fdcc815 100644 --- a/tests/Integration/Conformance/LaravelMcpConformanceTest.php +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -6,6 +6,7 @@ use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; use AgentCat\Tests\Support\LaravelMcp\LaravelConformanceHarness; use AgentCat\Tests\Support\LaravelMcp\LaravelHarness; use AgentCat\Tests\Support\LaravelMcp\LaravelStdioHarness; @@ -163,4 +164,17 @@ protected function fullPage(): ConformanceHarness { return LaravelConformanceHarness::fullPage(); } + + /** One HTTP request to a tracked server, built from the server variables like `Request::capture()` does. */ + #[\Override] + protected function requestInfoOf(array $server): \stdClass + { + $fixture = new LaravelTrackingFixture(); + (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->requestFromServer($server, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => self::SESSION]]); + + $info = JsonAssert::at($fixture->event()->parameters, 'extra', 'requestInfo'); + $this->assertInstanceOf(\stdClass::class, $info); + + return $info; + } } diff --git a/tests/Integration/Conformance/McpSdkConformanceTest.php b/tests/Integration/Conformance/McpSdkConformanceTest.php index 0df9ed9..d40b6ef 100644 --- a/tests/Integration/Conformance/McpSdkConformanceTest.php +++ b/tests/Integration/Conformance/McpSdkConformanceTest.php @@ -4,14 +4,19 @@ namespace AgentCat\Tests\Integration\Conformance; +use AgentCat\Internal\Engine\HttpContext; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; +use AgentCat\McpSdk\Internal\RequestReader; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\McpSdkConformanceHarness; use AgentCat\Tests\Support\McpSdk\Tools; use AgentCat\Tests\Support\McpSdk\TrackingFixture; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Request\CallToolRequest; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Session; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -153,4 +158,17 @@ protected function fullPage(): ConformanceHarness { return McpSdkConformanceHarness::fullPage(); } + + /** The adapter's reader with what `HttpContext::capture()` gives under PHP-FPM for these server variables. */ + #[\Override] + protected function requestInfoOf(array $server): \stdClass + { + $reader = new RequestReader(static fn(): ?HttpContext => HttpContext::fromServer('fpm-fcgi', $server)); + $request = (new CallToolRequest('echo', ['msg' => 'hi', 'session_id' => self::SESSION]))->withId(1); + + $info = JsonAssert::at($reader->read($request, new Session(new InMemorySessionStore()))->extra, 'requestInfo'); + $this->assertInstanceOf(\stdClass::class, $info); + + return $info; + } } diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 197fdb7..9978361 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -215,13 +215,14 @@ public function testHeadersComeFromTheServersOwnRequest(): void $this->assertSame('http://localhost/mcp/conformance', JsonAssert::at($info, 'url')); } - /** Task 5 review minor 2: the client controls the `Host` header; Symfony refuses an invalid one. */ - public function testAnInvalidHostHeaderCostsTheHttpContextOnly(): void + /** Task 5 review minor 2: the client controls the headers; Symfony refuses an invalid method override. */ + public function testAnInvalidMethodOverrideCostsTheHttpContextOnly(): void { $fixture = new LaravelTrackingFixture(); - $twin = (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + $headers = ['X-HTTP-Method-Override' => 'NOT A METHOD']; + $twin = (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: $headers); - $answer = (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + $answer = (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: $headers); $this->assertEquals($twin, $answer); $extra = JsonAssert::at($fixture->event()->parameters, 'extra'); @@ -230,6 +231,22 @@ public function testAnInvalidHostHeaderCostsTheHttpContextOnly(): void $this->assertNotContains(LaravelMcpAdapter::COMPATIBILITY_LINE, $fixture->lines()); } + /** The URL is built like the SAPI reading's (Task 6 review): an invalid `Host` header is recorded as it was sent. */ + #[Group('ac-02-43')] + public function testAnInvalidHostHeaderIsRecordedAsSent(): void + { + $fixture = new LaravelTrackingFixture(); + $twin = (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + + $answer = (new LaravelHarness(ConformanceServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], headers: ['Host' => 'bad host!']); + + $this->assertEquals($twin, $answer); + $info = JsonAssert::at($fixture->event()->parameters, 'extra', 'requestInfo'); + $this->assertSame('http://bad host!/mcp/conformance', JsonAssert::at($info, 'url')); + $this->assertSame('bad host!', JsonAssert::at($info, 'headers', 'host')); + $this->assertNotContains(LaravelMcpAdapter::COMPATIBILITY_LINE, $fixture->lines()); + } + /** @return iterable}> */ public static function callsWithoutAToolName(): iterable { diff --git a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php index d304e81..5361dcb 100644 --- a/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -255,14 +255,14 @@ public function testTheHttpContextIsTheRequestOfTheServersOwnTransport(): void } /** - * Symfony refuses an invalid `Host` header, which the client controls: no HTTP context, and no compatibility line + * Symfony refuses an invalid method override, which the client controls: no HTTP context, and no compatibility line * (Task 5 review minor 2). */ public function testARequestLaravelCannotReadHasNoHttpContextAndLogsNothing(): void { $logger = new MemoryLogger(); Log::setLogger($logger); - $server = $this->conformanceServer(new HttpTransport(Request::create('/mcp', 'POST', server: ['HTTP_HOST' => 'bad host!']))); + $server = $this->conformanceServer(new HttpTransport(Request::create('/mcp', 'POST', server: ['HTTP_X_HTTP_METHOD_OVERRIDE' => 'NOT A METHOD']))); $this->assertNull((LaravelMcpAdapter::httpContext($server))()); $this->assertSame([], $logger->records); diff --git a/tests/Support/LaravelMcp/LaravelHarness.php b/tests/Support/LaravelMcp/LaravelHarness.php index ad017db..25049bc 100644 --- a/tests/Support/LaravelMcp/LaravelHarness.php +++ b/tests/Support/LaravelMcp/LaravelHarness.php @@ -37,12 +37,38 @@ public function __construct(private readonly string $serverClass, private readon */ public function request(string $method, array $params = [], array $headers = []): \stdClass { - $body = json_encode(['jsonrpc' => '2.0', 'id' => $this->nextId++, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params], WireEncoder::FLAGS); $server = ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'localhost']; foreach ($headers as $name => $value) { $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; } - $transport = new HttpTransport(Request::create('/mcp/conformance', 'POST', server: $server, content: $body)); + + return $this->answer(Request::create('/mcp/conformance', 'POST', server: $server, content: $this->body($method, $params))); + } + + /** + * One JSON-RPC request in an HTTP request that PHP received with exactly these server variables, as + * `Request::capture()` builds it under PHP-FPM; the whole answer. + * + * @param array $server + * @param array $params + */ + public function requestFromServer(array $server, string $method, array $params = []): \stdClass + { + return $this->answer(new Request(server: $server, content: $this->body($method, $params))); + } + + /** + * @param array $params + */ + private function body(string $method, array $params): string + { + return json_encode(['jsonrpc' => '2.0', 'id' => $this->nextId++, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params], WireEncoder::FLAGS); + } + + /** Builds a new server on an `HttpTransport` over the request, and runs it. */ + private function answer(Request $request): \stdClass + { + $transport = new HttpTransport($request); $instance = Container::getInstance()->make($this->serverClass, ['transport' => $transport]); Assert::assertInstanceOf(Server::class, $instance); if ($this->track instanceof \Closure) { diff --git a/tests/Unit/LaravelMcp/LaravelHttpContextTest.php b/tests/Unit/LaravelMcp/LaravelHttpContextTest.php index f02247c..c84a334 100644 --- a/tests/Unit/LaravelMcp/LaravelHttpContextTest.php +++ b/tests/Unit/LaravelMcp/LaravelHttpContextTest.php @@ -6,13 +6,14 @@ use AgentCat\LaravelMcp\Internal\LaravelHttpContext; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; #[Group('ac-02-43')] final class LaravelHttpContextTest extends TestCase { - public function testTheMethodTheFullUrlAndTheHeadersComeFromTheRequest(): void + public function testTheMethodTheUrlAndTheHeadersComeFromTheRequest(): void { $request = Request::create('https://api.test/mcp/weather?unit=c', 'POST', server: ['HTTP_X_AGENTCAT_HEALTH_RUN' => 'S', 'HTTP_AUTHORIZATION' => 'Bearer t']); @@ -24,6 +25,46 @@ public function testTheMethodTheFullUrlAndTheHeadersComeFromTheRequest(): void $this->assertSame('Bearer t', $context->headers['authorization'] ?? null); } + /** Like `HttpContext::fromServer()`: Laravel's `fullUrl()` would give `https://example.test:8443/mcp?a=1&b=2`. */ + public function testTheUrlIsTheSchemeTheHostAndTheRequestUriAsSent(): void + { + $request = new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTPS' => 'on', 'HTTP_HOST' => 'Example.test:8443', 'REQUEST_URI' => '/mcp/?b=2&a=1', 'QUERY_STRING' => 'b=2&a=1']); + + $this->assertSame('https://Example.test:8443/mcp/?b=2&a=1', LaravelHttpContext::of($request)->url); + } + + /** No `Host` header, no full URL (C15), as the SAPI reading has it; Symfony would fall back to `SERVER_NAME`. */ + public function testARequestWithoutAHostHeaderHasNoUrl(): void + { + $request = new Request(server: ['REQUEST_METHOD' => 'POST', 'SERVER_NAME' => 'example.test', 'REQUEST_URI' => '/mcp']); + + $this->assertNull(LaravelHttpContext::of($request)->url); + } + + /** @return iterable, string}> */ + public static function credentials(): iterable + { + yield 'Basic, decoded by Symfony' => [['HTTP_AUTHORIZATION' => 'Basic dTpzZWNyZXQ='], 'Basic dTpzZWNyZXQ=']; + yield 'Basic, decoded by the SAPI too' => [['HTTP_AUTHORIZATION' => 'Basic dTpzZWNyZXQ=', 'PHP_AUTH_USER' => 'u', 'PHP_AUTH_PW' => 'secret'], 'Basic dTpzZWNyZXQ=']; + yield 'Digest' => [['HTTP_AUTHORIZATION' => 'Digest username="u", response="x"'], 'Digest username="u", response="x"']; + // mod_php keeps only PHP_AUTH_*: the Authorization header that Symfony rebuilds from them is the client's. + yield 'Basic, consumed by the SAPI' => [['PHP_AUTH_USER' => 'u', 'PHP_AUTH_PW' => 'secret'], 'Basic dTpzZWNyZXQ=']; + } + + /** + * Symfony's `ServerBag` adds `php-auth-user`, `php-auth-pw` or `php-auth-digest`, which no client sends: the + * Authorization header stays, verbatim (D06). + * + * @param array $credentials + */ + #[DataProvider('credentials')] + public function testTheHeadersSymfonyMakesUpFromTheCredentialsAreLeftOut(array $credentials, string $authorization): void + { + $request = new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'example.test', 'REQUEST_URI' => '/mcp'] + $credentials); + + $this->assertSame(['host' => 'example.test', 'authorization' => $authorization], LaravelHttpContext::of($request)->headers); + } + public function testRepeatedValuesAreJoinedAndNamesAreLowerCase(): void { $request = Request::create('/mcp', 'GET'); From fcde847690b795f8350e9931c2bdfbf9265570fa Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 20:48:34 +0000 Subject: [PATCH 12/30] feat: track every laravel/mcp server through an auto-discovered service provider The provider merges and publishes config/agentcat.php (snake_case keys over the Options names, AGENTCAT_* variables, hooks as invokable class names resolved from the current container when they run) and tracks each resolved laravel/mcp server from an afterResolving callback, with the exclude list and the per-server project map. A null exporter entry is skipped without a line, and a header that is not a string is named. --- .php-cs-fixer.dist.php | 2 +- composer-dependency-analyser.php | 2 + composer.json | 7 + config/agentcat.php | 50 +++ src/Internal/Support/ConfigOptions.php | 90 ++++ src/Internal/Support/EnabledGate.php | 38 ++ src/Internal/Telemetry/ExporterConfigs.php | 10 +- src/Laravel/AgentCatServiceProvider.php | 40 ++ src/Laravel/Internal/ServerTracker.php | 99 +++++ src/Options.php | 3 +- .../Laravel/ServiceProviderTest.php | 410 ++++++++++++++++++ tests/Support/Laravel/ArgumentsHook.php | 15 + tests/Support/Laravel/InvokableIdentify.php | 21 + .../Internal/Support/ConfigOptionsTest.php | 178 ++++++++ .../Unit/Internal/Support/EnabledGateTest.php | 105 +++++ .../Telemetry/ExporterConfigsTest.php | 38 +- tests/Unit/Laravel/ConfigFileTest.php | 32 ++ tests/Unit/OptionsTest.php | 2 +- 18 files changed, 1131 insertions(+), 11 deletions(-) create mode 100644 config/agentcat.php create mode 100644 src/Internal/Support/ConfigOptions.php create mode 100644 src/Internal/Support/EnabledGate.php create mode 100644 src/Laravel/AgentCatServiceProvider.php create mode 100644 src/Laravel/Internal/ServerTracker.php create mode 100644 tests/Integration/Laravel/ServiceProviderTest.php create mode 100644 tests/Support/Laravel/ArgumentsHook.php create mode 100644 tests/Support/Laravel/InvokableIdentify.php create mode 100644 tests/Unit/Internal/Support/ConfigOptionsTest.php create mode 100644 tests/Unit/Internal/Support/EnabledGateTest.php create mode 100644 tests/Unit/Laravel/ConfigFileTest.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 949f5e2..264907b 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -7,7 +7,7 @@ $finder = Finder::create() ->in([__DIR__ . '/src', __DIR__ . '/tests']) - ->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __FILE__]); + ->append([__DIR__ . '/rector.php', __DIR__ . '/composer-dependency-analyser.php', __DIR__ . '/config/agentcat.php', __FILE__]); return (new Config()) ->setRiskyAllowed(true) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index d8e55c2..be4c158 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -22,7 +22,9 @@ ->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/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]) ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // 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 both files (LayeringTest). ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) diff --git a/composer.json b/composer.json index e083e1c..bac6b5f 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,13 @@ }, "sort-packages": true }, + "extra": { + "laravel": { + "providers": [ + "AgentCat\\Laravel\\AgentCatServiceProvider" + ] + } + }, "scripts": { "check": [ "@composer validate --strict", diff --git a/config/agentcat.php b/config/agentcat.php new file mode 100644 index 0000000..97fd75a --- /dev/null +++ b/config/agentcat.php @@ -0,0 +1,50 @@ + env('AGENTCAT_ENABLED'), + + // Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run. + 'project_id' => env('AGENTCAT_PROJECT_ID'), + + // Servers AgentCat never tracks: server classes. A class also excludes its subclasses. + 'exclude' => [ + 'Laravel\Boost\Mcp\Boost', + ], + + // Another project for some servers: server class => project ID. The first class the server is an instance of wins. + 'projects' => [ + // App\Mcp\Servers\BillingServer::class => env('AGENTCAT_BILLING_PROJECT_ID'), + ], + + // The log channel for AgentCat's own lines. Unset: the application's default channel. + 'log_channel' => env('AGENTCAT_LOG_CHANNEL'), + + 'api_url' => env('AGENTCAT_API_URL'), + + 'enable_report_missing' => true, + 'enable_tracing' => true, + 'enable_tool_call_context' => true, + 'custom_context_description' => null, + 'enable_agent_tracking' => false, + + // Hooks: the class name of an invokable class, resolved from the container when the hook runs. + // Closures cannot go here: php artisan config:cache cannot store them. + 'identify' => null, + 'resolve_session_id' => null, + 'event_tags' => null, + 'event_properties' => null, + 'redact_sensitive_information' => null, + 'redact_event' => null, + + // Your own telemetry tools: name => ['type' => 'otlp'|'datadog'|'sentry'|'posthog', …]. A null entry is skipped. + 'exporters' => null, + + 'disable_diagnostics' => env('AGENTCAT_DISABLE_DIAGNOSTICS'), + 'disable_signal_handlers' => false, +]; diff --git a/src/Internal/Support/ConfigOptions.php b/src/Internal/Support/ConfigOptions.php new file mode 100644 index 0000000..19c4c9c --- /dev/null +++ b/src/Internal/Support/ConfigOptions.php @@ -0,0 +1,90 @@ + config key => `Options` argument name */ + public const array KEYS = [ + 'enable_report_missing' => 'enableReportMissing', + 'enable_tracing' => 'enableTracing', + 'enable_tool_call_context' => 'enableToolCallContext', + 'custom_context_description' => 'customContextDescription', + 'enable_agent_tracking' => 'enableAgentTracking', + 'resolve_session_id' => 'resolveSessionId', + 'identify' => 'identify', + 'redact_sensitive_information' => 'redactSensitiveInformation', + 'redact_event' => 'redactEvent', + 'api_url' => 'apiBaseUrl', + 'event_tags' => 'eventTags', + 'event_properties' => 'eventProperties', + 'exporters' => 'exporters', + 'disable_diagnostics' => 'disableDiagnostics', + 'disable_signal_handlers' => 'disableSignalHandlers', + ]; + + private const array HOOKS = ['resolveSessionId', 'identify', 'redactSensitiveInformation', 'redactEvent', 'eventTags', 'eventProperties']; + + /** + * Never throws: an unknown key or a bad value logs one line and the default is used (`Options::fromArray()`). + * + * @param array $config the bridge's `agentcat` configuration + * @param list $bridgeKeys keys the bridge reads itself (not options) + * @param \Closure(string): mixed $resolve a hook's name → the callable, when the hook runs + */ + public static function options(array $config, array $bridgeKeys, \Closure $resolve): Options + { + $mapped = []; + foreach ($config as $key => $value) { + if (in_array($key, $bridgeKeys, true)) { + continue; + } + $option = self::KEYS[$key] ?? null; + if ($option === null) { + Log::write(sprintf('WARN: Unknown AgentCat option "agentcat.%s" ignored', $key)); + + continue; + } + if ($value === null) { + continue; // env() of an unset variable: the default + } + $mapped[$option] = in_array($option, self::HOOKS, true) && is_string($value) && !is_callable($value) + ? self::named($value, $resolve) + : $value; + } + + return Options::fromArray($mapped); + } + + /** + * A hook that resolves its name when it runs. What the name resolves to must be callable; the hook guard logs + * anything it throws (D32). + * + * @param \Closure(string): mixed $resolve + * + * @return \Closure(mixed ...): mixed + */ + private static function named(string $name, \Closure $resolve): \Closure + { + return static function (mixed ...$arguments) use ($name, $resolve): mixed { + $hook = $resolve($name); + if (!is_callable($hook)) { + throw new \UnexpectedValueException(sprintf('%s is not invokable', $name)); + } + + return $hook(...$arguments); + }; + } +} diff --git a/src/Internal/Support/EnabledGate.php b/src/Internal/Support/EnabledGate.php new file mode 100644 index 0000000..395d087 --- /dev/null +++ b/src/Internal/Support/EnabledGate.php @@ -0,0 +1,38 @@ + $word; + } + if ($configured === null || (is_string($configured) && trim($configured) === '')) { + return TestMode::sdkEnabled(...); + } + Log::write(sprintf('WARN: AgentCat config "agentcat.enabled" is "%s", not one of true, 1, yes, on, false, 0, no, off; the default is used', is_scalar($configured) ? (string) $configured : get_debug_type($configured))); + + return static fn(): bool => !TestMode::inTestEnvironment(); + } +} diff --git a/src/Internal/Telemetry/ExporterConfigs.php b/src/Internal/Telemetry/ExporterConfigs.php index 8673d0e..1b5a57e 100644 --- a/src/Internal/Telemetry/ExporterConfigs.php +++ b/src/Internal/Telemetry/ExporterConfigs.php @@ -13,8 +13,9 @@ use AgentCat\Internal\Support\Js; /** - * `exporters` from a framework config file: each `['type' => …]` array becomes its config object. A bad entry logs - * one line and is skipped; the others are kept. Never throws. + * `exporters` from a framework config file: each `['type' => …]` array becomes its config object. A null entry is + * skipped without a line: a config file switches an exporter off with null. A bad entry logs one line and is skipped; + * the others are kept. Never throws. * * @internal */ @@ -36,6 +37,9 @@ public static function fromArray(array $entries): array { $configs = []; foreach ($entries as $key => $entry) { + if ($entry === null) { + continue; + } $name = (string) $key; try { $config = match (true) { @@ -114,7 +118,7 @@ private static function headers(array $entry): array $value = $entry['headers'] ?? []; $headers = []; foreach (is_array($value) ? $value : throw self::invalid('headers', 'array', $value) as $header => $text) { - $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers', 'array', $value); + $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers.' . $header, 'string', $text); } return $headers; diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php new file mode 100644 index 0000000..cb55c65 --- /dev/null +++ b/src/Laravel/AgentCatServiceProvider.php @@ -0,0 +1,40 @@ +mergeConfigFrom(self::CONFIG, 'agentcat'); + $this->app->singleton(ServerTracker::class); + $this->app->bind(Client::class, static fn(): Client => AgentCat::client()); + } + + public function boot(): void + { + $this->publishes([self::CONFIG => $this->app->configPath('agentcat.php')], 'agentcat-config'); + 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 { + $tracker->track($server, $app); + }); + } + } +} diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php new file mode 100644 index 0000000..9d8c6a4 --- /dev/null +++ b/src/Laravel/Internal/ServerTracker.php @@ -0,0 +1,99 @@ + 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']; + + private ?Client $client = null; + + private ?Options $options = null; + + public function track(Server $server, Container $app): void + { + try { + $config = $app->make('config')->get('agentcat'); + $config = is_array($config) ? $config : []; + if ($this->matches($server, $config['exclude'] ?? []) !== null) { + return; + } + $this->adoptLogger($app, $config['log_channel'] ?? null); + $client = AgentCat::client(); + // A fake records every server (header conflict 18); the shared client is never replaced (header conflict 11). + $client = FakeLog::of($client) instanceof FakeLog ? $client : ($this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null)))); + $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->resolve(...)); + $client->track($server, $this->project($server, $config), $this->options); + } catch (\Throwable $e) { + Log::write('Warning: Failed to track server - ' . ErrorText::of($e)); + } + } + + /** + * A hook's class, resolved when the hook runs from the current container: under Octane, the sandbox of the + * request, never a container of an earlier request (Review Focus 4). + */ + private function resolve(string $class): mixed + { + return GlobalContainer::getInstance()->make($class); + } + + /** @param array $config */ + private function project(Server $server, array $config): ?string + { + $projects = is_array($config['projects'] ?? null) ? $config['projects'] : []; + $class = $this->matches($server, array_keys($projects)); + $project = $class !== null ? ($projects[$class] ?? null) : ($config['project_id'] ?? null); + + return is_string($project) ? $project : null; // null reads AGENTCAT_PROJECT_ID (D36) + } + + /** The first listed class the server is an instance of; a class also matches its subclasses. */ + private function matches(Server $server, mixed $classes): ?string + { + foreach (is_array($classes) ? $classes : [] as $class) { + if (is_string($class) && $server instanceof $class) { + return $class; + } + } + + return null; + } + + /** + * D30: AgentCat's lines go to the configured log channel, else to the default one. The first adopted logger of the + * process wins, and a fake's diversion keeps priority, so a later server changes nothing; after a fake, the next + * server routes the lines again. + */ + private function adoptLogger(Container $app, mixed $channel): void + { + Log::adopt($app->make('log')->channel(is_string($channel) && $channel !== '' ? $channel : null)); + } +} diff --git a/src/Options.php b/src/Options.php index fe702f0..6fa1659 100644 --- a/src/Options.php +++ b/src/Options.php @@ -79,7 +79,8 @@ public function __construct( * Builds options from an array with the argument names as keys (camelCase only). * * An unknown key, or a value of the wrong type, logs one warning and the default is used. The bool options - * reject an explicit null too. A callable that is not a closure is converted to one. + * reject an explicit null too. A callable that is not a closure is converted to one. An `exporters` entry that is + * null is skipped without a warning: a config file switches an exporter off with null. * * `disableDiagnostics` also takes what an `.env` file gives: int 1 or 0, and the words of * `AGENTCAT_DISABLE_DIAGNOSTICS` (`true`, `1`, `yes`, `on` turn diagnostics off; `false`, `0`, `no`, `off` and diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php new file mode 100644 index 0000000..6da95f5 --- /dev/null +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -0,0 +1,410 @@ +agentcatEnvironment = Environment::clear('AGENTCAT_ENABLED', 'AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'AGENTCAT_LOG_CHANNEL'); + TestMode::reset(); + parent::setUp(); + EventQueue::setGlobal(new EventQueue(transport: $this->transport = new RecordingTransport())); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + TestMode::reset(); + $this->agentcatEnvironment?->restore(); + } + + /** @param Application $app */ + #[\Override] + protected function defineEnvironment(mixed $app): void + { + parent::defineEnvironment($app); + if (!$this->ownConfiguration) { + config(['agentcat.enabled' => true, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + } + + /** Review Focus 3: config/agentcat.php with missing and mistyped `env()` values, before the providers boot. */ + public function defineBadConfiguration(): void + { + $this->ownConfiguration = true; + config([ + 'agentcat.enabled' => true, + 'agentcat.project_id' => null, + 'agentcat.enable_tracing' => 'yes', + 'agentcat.identify' => 'App\Missing\Identify', + 'agentcat.exporters' => ['otlp' => null], + 'agentcat.unknown_key' => 1, + ]); + } + + /** Review Focus 3: `AGENTCAT_ENABLED=maybe`, which `env()` gives the configuration too, before the providers boot. */ + public function defineAnUnknownEnabledWord(): void + { + $this->ownConfiguration = true; + putenv('AGENTCAT_ENABLED=maybe'); + config(['agentcat.enabled' => 'maybe', 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + + /** @return list */ + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), AgentCatServiceProvider::class]; + } + + public function testEveryResolvedServerIsTrackedWithNoCode(): void + { + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + public function testBoostIsExcludedByDefaultAndConfigExcludesMore(): void + { + $this->assertSame(['Laravel\Boost\Mcp\Boost'], config('agentcat.exclude')); + config(['agentcat.exclude' => [ConformanceServer::class]]); + + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + public function testAnExcludedClassAlsoExcludesItsSubclasses(): void + { + config(['agentcat.exclude' => ['App\Mcp\Missing', Server::class]]); + + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + public function testAServerCanGoToItsOwnProject(): void + { + config(['agentcat.projects' => [ConformanceServer::class => self::OTHER_PROJECT]]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(self::OTHER_PROJECT, $this->deliveredField('project_id')); + } + + public function testTheFirstProjectEntryThatTheServerIsAnInstanceOfWins(): void + { + config(['agentcat.projects' => ['App\Mcp\Missing' => 'proj_mmmmmmmmmmmmmmmmmmmmmmmmmmm', Server::class => self::OTHER_PROJECT, ConformanceServer::class => 'proj_ccccccccccccccccccccccccccc']]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(self::OTHER_PROJECT, $this->deliveredField('project_id')); + } + + public function testAServerWithoutAProjectEntryGoesToTheProjectId(): void + { + config(['agentcat.projects' => ['App\Mcp\Missing' => self::OTHER_PROJECT]]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(LaravelTrackingFixture::PROJECT, $this->deliveredField('project_id')); + } + + public function testTheLogLinesGoToTheConfiguredChannel(): void + { + $path = $this->laravelDirectory . '/agentcat-channel.log'; + config(['logging.channels.agentcat-test' => ['driver' => 'single', 'path' => $path], 'agentcat.log_channel' => 'agentcat-test']); + + (new LaravelHarness(ConformanceServer::class))->listTools(); + + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', (string) file_get_contents($path)); + $this->assertStringNotContainsString('AgentCat', $this->defaultChannelLog()); + } + + /** @return iterable */ + public static function defaultChannels(): iterable + { + yield 'unset' => [null]; + yield 'blank' => ['']; + yield 'not a name' => [5]; + } + + #[DataProvider('defaultChannels')] + public function testWithoutALogChannelNameTheLinesGoToTheDefaultChannel(mixed $channel): void + { + config(['agentcat.log_channel' => $channel]); + + (new LaravelHarness(ConformanceServer::class))->listTools(); + + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->defaultChannelLog()); + } + + public function testAfterAFakeTheNextServerRoutesTheLinesToTheChannel(): void + { + AgentCat::fake(); + (new LaravelHarness(ConformanceServer::class))->listTools(); + AgentCat::setClient(null); + + (new LaravelHarness(ConformanceServer::class))->listTools(); + + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->defaultChannelLog()); + } + + public function testOneTrackerServesTheApplication(): void + { + $app = Container::getInstance(); + + $this->assertSame($app->make(ServerTracker::class), $app->make(ServerTracker::class)); + } + + public function testBootingTracksNothing(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $command = $this->artisan('list'); + $this->assertInstanceOf(PendingCommand::class, $command); + $command->assertExitCode(0)->run(); + + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); + } + + public function testTheContainerGivesTheSharedClient(): void + { + $app = Container::getInstance(); + + $this->assertSame(AgentCat::client(), $app->make(Client::class)); + $fake = AgentCat::fake(); + $this->assertSame($fake->client(), $app->make(Client::class)); // a binding, not a singleton + } + + public function testAFakeRecordsTheCallsOfEveryServerWhateverTheConfigSays(): void + { + $fake = AgentCat::fake(); + config(['agentcat.enabled' => false]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi']); + + $fake->assertTrackedCount(1); + $this->assertSame([], $this->transport->deliveries); + } + + /** @return iterable}> */ + public static function untracked(): iterable + { + yield 'an excluded server' => [['agentcat.exclude' => [ConformanceServer::class]]]; + yield 'AgentCat off' => [['agentcat.enabled' => false]]; + yield 'agentcat.enabled unset in a test environment (D31)' => [['agentcat.enabled' => null]]; + } + + /** + * An untracked server keeps laravel/mcp's own method classes and answers exactly like a server that the container + * never built (no `afterResolving` callback, so no AgentCat at all). + * + * @param array $config + */ + #[DataProvider('untracked')] + public function testAnUntrackedServerKeepsItsMethodsAndItsAnswers(array $config): void + { + config($config); + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $this->assertSame($this->answers(static fn(HttpTransport $transport): Server => new ConformanceServer($transport)), $this->answers(static fn(HttpTransport $transport): mixed => Container::getInstance()->make(ConformanceServer::class, ['transport' => $transport]))); + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); + } + + /** Review Focus 3: boot, resolve, `tools/call` and flush with the bad values. */ + #[DefineEnvironment('defineBadConfiguration')] + public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $harness = new LaravelHarness(ConformanceServer::class); + + $harness->listTools(); + $answer = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + $this->assertTrue(EventQueue::global()->flush(1.0)); + + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 1, 'text')); + $started = 'AgentCat setup started | project (telemetry-only) | sdk laravel-mcp/server | signals laravelMcpServer'; + $complete = 'AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=0'; + $own = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; // the fixture's own tool, for every server + $this->assertSame([ + 'WARN: Unknown AgentCat option "agentcat.unknown_key" ignored', // once: the options serve every server + 'WARN: AgentCat option "enableTracing" must be bool, got string; the default is used', + $started, + 'Initialized telemetry with 0 exporters', // the null exporter entry logs nothing + $complete, + $own, + $started, // the server of the second request + $complete, + $own, + 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)', + 'Error: identify callback threw - Illuminate\Contracts\Container\BindingResolutionException: Target class [App\Missing\Identify] does not exist.', + ], array_map(static fn(string $line): string => str_starts_with($line, $own) ? $own : $line, $logger->messages())); + } + + /** Review Focus 3: boot, resolve, `tools/call` and flush with `AGENTCAT_ENABLED=maybe`. */ + #[DefineEnvironment('defineAnUnknownEnabledWord')] + public function testAnUnknownEnabledWordLogsOneLineAndTracksNothing(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $harness = new LaravelHarness(ConformanceServer::class); + + $tools = $harness->listTools()->tools; + $harness->callTool('echo', ['msg' => 'hi']); + $this->assertTrue(EventQueue::global()->flush(1.0)); + + $this->assertIsArray($tools); + $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); // the default: off in a test environment + $this->assertSame(['WARN: AgentCat config "agentcat.enabled" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used'], $logger->messages()); + $this->assertSame([], $this->transport->deliveries); + } + + /** Octane runs each request in a sandbox container: a hook comes from the container of its request (Review Focus 4). */ + public function testAHookComesFromTheCurrentContainerWhenItRuns(): void + { + config(['agentcat.identify' => InvokableIdentify::class]); + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + $application = Container::getInstance(); + $sandbox = new Container(); + $sandbox->instance(InvokableIdentify::class, new InvokableIdentify('from-the-request-container')); + + Container::setInstance($sandbox); + try { + $this->assertTrue(EventQueue::global()->flush(1.0)); + } finally { + Container::setInstance($application); + } + + $this->assertSame('from-the-request-container', $this->deliveredField('identify_actor_given_id')); + } + + public function testATrackingFailureLogsOneLineAndTheServerStillAnswers(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $this->app?->bind('log', static fn(): never => throw new \RuntimeException('no log manager')); + + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame('own', JsonAssert::at(end($tools), 'name')); + $this->assertSame(['Warning: Failed to track server - RuntimeException: no log manager'], $logger->messages()); + } + + public function testTheConfigurationIsPublishedUnderItsTag(): void + { + $command = $this->artisan('vendor:publish', ['--tag' => 'agentcat-config']); + $this->assertInstanceOf(PendingCommand::class, $command); + $command->assertExitCode(0)->run(); + + $this->assertFileEquals(dirname(__DIR__, 3) . '/config/agentcat.php', $this->laravelDirectory . '/config/agentcat.php'); + } + + public function testComposerNamesTheProviderForPackageDiscovery(): void + { + $composer = Json::decode((string) file_get_contents(dirname(__DIR__, 3) . '/composer.json')); + + $this->assertSame([AgentCatServiceProvider::class], JsonAssert::at($composer, 'extra', 'laravel', 'providers')); + } + + /** What the application's default log channel wrote (`LaravelTestCase`); '' before its first line. */ + private function defaultChannelLog(): string + { + $path = $this->laravelDirectory . '/laravel.log'; + + return is_file($path) ? (string) file_get_contents($path) : ''; + } + + /** A field of the one event the API got. */ + private function deliveredField(string $field): mixed + { + $this->assertTrue(EventQueue::global()->flush(1.0)); + $this->assertCount(1, $this->transport->deliveries); + $delivery = $this->transport->deliveries[0] ?? null; + $this->assertInstanceOf(Delivery::class, $delivery); + + return JsonAssert::at(Json::decode($delivery->body), $field); + } + + /** + * The method map and the raw answer of a new server for `initialize`, `tools/list` and `tools/call`, each request + * on its own `HttpTransport`, as `Mcp::web()` answers it. + * + * @param \Closure(HttpTransport): mixed $build + * + * @return list, answer: string}> + */ + private function answers(\Closure $build): array + { + $requests = [ + ['initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'legacy-client', 'version' => '1.0.0']]], + ['tools/list', new \stdClass()], + ['tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi'], '_meta' => LaravelHarness::meta([], ['name' => 'modern-client', 'version' => '2.0.0'])]], + ]; + $answers = []; + foreach ($requests as $id => [$method, $params]) { + $body = json_encode(['jsonrpc' => '2.0', 'id' => $id + 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR); + $transport = new HttpTransport(Request::create('/mcp/conformance', 'POST', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'localhost'], content: $body)); + $server = $build($transport); + $this->assertInstanceOf(ConformanceServer::class, $server); + $methods = ServerAccess::methods($server); + $server->start(); + $answers[] = ['methods' => $methods, 'answer' => (string) $transport->run()->getContent()]; + } + + return $answers; + } +} diff --git a/tests/Support/Laravel/ArgumentsHook.php b/tests/Support/Laravel/ArgumentsHook.php new file mode 100644 index 0000000..66c8872 --- /dev/null +++ b/tests/Support/Laravel/ArgumentsHook.php @@ -0,0 +1,15 @@ + */ + public function __invoke(mixed ...$arguments): array + { + return array_values($arguments); + } +} diff --git a/tests/Support/Laravel/InvokableIdentify.php b/tests/Support/Laravel/InvokableIdentify.php new file mode 100644 index 0000000..e13c9b5 --- /dev/null +++ b/tests/Support/Laravel/InvokableIdentify.php @@ -0,0 +1,21 @@ +userId); + } +} diff --git a/tests/Unit/Internal/Support/ConfigOptionsTest.php b/tests/Unit/Internal/Support/ConfigOptionsTest.php new file mode 100644 index 0000000..af99297 --- /dev/null +++ b/tests/Unit/Internal/Support/ConfigOptionsTest.php @@ -0,0 +1,178 @@ +container = new Container(); + $this->resolve = fn(string $name): mixed => $this->container->make($name); + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testEverySnakeCaseKeyBecomesItsOption(): void + { + $options = ConfigOptions::options([ + 'api_url' => 'http://127.0.0.1:1', + 'enable_report_missing' => false, + 'enable_tracing' => false, + 'enable_tool_call_context' => false, + 'custom_context_description' => 'Why?', + 'enable_agent_tracking' => true, + 'disable_diagnostics' => 'true', + 'disable_signal_handlers' => true, + 'exporters' => ['otlp' => ['type' => 'otlp', 'endpoint' => 'http://127.0.0.1:1/v1/traces']], + ], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertSame('http://127.0.0.1:1', $options->apiBaseUrl); + $this->assertSame([false, false, false, 'Why?', true, true, true], [$options->enableReportMissing, $options->enableTracing, $options->enableToolCallContext, $options->customContextDescription, $options->enableAgentTracking, $options->disableDiagnostics, $options->disableSignalHandlers]); + $this->assertInstanceOf(OtlpExporter::class, $options->exporters['otlp'] ?? null); + $this->assertSame([], $this->logger->messages()); + } + + public function testAHookIsAnInvokableClassResolvedWhenItRuns(): void + { + $container = $this->container; + $options = ConfigOptions::options(['identify' => InvokableIdentify::class], ServerTracker::BRIDGE_KEYS, $this->resolve); + $container->instance(InvokableIdentify::class, new InvokableIdentify('from-container')); + + $hook = $options->identify; + $this->assertInstanceOf(\Closure::class, $hook); + $this->assertEquals(new Identity('from-container'), $hook(new ToolCall('echo', []))); + } + + /** @return iterable */ + public static function hooks(): iterable + { + yield 'resolve_session_id' => ['resolve_session_id', 'resolveSessionId']; + yield 'identify' => ['identify', 'identify']; + yield 'redact_sensitive_information' => ['redact_sensitive_information', 'redactSensitiveInformation']; + yield 'redact_event' => ['redact_event', 'redactEvent']; + yield 'event_tags' => ['event_tags', 'eventTags']; + yield 'event_properties' => ['event_properties', 'eventProperties']; + } + + #[DataProvider('hooks')] + public function testEveryHookTakesAClassNameAndGetsItsArguments(string $key, string $option): void + { + $options = ConfigOptions::options([$key => ArgumentsHook::class], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $hook = get_object_vars($options)[$option] ?? null; + $this->assertInstanceOf(\Closure::class, $hook); + $this->assertSame(['first', 2], $hook('first', 2)); + $this->assertSame([], $this->logger->messages()); + } + + public function testACallableHookIsKeptAsItIs(): void + { + $identify = static fn(ToolCall $call): Identity => new Identity('closure'); + + $options = ConfigOptions::options(['identify' => $identify, 'redact_sensitive_information' => 'strtoupper'], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertSame($identify, $options->identify); + $redact = $options->redactSensitiveInformation; + $this->assertInstanceOf(\Closure::class, $redact); + $this->assertSame('SECRET', $redact('secret')); // a function name is no class to resolve + } + + public function testAHookThatIsNeitherANameNorACallableIsTheDefault(): void + { + $options = ConfigOptions::options(['identify' => 7], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertNull($options->identify); + $this->assertSame(['WARN: AgentCat option "identify" must be callable or null, got int; the default is used'], $this->logger->messages()); + } + + public function testAHookWhoseClassIsNotInvokableThrowsWhenItRuns(): void + { + $options = ConfigOptions::options(['identify' => \stdClass::class], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $hook = $options->identify; + $this->assertInstanceOf(\Closure::class, $hook); + $error = Thrown::by(\UnexpectedValueException::class, static fn(): mixed => $hook(new ToolCall('echo', []))); + $this->assertSame('stdClass is not invokable', $error->getMessage()); + } + + public function testNullIsTheDefault(): void + { + $options = ConfigOptions::options(['enable_tracing' => null, 'identify' => null, 'exporters' => null, 'api_url' => null], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertTrue($options->enableTracing); + $this->assertNull($options->identify); + $this->assertNull($options->exporters); + $this->assertNull($options->apiBaseUrl); + $this->assertSame([], $this->logger->messages()); // env() of an unset variable is null: no line + } + + public function testTheBridgeKeysAreNotOptions(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + ConfigOptions::options(['enabled' => true, 'project_id' => 'x', 'exclude' => [], 'projects' => [], 'log_channel' => null], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertSame([], $logger->messages()); + } + + public function testBadValuesLogOneLineEachAndNeverThrow(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $options = ConfigOptions::options([ + 'enable_tracing' => 'yes', + 'identify' => 'App\Missing\Identify', + 'exporters' => ['otlp' => null, 'dd' => ['type' => 'datadog']], + 'unknown_key' => 1, + ], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertTrue($options->enableTracing); + $this->assertSame([], $options->exporters); + $this->assertSame([ + 'WARN: Unknown AgentCat option "agentcat.unknown_key" ignored', + 'WARN: AgentCat option "enableTracing" must be bool, got string; the default is used', + 'Failed to initialize exporter dd: InvalidArgumentException: exporter option "apiKey" must be string, got null', + ], $logger->messages()); + $this->assertInstanceOf(\Closure::class, $options->identify); // fails when it runs, inside the hook guard (D32) + } + + public function testAnUnknownKeyLeavesTheKeysAfterIt(): void + { + $options = ConfigOptions::options([0 => 'stray', 'enable_tracing' => false], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertFalse($options->enableTracing); + $this->assertSame(['WARN: Unknown AgentCat option "agentcat.0" ignored'], $this->logger->messages()); + } +} diff --git a/tests/Unit/Internal/Support/EnabledGateTest.php b/tests/Unit/Internal/Support/EnabledGateTest.php new file mode 100644 index 0000000..e3542d7 --- /dev/null +++ b/tests/Unit/Internal/Support/EnabledGateTest.php @@ -0,0 +1,105 @@ +environment = Environment::clear('APP_ENV', 'AGENTCAT_ENABLED'); + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + TestMode::reset(); + Log::reset(); + $this->environment->restore(); + } + + public function testTheConfigWordsDecide(): void + { + $this->assertTrue((EnabledGate::of('true'))()); + $this->assertTrue((EnabledGate::of(true))()); + $this->assertFalse((EnabledGate::of('off'))()); + $this->assertFalse((EnabledGate::of(false))()); + $this->assertTrue((EnabledGate::of(' YES '))()); + $this->assertTrue((EnabledGate::of(1))()); + $this->assertFalse((EnabledGate::of(0))()); + $this->assertSame([], $this->logger->messages()); + } + + public function testUnsetFallsBackToTheTestModeGate(): void + { + $this->assertFalse((EnabledGate::of(null))()); // PHPUnit is loaded (D31) + $this->assertFalse((EnabledGate::of(''))()); + $this->assertFalse((EnabledGate::of(' '))()); + $this->assertSame([], $this->logger->messages()); + } + + public function testUnsetReadsTheEnvironmentWhenTheGateRuns(): void + { + $gate = EnabledGate::of(null); + putenv('AGENTCAT_ENABLED=true'); + + $this->assertTrue($gate()); // TestMode::sdkEnabled(): AGENTCAT_ENABLED of the process (D44) + } + + public function testAnUnknownWordLogsOnceAndUsesTheDefault(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $gate = EnabledGate::of('maybe'); + + $this->assertFalse($gate()); + $this->assertFalse($gate()); + $this->assertSame(['WARN: AgentCat config "agentcat.enabled" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used'], $logger->messages()); + } + + /** + * Review Focus 3: `AGENTCAT_ENABLED=maybe` gives `agentcat.enabled` the same word. The default is the test-mode + * default: reading the variable again would log a second line for the same value. + */ + public function testAnUnknownWordThatIsAlsoTheEnvironmentLogsOneLine(): void + { + putenv('AGENTCAT_ENABLED=maybe'); + $gate = EnabledGate::of('maybe'); + + $this->assertFalse($gate()); + $this->assertSame(['WARN: AgentCat config "agentcat.enabled" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used'], $this->logger->messages()); + } + + /** @return iterable */ + public static function unknownValues(): iterable + { + yield 'an int' => [2, '2']; + yield 'a float' => [1.5, '1.5']; + yield 'an array' => [['on'], 'array']; + yield 'an object' => [new \stdClass(), 'stdClass']; + } + + #[DataProvider('unknownValues')] + public function testAValueOfAnotherTypeIsNamedInTheLine(mixed $value, string $named): void + { + $this->assertFalse((EnabledGate::of($value))()); + $this->assertSame([sprintf('WARN: AgentCat config "agentcat.enabled" is "%s", not one of true, 1, yes, on, false, 0, no, off; the default is used', $named)], $this->logger->messages()); + } +} diff --git a/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php index f0c1475..00cc2a2 100644 --- a/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php +++ b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php @@ -112,13 +112,31 @@ public function testAMistypedFieldSkipsOnlyThatEntry(): void public function testANonArrayEntryIsLogged(): void { - $this->assertSame([], ExporterConfigs::fromArray([3 => null, 'x' => new \stdClass()])); + $this->assertSame([], ExporterConfigs::fromArray([3 => 'otlp', 'x' => new \stdClass()])); $this->assertSame([ - 'Failed to initialize exporter 3: InvalidArgumentException: exporter configuration must be an array, got null', + 'Failed to initialize exporter 3: InvalidArgumentException: exporter configuration must be an array, got string', 'Failed to initialize exporter x: InvalidArgumentException: exporter configuration must be an array, got stdClass', ], $this->logger->messages()); } + /** A config file switches an exporter off with null (plan E1 header conflict 17). */ + public function testANullEntryIsSkippedWithoutALine(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $this->assertSame([], ExporterConfigs::fromArray(['otlp' => null])); + $this->assertSame([], $logger->messages()); + } + + public function testANullEntryLeavesTheOthers(): void + { + $configs = ExporterConfigs::fromArray(['off' => null, 'on' => ['type' => 'otlp', 'endpoint' => 'e']]); + + $this->assertEquals(['on' => new OtlpExporter('e')], $configs); + $this->assertSame([], $this->logger->messages()); + } + public function testAnUnknownKeyIsWarnedAboutAndTheEntryIsKept(): void { $configs = ExporterConfigs::fromArray([ @@ -133,9 +151,19 @@ public function testAnUnknownKeyIsWarnedAboutAndTheEntryIsKept(): void ], $this->logger->messages()); } - public function testAHeaderWithANonStringValueIsRejected(): void + public function testAHeaderThatIsNotAStringIsNamed(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + ExporterConfigs::fromArray(['otlp' => ['type' => 'otlp', 'endpoint' => 'http://127.0.0.1:1', 'headers' => ['Authorization' => null]]]); + + $this->assertSame(['Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers.Authorization" must be string, got null'], $logger->messages()); + } + + public function testTheFirstHeaderThatIsNotAStringIsNamed(): void { - $this->assertSame([], ExporterConfigs::fromArray(['a' => ['type' => 'otlp', 'endpoint' => 'e', 'headers' => ['x' => 'y', 'z' => null]]])); - $this->assertSame(['Failed to initialize exporter a: InvalidArgumentException: exporter option "headers" must be array, got array'], $this->logger->messages()); + $this->assertSame([], ExporterConfigs::fromArray(['a' => ['type' => 'otlp', 'endpoint' => 'e', 'headers' => ['x' => 'y', 'z' => 1, 'w' => null]]])); + $this->assertSame(['Failed to initialize exporter a: InvalidArgumentException: exporter option "headers.z" must be string, got int'], $this->logger->messages()); } } diff --git a/tests/Unit/Laravel/ConfigFileTest.php b/tests/Unit/Laravel/ConfigFileTest.php new file mode 100644 index 0000000..26de004 --- /dev/null +++ b/tests/Unit/Laravel/ConfigFileTest.php @@ -0,0 +1,32 @@ +assertIsArray($config); + array_walk_recursive($config, function (mixed $value): void { + $this->assertNotInstanceOf(\Closure::class, $value); // config:cache + }); + $this->assertSame(['Laravel\Boost\Mcp\Boost'], $config['exclude'] ?? null); + $this->assertSame([], array_diff(array_keys($config), [...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)])); + } + + public function testTheConfigFileListsEveryKey(): void + { + $config = require dirname(__DIR__, 3) . '/config/agentcat.php'; + + $this->assertIsArray($config); + $this->assertEqualsCanonicalizing([...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)], array_keys($config)); + } +} diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index d326403..d5cd9d8 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -254,7 +254,7 @@ public function testFromArraySkipsAnExporterWithMissingOrMistypedValues(): void 'Failed to initialize exporter dd: InvalidArgumentException: exporter option "apiKey" must be string, got null', 'Failed to initialize exporter sentry: InvalidArgumentException: exporter option "enableTracing" must be bool, got string', 'WARN: Unknown AgentCat option "exporters.otlp.timeout" ignored', - 'Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers" must be array, got array', + 'Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers.x" must be string, got int', 'WARN: Unknown AgentCat option "exporters.ph.hots" ignored', 'Failed to initialize exporter str: InvalidArgumentException: exporter configuration must be an array, got string', ], $this->logger->messages()); From b3ea4bff20a0b883e5c5f7ea567436e633ed69cc Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 21:39:05 +0000 Subject: [PATCH 13/30] fix: let the cached Laravel configuration decide the project and the switch of every client A null projects entry falls back to agentcat.project_id, and agentcat.enabled sets a process-wide switch that TestMode::sdkEnabled() reads before AGENTCAT_ENABLED, so the shared client honors the configuration under config:cache; a fake still records. The tracker asks Laravel for its log channel only while no logger is chosen, and an exporters map of null entries counts as no exporters. --- config/agentcat.php | 2 +- src/AgentCat.php | 2 +- src/Client.php | 2 +- src/Internal/Logging/Log.php | 6 + src/Internal/Support/TestMode.php | 30 +++- src/Laravel/AgentCatServiceProvider.php | 4 + src/Laravel/Internal/ServerTracker.php | 16 +- src/Options.php | 6 +- .../Laravel/CachedConfigurationTest.php | 131 +++++++++++++++ .../Laravel/ServiceProviderTest.php | 158 ++++++++++-------- tests/Support/Laravel/BridgeTestCase.php | 100 +++++++++++ tests/Support/LaravelMcp/LaravelTestCase.php | 2 + tests/Unit/Internal/Logging/LogTest.php | 32 ++++ tests/Unit/Internal/Support/TestModeTest.php | 35 ++++ tests/Unit/OptionsTest.php | 9 + 15 files changed, 443 insertions(+), 92 deletions(-) create mode 100644 tests/Integration/Laravel/CachedConfigurationTest.php create mode 100644 tests/Support/Laravel/BridgeTestCase.php diff --git a/config/agentcat.php b/config/agentcat.php index 97fd75a..d980b81 100644 --- a/config/agentcat.php +++ b/config/agentcat.php @@ -6,7 +6,7 @@ // Publish this file with: php artisan vendor:publish --tag=agentcat-config return [ - // On or off for every MCP server: true or false. Unset: on, except while your tests run. + // On or off for every MCP server and every AgentCat call: true or false. Unset: on, except while your tests run. 'enabled' => env('AGENTCAT_ENABLED'), // Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run. diff --git a/src/AgentCat.php b/src/AgentCat.php index 3cefd4a..f354e75 100644 --- a/src/AgentCat.php +++ b/src/AgentCat.php @@ -30,7 +30,7 @@ private function __construct() {} * * `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with * `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV` - * `testing` or `test`). + * `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable. * * @template T of object * diff --git a/src/Client.php b/src/Client.php index f8674ac..dafcbc4 100644 --- a/src/Client.php +++ b/src/Client.php @@ -67,7 +67,7 @@ public function __construct(?ClientSeams $seams = null) * * `AGENTCAT_ENABLED` turns AgentCat off everywhere with `false`, `0`, `no` or `off`, and on everywhere with * `true`, `1`, `yes` or `on`. Otherwise AgentCat does nothing in test environments (PHPUnit, Pest, `APP_ENV` - * `testing` or `test`). + * `testing` or `test`). In a Laravel application, `enabled` in config/agentcat.php decides before the variable. * * @template T of object * diff --git a/src/Internal/Logging/Log.php b/src/Internal/Logging/Log.php index a6f9be2..1284631 100644 --- a/src/Internal/Logging/Log.php +++ b/src/Internal/Logging/Log.php @@ -41,6 +41,12 @@ public static function adopt(LoggerInterface $logger): void self::$chosen = true; } + /** True when `setLogger()`, `adopt()` or `divert()` chose the logger; the lazy file fallback does not count. */ + public static function hasLogger(): bool + { + return self::$chosen; + } + /** * Sends every later line to `$logger` only, until `endDiversion()`; `adopt()` changes nothing meanwhile. A second * `divert()` changes the target and keeps the routing from before the first one. `AgentCat::fake()` uses it. diff --git a/src/Internal/Support/TestMode.php b/src/Internal/Support/TestMode.php index bc538df..baeb798 100644 --- a/src/Internal/Support/TestMode.php +++ b/src/Internal/Support/TestMode.php @@ -7,9 +7,9 @@ use AgentCat\Internal\Logging\Log; /** - * When AgentCat instruments and delivers. `AGENTCAT_ENABLED` decides first (D44): a false value turns AgentCat off - * everywhere, a true value turns it on everywhere. Otherwise AgentCat is off while a test framework runs (D31) and - * on everywhere else. + * When AgentCat instruments and delivers. A framework bridge's configured switch decides first (plan E1 header conflict + * 12), then `AGENTCAT_ENABLED` (D44): a false value turns AgentCat off everywhere, a true value turns it on everywhere. + * Otherwise AgentCat is off while a test framework runs (D31) and on everywhere else. * * @internal */ @@ -26,6 +26,19 @@ final class TestMode /** True once an unknown `AGENTCAT_ENABLED` value was logged: the line appears once per process. */ private static bool $reported = false; + /** The configured switch of a framework bridge; null: none, `AGENTCAT_ENABLED` decides. */ + private static ?bool $configured = null; + + /** + * Sets the switch of a framework bridge's configuration (`agentcat.enabled` in Laravel), which decides before + * `AGENTCAT_ENABLED`: a cached configuration hides the `.env` file from `Env::get()`. Null removes it. The last call + * wins; it is process-wide, like the environment. + */ + public static function configure(?bool $enabled): void + { + self::$configured = $enabled; + } + /** * True when a test runner class is loaded, or `APP_ENV` is `testing` or `test` (trimmed, any case). * @@ -47,15 +60,15 @@ public static function inTestEnvironment(?\Closure $classLoaded = null): bool } /** - * True when `AGENTCAT_ENABLED` is `true`, `1`, `yes` or `on`; false when it is `false`, `0`, `no` or `off` - * (trimmed, any case). Unset or empty: true outside test environments only. Any other value counts as unset and - * logs one line, once per process. + * The configured switch when a bridge set one (`configure()`). Else true when `AGENTCAT_ENABLED` is `true`, `1`, + * `yes` or `on`; false when it is `false`, `0`, `no` or `off` (trimmed, any case). Unset or empty: true outside test + * environments only. Any other value counts as unset and logs one line, once per process. * * @param (\Closure(string): bool)|null $classLoaded see `inTestEnvironment()` */ public static function sdkEnabled(?\Closure $classLoaded = null): bool { - return self::word(Env::get('AGENTCAT_ENABLED') ?? '') ?? self::byDefault(self::normalized('AGENTCAT_ENABLED'), $classLoaded); + return self::$configured ?? self::word(Env::get('AGENTCAT_ENABLED') ?? '') ?? self::byDefault(self::normalized('AGENTCAT_ENABLED'), $classLoaded); } /** @@ -84,10 +97,11 @@ public static function isLoaded(string $class): bool return class_exists($class, false); } - /** Tests only. */ + /** Tests only: forgets the logged line and the configured switch. */ public static function reset(): void { self::$reported = false; + self::$configured = null; } /** @param (\Closure(string): bool)|null $classLoaded */ diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index cb55c65..995fb4e 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -6,6 +6,7 @@ use AgentCat\AgentCat; use AgentCat\Client; +use AgentCat\Internal\Support\TestMode; use AgentCat\Laravel\Internal\ServerTracker; use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; @@ -30,6 +31,9 @@ public function register(): void public function boot(): void { $this->publishes([self::CONFIG => $this->app->configPath('agentcat.php')], 'agentcat-config'); + // `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. + TestMode::configure(TestMode::word($this->app->make('config')->get('agentcat.enabled'))); 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 { diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index 9d8c6a4..a64de51 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -70,9 +70,12 @@ private function project(Server $server, array $config): ?string { $projects = is_array($config['projects'] ?? null) ? $config['projects'] : []; $class = $this->matches($server, array_keys($projects)); - $project = $class !== null ? ($projects[$class] ?? null) : ($config['project_id'] ?? null); + // A null entry (env() of an unset variable) is the configured project: under config:cache the process does + // not hold .env, so AGENTCAT_PROJECT_ID is read only when the configuration has no project either (D36). + $project = $class !== null ? ($projects[$class] ?? null) : null; + $project ??= $config['project_id'] ?? null; - return is_string($project) ? $project : null; // null reads AGENTCAT_PROJECT_ID (D36) + return is_string($project) ? $project : null; } /** The first listed class the server is an instance of; a class also matches its subclasses. */ @@ -89,11 +92,14 @@ private function matches(Server $server, mixed $classes): ?string /** * D30: AgentCat's lines go to the configured log channel, else to the default one. The first adopted logger of the - * process wins, and a fake's diversion keeps priority, so a later server changes nothing; after a fake, the next - * server routes the lines again. + * process wins, and a fake's diversion keeps priority; after a fake, the next server routes the lines again. The + * channel is resolved only while no logger is chosen: Laravel does not keep the emergency logger of a channel it + * cannot create, and logs that failure each time it is asked. */ private function adoptLogger(Container $app, mixed $channel): void { - Log::adopt($app->make('log')->channel(is_string($channel) && $channel !== '' ? $channel : null)); + if (!Log::hasLogger()) { + Log::adopt($app->make('log')->channel(is_string($channel) && $channel !== '' ? $channel : null)); + } } } diff --git a/src/Options.php b/src/Options.php index 6fa1659..c1b28b0 100644 --- a/src/Options.php +++ b/src/Options.php @@ -80,7 +80,8 @@ public function __construct( * * An unknown key, or a value of the wrong type, logs one warning and the default is used. The bool options * reject an explicit null too. A callable that is not a closure is converted to one. An `exporters` entry that is - * null is skipped without a warning: a config file switches an exporter off with null. + * null is skipped without a warning: a config file switches an exporter off with null. When every entry is null, + * `exporters` is null: no exporters. * * `disableDiagnostics` also takes what an `.env` file gives: int 1 or 0, and the words of * `AGENTCAT_DISABLE_DIAGNOSTICS` (`true`, `1`, `yes`, `on` turn diagnostics off; `false`, `0`, `no`, `off` and @@ -159,7 +160,8 @@ private static function exporters(array $config): ?array return null; } if (is_array($value)) { - return ExporterConfigs::fromArray($value); + // Only null entries: a config file switched every exporter off, which is no exporters at all. + return $value !== [] && array_filter($value, static fn(mixed $entry): bool => $entry !== null) === [] ? null : ExporterConfigs::fromArray($value); } self::reject('exporters', 'array or null', $value); diff --git a/tests/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php new file mode 100644 index 0000000..29e4bf7 --- /dev/null +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -0,0 +1,131 @@ +logger = new MemoryLogger()); // nothing goes to agentcat.log in the test HOME + } + + /** `.env` sets the project; the published file maps the server to `env('AGENTCAT_BILLING_PROJECT_ID')`, which it does not set. */ + public function defineANullProjectEntry(): void + { + self::cachedConfiguration(['AGENTCAT_ENABLED' => 'true', 'AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT], ['projects' => [ConformanceServer::class => null]]); + } + + /** + * `.env` turns AgentCat off. PHPUnit makes AgentCat off by default, so the process turns it on, as a variable of the + * platform can: the cached value must decide first (the D44 kill switch). + */ + public function defineCachedOff(): void + { + self::cachedConfiguration(['AGENTCAT_ENABLED' => 'false', 'AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT]); + putenv('AGENTCAT_ENABLED=true'); + } + + public function defineCachedOn(): void + { + self::cachedConfiguration(['AGENTCAT_ENABLED' => 'true', 'AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT]); + } + + /** `.env` does not set `AGENTCAT_ENABLED`; the process turns AgentCat on. */ + public function defineCachedUnset(): void + { + self::cachedConfiguration(['AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT]); + putenv('AGENTCAT_ENABLED=true'); + } + + /** `.env` holds a word that is not a D44 word; the process turns AgentCat on. */ + public function defineCachedUnknown(): void + { + self::cachedConfiguration(['AGENTCAT_ENABLED' => 'maybe', 'AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT]); + putenv('AGENTCAT_ENABLED=true'); + } + + #[DefineEnvironment('defineANullProjectEntry')] + public function testANullProjectEntryGoesToTheCachedProjectId(): void + { + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(LaravelTrackingFixture::PROJECT, $this->deliveredField('project_id')); + } + + #[DefineEnvironment('defineCachedOff')] + public function testACachedFalseStopsTheSharedClient(): void + { + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + Container::getInstance()->make(Client::class)->publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + + $this->assertSame([], $this->deliveries()); + $this->assertSame([], $this->logger->messages()); + $this->assertIsArray($tools); + $this->assertSame('own', JsonAssert::at(end($tools), 'name')); // the server is not tracked either + } + + #[DefineEnvironment('defineCachedOn')] + public function testACachedTrueTurnsTheSharedClientOnInATestEnvironment(): void + { + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + + $this->assertSame(self::SESSION, $this->deliveredField('session_id')); + $this->assertContains(self::PUBLISHED, $this->logger->messages()); + } + + #[DefineEnvironment('defineCachedOff')] + public function testAFakeStillRecordsWhenTheCacheSaysFalse(): void + { + $fake = AgentCat::fake(); + + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi']); + + $fake->assertCustomEventPublished(); + $fake->assertTrackedCount(1); + $this->assertSame([], $this->transport->deliveries); + } + + #[DefineEnvironment('defineCachedUnset')] + public function testWithoutACachedSwitchTheProcessDecides(): void + { + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + + $this->assertSame(self::SESSION, $this->deliveredField('session_id')); + } + + #[DefineEnvironment('defineCachedUnknown')] + public function testAnUnknownCachedWordLeavesTheProcessToDecide(): void + { + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + + $this->assertSame(self::SESSION, $this->deliveredField('session_id')); + } +} diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php index 6da95f5..eb30913 100644 --- a/tests/Integration/Laravel/ServiceProviderTest.php +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -9,21 +9,19 @@ use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; -use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Tools\GetMoreTools; -use AgentCat\Internal\Transport\Delivery; use AgentCat\Laravel\AgentCatServiceProvider; use AgentCat\Laravel\Internal\ServerTracker; use AgentCat\LaravelMcp\Internal\Access\ServerAccess; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\Laravel\BridgeTestCase; use AgentCat\Tests\Support\Laravel\InvokableIdentify; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; use AgentCat\Tests\Support\LaravelMcp\LaravelHarness; -use AgentCat\Tests\Support\LaravelMcp\LaravelTestCase; use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; +use AgentCat\Tests\Support\LaravelMcp\PagedServer; use AgentCat\Tests\Support\MemoryLogger; -use AgentCat\Tests\Support\RecordingTransport; use Illuminate\Container\Container; use Illuminate\Foundation\Application; use Illuminate\Http\Request; @@ -33,35 +31,13 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\DataProvider; -final class ServiceProviderTest extends LaravelTestCase +final class ServiceProviderTest extends BridgeTestCase { private const string OTHER_PROJECT = 'proj_ppppppppppppppppppppppppppp'; - private RecordingTransport $transport; - - private ?Environment $agentcatEnvironment = null; - /** True when a `DefineEnvironment` method of the test sets the whole configuration (it can run first). */ private bool $ownConfiguration = false; - #[\Override] - protected function setUp(): void - { - // The configuration decides, not the variables of the process that runs the tests. - $this->agentcatEnvironment = Environment::clear('AGENTCAT_ENABLED', 'AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'AGENTCAT_LOG_CHANNEL'); - TestMode::reset(); - parent::setUp(); - EventQueue::setGlobal(new EventQueue(transport: $this->transport = new RecordingTransport())); - } - - #[\Override] - protected function tearDown(): void - { - parent::tearDown(); - TestMode::reset(); - $this->agentcatEnvironment?->restore(); - } - /** @param Application $app */ #[\Override] protected function defineEnvironment(mixed $app): void @@ -86,6 +62,18 @@ public function defineBadConfiguration(): void ]); } + public function defineAgentCatOff(): void + { + $this->ownConfiguration = true; + config(['agentcat.enabled' => false, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + + public function defineEnabledUnset(): void + { + $this->ownConfiguration = true; + config(['agentcat.enabled' => null, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + /** Review Focus 3: `AGENTCAT_ENABLED=maybe`, which `env()` gives the configuration too, before the providers boot. */ public function defineAnUnknownEnabledWord(): void { @@ -94,13 +82,6 @@ public function defineAnUnknownEnabledWord(): void config(['agentcat.enabled' => 'maybe', 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); } - /** @return list */ - #[\Override] - protected function getPackageProviders(mixed $app): array - { - return [...parent::getPackageProviders($app), AgentCatServiceProvider::class]; - } - public function testEveryResolvedServerIsTrackedWithNoCode(): void { $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; @@ -157,6 +138,19 @@ public function testAServerWithoutAProjectEntryGoesToTheProjectId(): void $this->assertSame(LaravelTrackingFixture::PROJECT, $this->deliveredField('project_id')); } + /** D53: two server classes of one application send to two projects. */ + public function testTwoServerClassesSendToTwoProjects(): void + { + config(['agentcat.projects' => [PagedServer::class => self::OTHER_PROJECT]]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + (new LaravelHarness(PagedServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $events = $this->deliveries(); + $this->assertCount(2, $events); + $this->assertSame([[LaravelTrackingFixture::PROJECT, 'conformance-server'], [self::OTHER_PROJECT, 'paged-server']], array_map(static fn(mixed $event): array => [JsonAssert::at($event, 'project_id'), JsonAssert::at($event, 'server_name')], $events)); + } + public function testTheLogLinesGoToTheConfiguredChannel(): void { $path = $this->laravelDirectory . '/agentcat-channel.log'; @@ -186,6 +180,21 @@ public function testWithoutALogChannelNameTheLinesGoToTheDefaultChannel(mixed $c $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->defaultChannelLog()); } + /** Laravel does not keep the emergency logger of a channel it cannot create: AgentCat asks for it once. */ + public function testAMistypedLogChannelGivesOneEmergencyEntry(): void + { + config(['agentcat.log_channel' => 'agentcat-mistyped']); + $harness = new LaravelHarness(ConformanceServer::class); + + $harness->listTools(); + $harness->listTools(); + $harness->listTools(); + + $emergency = $this->laravelDirectory . '/storage/logs/laravel.log'; + $this->assertFileExists($emergency); + $this->assertSame(1, substr_count((string) file_get_contents($emergency), 'Unable to create configured logger. Using emergency logger.')); + } + public function testAfterAFakeTheNextServerRoutesTheLinesToTheChannel(): void { AgentCat::fake(); @@ -237,30 +246,24 @@ public function testAFakeRecordsTheCallsOfEveryServerWhateverTheConfigSays(): vo $this->assertSame([], $this->transport->deliveries); } - /** @return iterable}> */ - public static function untracked(): iterable + public function testAnExcludedServerKeepsItsMethodsAndItsAnswers(): void { - yield 'an excluded server' => [['agentcat.exclude' => [ConformanceServer::class]]]; - yield 'AgentCat off' => [['agentcat.enabled' => false]]; - yield 'agentcat.enabled unset in a test environment (D31)' => [['agentcat.enabled' => null]]; + config(['agentcat.exclude' => [ConformanceServer::class]]); + + $this->assertUntouched(); } - /** - * An untracked server keeps laravel/mcp's own method classes and answers exactly like a server that the container - * never built (no `afterResolving` callback, so no AgentCat at all). - * - * @param array $config - */ - #[DataProvider('untracked')] - public function testAnUntrackedServerKeepsItsMethodsAndItsAnswers(array $config): void + #[DefineEnvironment('defineAgentCatOff')] + public function testWithAgentCatOffAServerKeepsItsMethodsAndItsAnswers(): void { - config($config); - $logger = new MemoryLogger(); - Log::setLogger($logger); + $this->assertUntouched(); + } - $this->assertSame($this->answers(static fn(HttpTransport $transport): Server => new ConformanceServer($transport)), $this->answers(static fn(HttpTransport $transport): mixed => Container::getInstance()->make(ConformanceServer::class, ['transport' => $transport]))); - $this->assertSame([], $logger->messages()); - $this->assertFalse(EventQueue::global()->hasWork()); + /** D31: `agentcat.enabled` unset in a test environment. */ + #[DefineEnvironment('defineEnabledUnset')] + public function testWithoutASwitchInATestAServerKeepsItsMethodsAndItsAnswers(): void + { + $this->assertUntouched(); } /** Review Focus 3: boot, resolve, `tools/call` and flush with the bad values. */ @@ -277,16 +280,18 @@ public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 1, 'text')); $started = 'AgentCat setup started | project (telemetry-only) | sdk laravel-mcp/server | signals laravelMcpServer'; + $nowhere = 'Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.'; // only null exporter entries $complete = 'AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=0'; $own = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; // the fixture's own tool, for every server $this->assertSame([ 'WARN: Unknown AgentCat option "agentcat.unknown_key" ignored', // once: the options serve every server 'WARN: AgentCat option "enableTracing" must be bool, got string; the default is used', $started, - 'Initialized telemetry with 0 exporters', // the null exporter entry logs nothing + $nowhere, $complete, $own, $started, // the server of the second request + $nowhere, $complete, $own, 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)', @@ -333,15 +338,25 @@ public function testAHookComesFromTheCurrentContainerWhenItRuns(): void public function testATrackingFailureLogsOneLineAndTheServerStillAnswers(): void { - $logger = new MemoryLogger(); - Log::setLogger($logger); - $this->app?->bind('log', static fn(): never => throw new \RuntimeException('no log manager')); + // No logger is chosen, so the tracker asks Laravel for its channel, and the line goes to agentcat.log in HOME: + // a HOME of this test, which the test removes. + $home = Environment::clear('HOME'); + putenv('HOME=' . $this->laravelDirectory); + try { + $this->app?->bind('log', static fn(): never => throw new \RuntimeException('no log manager')); - $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + Log::reset(); // closes the file logger of this HOME + } finally { + $home->restore(); + } $this->assertIsArray($tools); $this->assertSame('own', JsonAssert::at(end($tools), 'name')); - $this->assertSame(['Warning: Failed to track server - RuntimeException: no log manager'], $logger->messages()); + $lines = file($this->laravelDirectory . '/agentcat.log', FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + $this->assertCount(1, $lines); + $this->assertStringEndsWith(' Warning: Failed to track server - RuntimeException: no log manager', $lines[0]); } public function testTheConfigurationIsPublishedUnderItsTag(): void @@ -360,23 +375,18 @@ public function testComposerNamesTheProviderForPackageDiscovery(): void $this->assertSame([AgentCatServiceProvider::class], JsonAssert::at($composer, 'extra', 'laravel', 'providers')); } - /** What the application's default log channel wrote (`LaravelTestCase`); '' before its first line. */ - private function defaultChannelLog(): string - { - $path = $this->laravelDirectory . '/laravel.log'; - - return is_file($path) ? (string) file_get_contents($path) : ''; - } - - /** A field of the one event the API got. */ - private function deliveredField(string $field): mixed + /** + * An untracked server keeps laravel/mcp's own method classes and answers exactly like a server that the container + * never built (no `afterResolving` callback, so no AgentCat at all). Nothing is logged or queued. + */ + private function assertUntouched(): void { - $this->assertTrue(EventQueue::global()->flush(1.0)); - $this->assertCount(1, $this->transport->deliveries); - $delivery = $this->transport->deliveries[0] ?? null; - $this->assertInstanceOf(Delivery::class, $delivery); + $logger = new MemoryLogger(); + Log::setLogger($logger); - return JsonAssert::at(Json::decode($delivery->body), $field); + $this->assertSame($this->answers(static fn(HttpTransport $transport): Server => new ConformanceServer($transport)), $this->answers(static fn(HttpTransport $transport): mixed => Container::getInstance()->make(ConformanceServer::class, ['transport' => $transport]))); + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); } /** diff --git a/tests/Support/Laravel/BridgeTestCase.php b/tests/Support/Laravel/BridgeTestCase.php new file mode 100644 index 0000000..9daafc7 --- /dev/null +++ b/tests/Support/Laravel/BridgeTestCase.php @@ -0,0 +1,100 @@ +agentcatEnvironment = Environment::clear(...self::VARIABLES); + TestMode::reset(); // an earlier test can have logged the once-per-process line of AGENTCAT_ENABLED + parent::setUp(); + EventQueue::setGlobal(new EventQueue(transport: $this->transport = new RecordingTransport())); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->agentcatEnvironment?->restore(); + } + + /** @return list */ + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), AgentCatServiceProvider::class]; + } + + /** + * What `php artisan config:cache` stores for `agentcat`: config/agentcat.php evaluated with these `.env` values, + * then the edits of a published file. The process keeps none of the values: with a cached configuration, Laravel + * does not load `.env`. Call it before the providers boot (a `DefineEnvironment` method). + * + * @param array $dotEnv + * @param array $edits + */ + protected static function cachedConfiguration(array $dotEnv, array $edits = []): void + { + $loaded = Environment::clear(...array_keys($dotEnv)); + foreach ($dotEnv as $name => $value) { + putenv($name . '=' . $value); + } + $cached = require dirname(__DIR__, 3) . '/config/agentcat.php'; + $loaded->restore(); + config(['agentcat' => array_replace(is_array($cached) ? $cached : [], $edits)]); + } + + /** + * Every event the API got, decoded, after a flush. + * + * @return list + */ + protected function deliveries(): array + { + $this->assertTrue(EventQueue::global()->flush(1.0)); + + return array_map(static fn(Delivery $delivery): mixed => Json::decode($delivery->body), $this->transport->deliveries); + } + + /** A field of the one event the API got. */ + protected function deliveredField(string $field): mixed + { + $deliveries = $this->deliveries(); + $this->assertCount(1, $deliveries); + + return JsonAssert::at($deliveries[0], $field); + } + + /** What the application's default log channel wrote (`LaravelTestCase`); '' before its first line. */ + protected function defaultChannelLog(): string + { + $path = $this->laravelDirectory . '/laravel.log'; + + return is_file($path) ? (string) file_get_contents($path) : ''; + } +} diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 1438be6..5ae5d78 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; use Illuminate\Foundation\Application; @@ -89,6 +90,7 @@ protected function defineEnvironment(mixed $app): void protected function tearDown(): void { ToolCalls::reset(); + TestMode::reset(); // also the configured switch that AgentCat's service provider sets Log::reset(); Diagnostics::reset(); EventQueue::setGlobal(null); diff --git a/tests/Unit/Internal/Logging/LogTest.php b/tests/Unit/Internal/Logging/LogTest.php index 679ce86..92ad174 100644 --- a/tests/Unit/Internal/Logging/LogTest.php +++ b/tests/Unit/Internal/Logging/LogTest.php @@ -7,6 +7,8 @@ use AgentCat\Internal\Logging\FileLogger; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Logging\LogLine; +use AgentCat\Tests\Support\Environment; +use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\MemoryLogger; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -166,6 +168,36 @@ public function testResetForgetsTheAdoptedLogger(): void $this->assertSame(['hello'], $second->messages()); } + public function testHasLoggerTellsWhetherALoggerWasChosen(): void + { + $this->assertFalse(Log::hasLogger()); + $directory = getenv('HOME') . '/logtest-' . bin2hex(random_bytes(6)); // under the test HOME + mkdir($directory); + $home = Environment::clear('HOME'); + putenv('HOME=' . $directory); + try { + Log::write('to the file fallback'); + $this->assertFalse(Log::hasLogger()); // the fallback does not count + $this->assertFileExists($directory . '/agentcat.log'); + Log::reset(); + } finally { + $home->restore(); + FileTree::remove($directory); + } + Log::adopt(new MemoryLogger()); + $this->assertTrue(Log::hasLogger()); + Log::reset(); + $this->assertFalse(Log::hasLogger()); + Log::setLogger(new MemoryLogger()); + $this->assertTrue(Log::hasLogger()); + Log::setLogger(null); + $this->assertFalse(Log::hasLogger()); + Log::divert(new MemoryLogger()); + $this->assertTrue(Log::hasLogger()); + Log::endDiversion(); + $this->assertFalse(Log::hasLogger()); + } + public function testADiversionTakesEveryLineAndBlocksAdoption(): void { $set = new MemoryLogger(); diff --git a/tests/Unit/Internal/Support/TestModeTest.php b/tests/Unit/Internal/Support/TestModeTest.php index 9a37538..ba2301c 100644 --- a/tests/Unit/Internal/Support/TestModeTest.php +++ b/tests/Unit/Internal/Support/TestModeTest.php @@ -199,6 +199,41 @@ public function testTheEnabledWords(mixed $value, ?bool $expected): void $this->assertSame($expected, TestMode::word($value)); } + /** Plan E1 header conflict 12: a framework bridge's configuration decides before the environment. */ + public function testAConfiguredSwitchDecidesBeforeTheEnvironment(): void + { + putenv('AGENTCAT_ENABLED=true'); + TestMode::configure(false); + $this->assertFalse(TestMode::sdkEnabled()); + + putenv('AGENTCAT_ENABLED=off'); + TestMode::configure(true); + $this->assertTrue(TestMode::sdkEnabled()); + + putenv('AGENTCAT_ENABLED=maybe'); + $this->assertTrue(TestMode::sdkEnabled()); + $this->assertSame([], $this->logger->messages()); // the variable is not read + } + + public function testNoConfiguredSwitchLeavesTheEnvironmentToDecide(): void + { + putenv('AGENTCAT_ENABLED=true'); + TestMode::configure(false); + + TestMode::configure(null); + + $this->assertTrue(TestMode::sdkEnabled()); + } + + public function testResetForgetsTheConfiguredSwitch(): void + { + TestMode::configure(true); + + TestMode::reset(); + + $this->assertFalse(TestMode::sdkEnabled()); // PHPUnit is loaded (D31) + } + public function testResetReportsAnOtherValueAgain(): void { putenv('AGENTCAT_ENABLED=maybe'); diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index d5cd9d8..0e6e7e1 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -260,6 +260,15 @@ public function testFromArraySkipsAnExporterWithMissingOrMistypedValues(): void ], $this->logger->messages()); } + /** Plan E1 header conflict 17: a config file switches every exporter off with null entries. */ + public function testFromArrayCountsAMapOfNullExportersAsNoExporters(): void + { + $this->assertNull(Options::fromArray(['exporters' => ['otlp' => null, 'dd' => null]])->exporters); + $this->assertSame([], Options::fromArray(['exporters' => []])->exporters); + $this->assertSame([], Options::fromArray(['exporters' => ['otlp' => null, 'dd' => ['type' => 'datadog']]])->exporters); + $this->assertSame(['Failed to initialize exporter dd: InvalidArgumentException: exporter option "apiKey" must be string, got null'], $this->logger->messages()); + } + public function testFromArrayRejectsWrongTelemetryOptionTypes(): void { $options = Options::fromArray(['exporters' => 'otlp', 'disableSignalHandlers' => 1]); From 47d697b978bc2b9412af31abe2fab6f449355afa Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 22:11:34 +0000 Subject: [PATCH 14/30] feat: wait at most 1 s for delivery after each Laravel worker request --- src/Internal/Delivery/RequestEndWait.php | 44 ++++ src/Laravel/AgentCatServiceProvider.php | 2 + src/Laravel/Internal/AfterRequest.php | 51 ++++ .../Laravel/CustomerTestSuiteTest.php | 96 +++++++ tests/Integration/Laravel/DeliveryTest.php | 234 ++++++++++++++++++ tests/Integration/Support/MockCollector.php | 2 + tests/Support/Laravel/HeaderIdentity.php | 17 ++ .../LaravelMcp/HeaderIdentityServer.php | 26 ++ .../Internal/Delivery/RequestEndWaitTest.php | 90 +++++++ 9 files changed, 562 insertions(+) create mode 100644 src/Internal/Delivery/RequestEndWait.php create mode 100644 src/Laravel/Internal/AfterRequest.php create mode 100644 tests/Integration/Laravel/CustomerTestSuiteTest.php create mode 100644 tests/Integration/Laravel/DeliveryTest.php create mode 100644 tests/Support/Laravel/HeaderIdentity.php create mode 100644 tests/Support/LaravelMcp/HeaderIdentityServer.php create mode 100644 tests/Unit/Internal/Delivery/RequestEndWaitTest.php diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php new file mode 100644 index 0000000..3f2964e --- /dev/null +++ b/src/Internal/Delivery/RequestEndWait.php @@ -0,0 +1,44 @@ +|null $server `$_SERVER`; null reads it + * @param (\Closure(string): (string|false))|null $getenv default `getenv(...)` + */ + public static function seconds(bool $console, string $sapi = PHP_SAPI, ?array $server = null, ?\Closure $getenv = null): float + { + if ($console) { + return 0.0; + } + $outlivesRequest = $sapi === 'cli' || $sapi === 'phpdbg' + || ($sapi === 'frankenphp' && FinishRequest::isFrankenPhpWorker($server ?? $_SERVER, $getenv)); + + return $outlivesRequest ? self::WORKER_SECONDS : 0.0; + } +} diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index 995fb4e..01300c7 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -7,6 +7,7 @@ use AgentCat\AgentCat; use AgentCat\Client; use AgentCat\Internal\Support\TestMode; +use AgentCat\Laravel\Internal\AfterRequest; use AgentCat\Laravel\Internal\ServerTracker; use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; @@ -40,5 +41,6 @@ public function boot(): void $tracker->track($server, $app); }); } + AfterRequest::register($this->app); // callbacks only: nothing runs at boot } } diff --git a/src/Laravel/Internal/AfterRequest.php b/src/Laravel/Internal/AfterRequest.php new file mode 100644 index 0000000..0b1b326 --- /dev/null +++ b/src/Laravel/Internal/AfterRequest.php @@ -0,0 +1,51 @@ +terminating(static function (Application $current): void { + self::guarded(static function () use ($current): void { + AgentCat::client()->flush(RequestEndWait::seconds($current->runningInConsole())); + }); + }); + $app->make('events')->listen(self::OCTANE_WORKER_STOPPING, static function (): void { + self::guarded(static function (): void { + AgentCat::client()->shutdown(); + }); + }); + } + + /** + * Delivery never breaks the application's terminate phase, and a stopping worker must stop (D32). + * + * @param \Closure(): void $step + */ + private static function guarded(\Closure $step): void + { + try { + $step(); + } catch (\Throwable) { + // nothing to do: the application goes on + } + } +} diff --git a/tests/Integration/Laravel/CustomerTestSuiteTest.php b/tests/Integration/Laravel/CustomerTestSuiteTest.php new file mode 100644 index 0000000..6fc6423 --- /dev/null +++ b/tests/Integration/Laravel/CustomerTestSuiteTest.php @@ -0,0 +1,96 @@ + array_column($this->items, 'name')); + } + + #[\Override] + protected function tearDown(): void + { + TestListResponse::flushMacros(); + parent::tearDown(); + } + + public function testWithoutAFakeTheHelpersSeeTheLibrarysOwnAnswers(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + ConformanceServer::tool(EchoTool::class, ['msg' => 'hi'])->assertSee('echo: hi')->assertHasNoErrors(); + ConformanceServer::tool(StructuredTool::class)->assertStructuredContent(['echoed' => 'x']); + $tools = ConformanceServer::tools(); + $resolved = $this->app?->make(ConformanceServer::class, ['transport' => new FakeTransporter()]); + + $this->assertSame(self::TOOLS, $this->names($tools)); + $this->assertInstanceOf(ConformanceServer::class, $resolved); + $methods = ServerAccess::methods($resolved); // laravel/mcp's own method classes, as without AgentCat + $this->assertSame([CallTool::class, ListTools::class], [$methods['tools/call'] ?? null, $methods['tools/list'] ?? null]); + $this->assertSame(ServerAccess::methods(new ConformanceServer(new FakeTransporter())), $methods); + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); + } + + public function testAFakeRecordsTheHelpersCalls(): void + { + $fake = AgentCat::fake(); + + ConformanceServer::tool(EchoTool::class, ['msg' => 'hi', 'session_id' => 'start'])->assertSee('echo: hi'); + ConformanceServer::tool(StructuredTool::class, ['session_id' => 'start'])->assertStructuredContent(static function (AssertableJson $json): void { + $json->where('echoed', 'x')->etc(); // the helpers still read PHP arrays + }); + + $fake->assertTrackedCount(2); + $this->assertSame(['echo', 'structured'], array_map(static fn(Event $event): ?string => $event->resourceName, $fake->events())); + } + + /** D15: a tracked listing ends with AgentCat's tool; `tools()` follows `nextCursor`, so it shows one per page. */ + public function testWithAFakeTheListingEndsWithTheGetMoreToolsTool(): void + { + AgentCat::fake(); + + $this->assertSame([...self::TOOLS, GetMoreTools::NAME], $this->names(ConformanceServer::tools())); + } + + /** @return list */ + private function names(TestListResponse $tools): array + { + $names = $tools->__call('names', []); // the macro of setUp(), which static analysis cannot see + $this->assertIsArray($names); + + return array_values($names); + } +} diff --git a/tests/Integration/Laravel/DeliveryTest.php b/tests/Integration/Laravel/DeliveryTest.php new file mode 100644 index 0000000..ae7f592 --- /dev/null +++ b/tests/Integration/Laravel/DeliveryTest.php @@ -0,0 +1,234 @@ +terminate()` after it, as a server does after it sent the response. + */ +final class DeliveryTest extends BridgeTestCase +{ + private const string OCTANE_WORKER_STOPPING = 'Laravel\Octane\Events\WorkerStopping'; + + private static ?MockCollector $collector = null; + + private ?Environment $console = null; + + #[\Override] + public static function setUpBeforeClass(): void + { + self::$collector = MockCollector::start(); + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$collector?->stop(); + self::$collector = null; + } + + /** Octane's worker bootstrap sets APP_RUNNING_IN_CONSOLE=false before it creates the application. */ + #[\Override] + protected function setUp(): void + { + $this->console = Environment::clear('APP_RUNNING_IN_CONSOLE'); + putenv('APP_RUNNING_IN_CONSOLE=false'); + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + parent::setUp(); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->console?->restore(); + } + + /** + * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. + * + * @param Application $app + */ + #[\Override] + protected function defineEnvironment(mixed $app): void + { + parent::defineEnvironment($app); + config(['agentcat.enabled' => true, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT, 'agentcat.api_url' => MockCollector::closedPortUrl()]); + } + + /** @param Router $router */ + #[\Override] + protected function defineRoutes(mixed $router): void + { + Mcp::web('/mcp/test', HeaderIdentityServer::class); + } + + public function testTheApplicationRunsOutsideTheConsole(): void + { + $this->assertFalse($this->application()->runningInConsole()); + } + + #[Group('ac-03-12')] + #[Group('ac-02-48')] + public function testTheTerminatingCallbackDeliversTheRequestsEvents(): void + { + $response = $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => 'hi', 'session_id' => 'start']), $this->modernHeaders('echo')); + + $response->assertOk(); + $this->assertCount(1, $this->transport->deliveries); // Laravel's test client runs $kernel->terminate() + } + + public function testOctanesWorkerStopEndsTheQueue(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $this->application()->make('events')->dispatch(self::OCTANE_WORKER_STOPPING); + EventQueue::global()->add(new QueuedEvent(new Event(projectId: LaravelTrackingFixture::PROJECT))); + + $this->assertContains('Queue is shutting down, event dropped', $logger->messages()); + } + + /** A failing hook never stops Laravel's terminate phase: the callbacks after it still run (D32). */ + public function testAFailingHookLetsTheLaterTerminatingCallbacksRun(): void + { + $application = Container::getInstance(); + try { + $broken = new class extends Application { + public bool $failing = false; + + #[\Override] + public function runningInConsole(): bool + { + return $this->failing ? throw new \RuntimeException('no environment') : parent::runningInConsole(); + } + }; + } finally { + Container::setInstance($application); // a new application makes itself the global container + } + $broken->failing = true; + $later = new \ArrayObject(); + AfterRequest::register($broken); + $broken->terminating(static function () use ($later): void { + $later->append('ran'); + }); + + $broken->terminate(); + + $this->assertSame(['ran'], $later->getArrayCopy()); + } + + /** Review Focus 4: three requests in one application container, each with its own server, headers and identity. */ + public function testRequestsInOneContainerKeepTheirOwnContext(): void + { + config(['agentcat.identify' => HeaderIdentity::class]); + + foreach (['ann', 'bob', 'cid'] as $user) { + $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => $user, 'session_id' => 'start']), [...$this->modernHeaders('echo'), 'X-User' => $user])->assertOk(); + } + + $bodies = array_map(static fn(Delivery $d): mixed => Json::decode($d->body), $this->transport->deliveries); + $this->assertSame(['ann', 'bob', 'cid'], array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'identify_actor_given_id'), $bodies)); + $this->assertSame(['ann', 'bob', 'cid'], array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'parameters', 'extra', 'requestInfo', 'headers', 'x-user'), $bodies)); + } + + /** Review Focus 4: an AgentCat API that does not answer holds each request for the cap, not for the 5 s budget. */ + #[Group('ac-03-1')] + public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void + { + $this->transport->complete = false; // an API that does not answer within the request; the clock is simulated + $before = $this->transport->now(); + + $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => 'one', 'session_id' => 'start']), $this->modernHeaders('echo'))->assertOk(); + + $this->assertSame(RequestEndWait::WORKER_SECONDS, $this->transport->now() - $before); // the cap (D58), not the 5 s budget + $this->assertSame(1, $this->transport->inFlight()); + $this->transport->complete = true; + + $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => 'two', 'session_id' => 'start']), $this->modernHeaders('echo'))->assertOk(); + + $this->assertSame(0, $this->transport->inFlight()); // the second request's pump() finished the first delivery + $this->assertCount(2, $this->transport->deliveries); + } + + /** + * Review Focus 4 with the real curl transport. The collector answers after 4 s: a hook that waited for the 5 s + * shutdown budget would hold the request at least that long, and the worker's stop still has time to deliver. + */ + #[MaximumDuration(12000)] + public function testWithASlowApiTheHookReturnsAfterAboutOneSecondAndTheWorkerStopDelivers(): void + { + $collector = self::$collector; + $this->assertInstanceOf(MockCollector::class, $collector); + $scenario = $collector->scenario([['status' => 202, 'delayMs' => 4000]]); + EventQueue::setGlobal(new EventQueue()); // the real curl transport, and no shutdown function in the test process + config(['agentcat.api_url' => $scenario]); + $started = microtime(true); + + $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => 'hi', 'session_id' => 'start']), $this->modernHeaders('echo'))->assertOk(); + + $elapsed = microtime(true) - $started; + $this->assertGreaterThanOrEqual(RequestEndWait::WORKER_SECONDS - 0.1, $elapsed); // it waited for the slow API ... + $this->assertLessThan(RequestEndWait::WORKER_SECONDS + 1.5, $elapsed); // ... for the cap, not for the 5 s shutdown budget + $this->assertSame([], $collector->requests($scenario)); // the collector records a request when it answers + $this->application()->make('events')->dispatch(self::OCTANE_WORKER_STOPPING); + + $this->assertCount(1, $collector->requests($scenario)); // shutdown() waited for the answer inside its 5 s + } + + /** + * A JSON-RPC `tools/call` of the 2026-07-28 era (laravel/mcp's default) with the client info in `_meta`. + * + * @param array $arguments + * + * @return array + */ + private function toolCall(string $tool, array $arguments): array + { + return ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => $tool, 'arguments' => $arguments, '_meta' => LaravelHarness::meta([], ['name' => 'modern-client', 'version' => '2.0.0'])]]; + } + + /** + * The headers that laravel/mcp's `ValidateMcpHeaders` requires of a 2026-07-28 `tools/call`. + * + * @return array + */ + private function modernHeaders(string $tool): array + { + return ['MCP-Protocol-Version' => LaravelHarness::MODERN_PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => $tool]; + } + + private function application(): Application + { + $this->assertInstanceOf(Application::class, $this->app); + + return $this->app; + } +} diff --git a/tests/Integration/Support/MockCollector.php b/tests/Integration/Support/MockCollector.php index 6f5680d..728f479 100644 --- a/tests/Integration/Support/MockCollector.php +++ b/tests/Integration/Support/MockCollector.php @@ -79,6 +79,8 @@ public function scenario(array $steps = [], array $default = ['status' => 202, ' * The requests a scenario received so far, in arrival order (`n`). * * @return list<\stdClass> each with `n`, `method`, `path`, `headers`, `body`, `start` and `end` + * + * @phpstan-impure the collector writes the log while the test runs */ public function requests(string $baseUrl): array { diff --git a/tests/Support/Laravel/HeaderIdentity.php b/tests/Support/Laravel/HeaderIdentity.php new file mode 100644 index 0000000..33ff457 --- /dev/null +++ b/tests/Support/Laravel/HeaderIdentity.php @@ -0,0 +1,17 @@ +headers['x-user'] ?? 'nobody'); + } +} diff --git a/tests/Support/LaravelMcp/HeaderIdentityServer.php b/tests/Support/LaravelMcp/HeaderIdentityServer.php new file mode 100644 index 0000000..83b3726 --- /dev/null +++ b/tests/Support/LaravelMcp/HeaderIdentityServer.php @@ -0,0 +1,26 @@ +tools = [EchoTool::class]; + } +} diff --git a/tests/Unit/Internal/Delivery/RequestEndWaitTest.php b/tests/Unit/Internal/Delivery/RequestEndWaitTest.php new file mode 100644 index 0000000..4ec0df3 --- /dev/null +++ b/tests/Unit/Internal/Delivery/RequestEndWaitTest.php @@ -0,0 +1,90 @@ +assertEqualsWithDelta(1.0, RequestEndWait::WORKER_SECONDS, PHP_FLOAT_EPSILON); // D58 + } + + /** @return iterable, float}> */ + public static function processes(): iterable + { + yield 'Octane on Swoole or RoadRunner, a Symfony Runtime worker' => [false, 'cli', [], RequestEndWait::WORKER_SECONDS]; + yield 'a worker under the phpdbg CLI' => [false, 'phpdbg', [], RequestEndWait::WORKER_SECONDS]; + yield 'FrankenPHP worker mode' => [false, 'frankenphp', ['FRANKENPHP_WORKER' => '1'], RequestEndWait::WORKER_SECONDS]; + yield 'FrankenPHP classic mode' => [false, 'frankenphp', [], 0.0]; + yield 'PHP-FPM' => [false, 'fpm-fcgi', [], 0.0]; + yield 'PHP-FPM with a stray worker variable' => [false, 'fpm-fcgi', ['FRANKENPHP_WORKER' => '1'], 0.0]; + yield 'mod_php' => [false, 'apache2handler', [], 0.0]; + yield 'php -S' => [false, 'cli-server', [], 0.0]; + yield 'a console command, artisan mcp:start included' => [true, 'cli', [], 0.0]; + yield 'a console command in a FrankenPHP worker' => [true, 'frankenphp', ['FRANKENPHP_WORKER' => '1'], 0.0]; + } + + /** @param array $server */ + #[DataProvider('processes')] + public function testOnlyAProcessThatOutlivesTheRequestWaits(bool $console, string $sapi, array $server, float $expected): void + { + $this->assertSame($expected, RequestEndWait::seconds($console, $sapi, $server, static fn(string $name): false => false)); + } + + public function testFrankenPhpWorkerModeIsAlsoReadFromTheEnvironment(): void + { + $getenv = static fn(string $name): string|false => $name === 'FRANKENPHP_WORKER' ? '1' : false; + + $this->assertSame(RequestEndWait::WORKER_SECONDS, RequestEndWait::seconds(false, 'frankenphp', [], $getenv)); + } + + public function testTheDefaultsReadThisProcess(): void + { + $this->assertSame('cli', PHP_SAPI); + $this->assertSame(RequestEndWait::WORKER_SECONDS, RequestEndWait::seconds(false)); // the CLI SAPI, as in an Octane worker + $this->assertEqualsWithDelta(0.0, RequestEndWait::seconds(true), PHP_FLOAT_EPSILON); + } + + public function testTheServerVariablesDefaultToThoseOfTheRequest(): void + { + $saved = $_SERVER; + $noEnvironment = static fn(string $name): false => false; + + try { + unset($_SERVER['FRANKENPHP_WORKER']); + $this->assertEqualsWithDelta(0.0, RequestEndWait::seconds(false, 'frankenphp', null, $noEnvironment), PHP_FLOAT_EPSILON); + $_SERVER['FRANKENPHP_WORKER'] = '1'; + $this->assertSame(RequestEndWait::WORKER_SECONDS, RequestEndWait::seconds(false, 'frankenphp', null, $noEnvironment)); + $this->assertEqualsWithDelta(0.0, RequestEndWait::seconds(false, 'frankenphp', [], $noEnvironment), PHP_FLOAT_EPSILON); // given variables win + } finally { + $_SERVER = $saved; + } + } + + public function testTheWaitLeavesTheShutdownBudgetWhole(): void + { + $transport = new RecordingTransport(complete: false); // an API that never answers; the clock is simulated + $queue = new EventQueue(transport: $transport); + $queue->add(new QueuedEvent(new Event(projectId: 'proj_' . str_repeat('a', 27)))); + + $this->assertFalse($queue->flush(RequestEndWait::WORKER_SECONDS)); + $this->assertSame(RequestEndWait::WORKER_SECONDS, $transport->now()); + $this->assertSame(1, $transport->inFlight()); // still in delivery, not dropped + $queue->add(new QueuedEvent(new Event(projectId: 'proj_' . str_repeat('a', 27)))); // still accepting + $queue->shutdown(); + + // The worker's stop gets the one 5 s deadline, from its own start: the waits before it used none of it. + $this->assertSame(RequestEndWait::WORKER_SECONDS + EventQueue::SHUTDOWN_TIMEOUT_SECONDS, $transport->now()); + $this->assertCount(2, $transport->deliveries); // the event added after the wait went out too + } +} From 43ec64234ced3e6886edb498b1cbf83fce2e6d55 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 22:42:56 +0000 Subject: [PATCH 15/30] fix: read Laravel's runningInConsole() by its truthiness in the request-end hook --- src/Laravel/Internal/AfterRequest.php | 11 ++- tests/Integration/Laravel/DeliveryTest.php | 106 +++++++++++++++++++-- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/Laravel/Internal/AfterRequest.php b/src/Laravel/Internal/AfterRequest.php index 0b1b326..fdd75a8 100644 --- a/src/Laravel/Internal/AfterRequest.php +++ b/src/Laravel/Internal/AfterRequest.php @@ -25,7 +25,7 @@ public static function register(Application $app): void // The container calls the callback with the application of the request (Octane's sandbox): nothing is captured. $app->terminating(static function (Application $current): void { self::guarded(static function () use ($current): void { - AgentCat::client()->flush(RequestEndWait::seconds($current->runningInConsole())); + AgentCat::client()->flush(RequestEndWait::seconds(self::truthy($current->runningInConsole()))); }); }); $app->make('events')->listen(self::OCTANE_WORKER_STOPPING, static function (): void { @@ -35,6 +35,15 @@ public static function register(Application $app): void }); } + /** + * PHP's truthiness, which Laravel's own checks of `runningInConsole()` use. Its PHPDoc says bool, but it returns + * APP_RUNNING_IN_CONSOLE as `Env::get()` gives it: "0", "1" or "no" stay strings. + */ + private static function truthy(mixed $value): bool + { + return (bool) $value; + } + /** * Delivery never breaks the application's terminate phase, and a stopping worker must stop (D32). * diff --git a/tests/Integration/Laravel/DeliveryTest.php b/tests/Integration/Laravel/DeliveryTest.php index ae7f592..5f92d6f 100644 --- a/tests/Integration/Laravel/DeliveryTest.php +++ b/tests/Integration/Laravel/DeliveryTest.php @@ -10,6 +10,7 @@ use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\QueuedEvent; +use AgentCat\Internal\Tools\GetMoreTools; use AgentCat\Internal\Transport\Delivery; use AgentCat\Laravel\Internal\AfterRequest; use AgentCat\Tests\Integration\Support\MockCollector; @@ -26,6 +27,7 @@ use Illuminate\Foundation\Application; use Illuminate\Routing\Router; use Laravel\Mcp\Facades\Mcp; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -54,13 +56,15 @@ public static function tearDownAfterClass(): void self::$collector = null; } - /** Octane's worker bootstrap sets APP_RUNNING_IN_CONSOLE=false before it creates the application. */ + /** + * Laravel reads APP_RUNNING_IN_CONSOLE once, when it builds the application, so the value comes first: Octane's, + * whose worker bootstrap puts the bool false into `$_ENV`. + */ #[\Override] protected function setUp(): void { $this->console = Environment::clear('APP_RUNNING_IN_CONSOLE'); - putenv('APP_RUNNING_IN_CONSOLE=false'); - $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + $this->setRunningInConsole(false); parent::setUp(); } @@ -116,7 +120,10 @@ public function testOctanesWorkerStopEndsTheQueue(): void $this->assertContains('Queue is shutting down, event dropped', $logger->messages()); } - /** A failing hook never stops Laravel's terminate phase: the callbacks after it still run (D32). */ + /** + * A failing hook never stops Laravel's terminate phase (D32): the hook asks the application it got whether it runs + * in the console, that call throws, the exception stays in the hook, and the callbacks after it still run. + */ public function testAFailingHookLetsTheLaterTerminatingCallbacksRun(): void { $application = Container::getInstance(); @@ -124,10 +131,17 @@ public function testAFailingHookLetsTheLaterTerminatingCallbacksRun(): void $broken = new class extends Application { public bool $failing = false; + public int $thrown = 0; + #[\Override] public function runningInConsole(): bool { - return $this->failing ? throw new \RuntimeException('no environment') : parent::runningInConsole(); + if (!$this->failing) { + return parent::runningInConsole(); // the constructor asks too + } + ++$this->thrown; + + throw new \RuntimeException('no environment'); } }; } finally { @@ -139,12 +153,76 @@ public function runningInConsole(): bool $broken->terminating(static function () use ($later): void { $later->append('ran'); }); + $reached = null; - $broken->terminate(); + try { + $broken->terminate(); + } catch (\Throwable $e) { + $reached = $e; + } + $this->assertSame(1, $broken->thrown); // the hook's call threw ... + $this->assertNull($reached); // ... and the exception did not reach Laravel's terminate() $this->assertSame(['ran'], $later->getArrayCopy()); } + /** + * APP_RUNNING_IN_CONSOLE and the hook's wait. Laravel's `runningInConsole()` returns the value as `Env::get()` gives + * it, a string for "0", "1" or "no", and Laravel branches on its truthiness: so does the hook. + * + * @return iterable + */ + public static function consoleValues(): iterable + { + yield 'Octane: the bool false' => [false, RequestEndWait::WORKER_SECONDS]; + yield '"0": not the console for Laravel' => ['0', RequestEndWait::WORKER_SECONDS]; + yield '"1": a console command' => ['1', 0.0]; + yield '"no": the console for Laravel, a non-empty string' => ['no', 0.0]; + yield 'unset: the CLI SAPI makes it a console command' => [null, 0.0]; + } + + /** + * Outside the console the hook waits for the cap; in a console command it makes one pass that starts the delivery + * and does not wait, because the process ends soon and its shutdown function delivers (D47). Each case builds a + * new Laravel application, which reads the variable as it starts. + */ + #[DataProvider('consoleValues')] + public function testOnlyAnApplicationOutsideTheConsoleWaits(string|false|null $value, float $wait): void + { + $this->setRunningInConsole($value); + $application = Container::getInstance(); + try { + $started = new Application(); + } finally { + Container::setInstance($application); // a new application makes itself the global container + } + AfterRequest::register($started); + $this->transport->complete = false; // an API that does not answer; the clock is simulated + EventQueue::global()->add(new QueuedEvent(new Event(projectId: LaravelTrackingFixture::PROJECT))); + $before = $this->transport->now(); + + $started->terminate(); + + $this->assertCount(1, $this->transport->deliveries, 'the hook starts the delivery with APP_RUNNING_IN_CONSOLE ' . var_export($value, true)); + $this->assertSame($wait, $this->transport->now() - $before); + } + + /** A request that queued no event leaves the hook nothing to wait for, also in a worker and with a silent API. */ + public function testARequestThatQueuedNoEventDoesNotWait(): void + { + $this->transport->complete = false; + $before = $this->transport->now(); + $body = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => ['_meta' => LaravelHarness::meta([], ['name' => 'modern-client', 'version' => '2.0.0'])]]; + + $tools = $this->postJson('/mcp/test', $body, ['MCP-Protocol-Version' => LaravelHarness::MODERN_PROTOCOL_VERSION, 'Mcp-Method' => 'tools/list'])->assertOk()->json('result.tools'); + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); // a tracked server; a listing queues no event + $this->assertSame([], $this->transport->deliveries); + $this->assertSame(0, $this->transport->waits); + $this->assertSame($before, $this->transport->now()); + } + /** Review Focus 4: three requests in one application container, each with its own server, headers and identity. */ public function testRequestsInOneContainerKeepTheirOwnContext(): void { @@ -225,6 +303,22 @@ private function modernHeaders(string $tool): array return ['MCP-Protocol-Version' => LaravelHarness::MODERN_PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => $tool]; } + /** + * Sets APP_RUNNING_IN_CONSOLE: the bool false in `$_ENV` as Octane does, a string in the process environment, or + * null for unset. `setUp()` saved the variable, and `tearDown()` puts it back. + */ + private function setRunningInConsole(string|false|null $value): void + { + putenv('APP_RUNNING_IN_CONSOLE'); + unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + if ($value === false) { + $_ENV['APP_RUNNING_IN_CONSOLE'] = false; + } elseif (is_string($value)) { + putenv('APP_RUNNING_IN_CONSOLE=' . $value); + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = $value; + } + } + private function application(): Application { $this->assertInstanceOf(Application::class, $this->app); From 2e418edc5aa21c44761ea7b0190755c7fed0e9ff Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 23:01:26 +0000 Subject: [PATCH 16/30] feat: add agentcat:install to write the project ID into .env --- src/Laravel/AgentCatServiceProvider.php | 4 + src/Laravel/Console/InstallCommand.php | 57 +++++++ src/Laravel/Internal/EnvFile.php | 61 +++++++ .../Laravel/InstallCommandTest.php | 150 ++++++++++++++++++ tests/Unit/Laravel/EnvFileTest.php | 132 +++++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 src/Laravel/Console/InstallCommand.php create mode 100644 src/Laravel/Internal/EnvFile.php create mode 100644 tests/Integration/Laravel/InstallCommandTest.php create mode 100644 tests/Unit/Laravel/EnvFileTest.php diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index 01300c7..33c09e4 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -7,6 +7,7 @@ use AgentCat\AgentCat; use AgentCat\Client; use AgentCat\Internal\Support\TestMode; +use AgentCat\Laravel\Console\InstallCommand; use AgentCat\Laravel\Internal\AfterRequest; use AgentCat\Laravel\Internal\ServerTracker; use Illuminate\Contracts\Container\Container; @@ -32,6 +33,9 @@ public function register(): void public function boot(): void { $this->publishes([self::CONFIG => $this->app->configPath('agentcat.php')], 'agentcat-config'); + 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. TestMode::configure(TestMode::word($this->app->make('config')->get('agentcat.enabled'))); diff --git a/src/Laravel/Console/InstallCommand.php b/src/Laravel/Console/InstallCommand.php new file mode 100644 index 0000000..2f27a96 --- /dev/null +++ b/src/Laravel/Console/InstallCommand.php @@ -0,0 +1,57 @@ +signature = <<<'SIGNATURE' + agentcat:install + {project? : Your AgentCat project ID (proj_…)} + {--config : Also publish config/agentcat.php} + SIGNATURE; + $this->description = 'Set up AgentCat for this application'; + parent::__construct(); + } + + public function handle(Application $app): int + { + $project = $this->argument('project'); + $project = is_string($project) && $project !== '' ? $project : $this->ask('Your AgentCat project ID (proj_…)'); + if (!is_string($project) || preg_match('/\Aproj_[0-9A-Za-z]{27}\z/', $project) !== 1) { + $this->error('The project ID must be proj_ followed by 27 letters or digits.'); + + return self::FAILURE; + } + $env = $app->environmentFilePath(); + try { + EnvFile::set($env, 'AGENTCAT_PROJECT_ID', $project); + EnvFile::ensure($env . '.example', 'AGENTCAT_PROJECT_ID'); + } catch (\RuntimeException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + if ($this->option('config') === true) { + $this->call('vendor:publish', ['--tag' => 'agentcat-config']); + } + $this->info(sprintf('AgentCat is set up: AGENTCAT_PROJECT_ID is in %s. Every laravel/mcp server is tracked, except the excluded ones in config/agentcat.php.', $env)); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Internal/EnvFile.php b/src/Laravel/Internal/EnvFile.php new file mode 100644 index 0000000..0a23cb5 --- /dev/null +++ b/src/Laravel/Internal/EnvFile.php @@ -0,0 +1,61 @@ + $line, $contents, 1, $count); + if ($changed === null || $count === 0) { + $changed = self::append($contents, $line); + } + if ($changed === $contents) { + return; + } + if (@file_put_contents($path, $changed, LOCK_EX) === false) { + throw new \RuntimeException('Cannot write ' . $path); + } + } + + /** Appends `KEY=` when the file exists and has no `KEY=` line. Never creates the file. */ + public static function ensure(string $path, string $key): void + { + if (!is_file($path) || preg_match(self::pattern($key), self::read($path)) === 1) { + return; + } + self::set($path, $key, ''); + } + + private static function pattern(string $key): string + { + return '/^' . preg_quote($key, '/') . '=[^\r\n]*/m'; + } + + private static function read(string $path): string + { + $contents = is_file($path) ? @file_get_contents($path) : ''; + + return $contents === false ? '' : $contents; + } + + private static function append(string $contents, string $line): string + { + $eol = str_contains($contents, "\r\n") ? "\r\n" : "\n"; + + return $contents . ($contents === '' || str_ends_with($contents, "\n") ? '' : $eol) . $line . $eol; + } +} diff --git a/tests/Integration/Laravel/InstallCommandTest.php b/tests/Integration/Laravel/InstallCommandTest.php new file mode 100644 index 0000000..c099a6d --- /dev/null +++ b/tests/Integration/Laravel/InstallCommandTest.php @@ -0,0 +1,150 @@ +environment = Environment::clear('AGENTCAT_PROJECT_ID'); + parent::setUp(); + $this->dir = $this->laravelDirectory; + file_put_contents($this->dir . '/.env', "APP_NAME=Demo\n"); + file_put_contents($this->dir . '/.env.example', "APP_NAME=\n"); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->environment?->restore(); + } + + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), AgentCatServiceProvider::class]; + } + + #[MaximumDuration(500)] + public function testItWritesTheProjectIdAndNothingElse(): void + { + $this->install('agentcat:install', ['project' => self::PROJECT]) + ->expectsOutputToContain('AgentCat is set up: AGENTCAT_PROJECT_ID is in ' . $this->dir . '/.env.') + ->assertExitCode(0); + + $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=" . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); + $this->assertSame("APP_NAME=\nAGENTCAT_PROJECT_ID=\n", file_get_contents($this->dir . '/.env.example')); + $this->assertFileDoesNotExist($this->configFile()); + } + + #[MaximumDuration(500)] + public function testItReplacesAKeyThatIsAlreadyThere(): void + { + file_put_contents($this->dir . '/.env', "A=1\r\nAGENTCAT_PROJECT_ID=old\r\nB=2\r\n"); + file_put_contents($this->dir . '/.env.example', "AGENTCAT_PROJECT_ID=\n"); + + $this->install('agentcat:install', ['project' => self::PROJECT])->assertExitCode(0); + + $this->assertSame("A=1\r\nAGENTCAT_PROJECT_ID=" . self::PROJECT . "\r\nB=2\r\n", file_get_contents($this->dir . '/.env')); + $this->assertSame("AGENTCAT_PROJECT_ID=\n", file_get_contents($this->dir . '/.env.example')); + } + + #[MaximumDuration(500)] + public function testItCreatesAMissingEnvAndLeavesAMissingExampleAlone(): void + { + unlink($this->dir . '/.env'); + unlink($this->dir . '/.env.example'); + + $this->install('agentcat:install', ['project' => self::PROJECT])->assertExitCode(0); + + $this->assertSame('AGENTCAT_PROJECT_ID=' . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); + $this->assertFileDoesNotExist($this->dir . '/.env.example'); + } + + #[MaximumDuration(500)] + public function testItAsksWhenNoIdIsGivenAndRejectsABadOne(): void + { + $this->install('agentcat:install') + ->expectsQuestion('Your AgentCat project ID (proj_…)', 'nope') + ->expectsOutputToContain('The project ID must be proj_ followed by 27 letters or digits.') + ->assertExitCode(1); + + $this->assertSame("APP_NAME=Demo\n", file_get_contents($this->dir . '/.env')); + } + + #[MaximumDuration(500)] + public function testItAcceptsAnAnsweredQuestion(): void + { + $this->install('agentcat:install') + ->expectsQuestion('Your AgentCat project ID (proj_…)', self::PROJECT) + ->assertExitCode(0); + + $this->assertStringContainsString('AGENTCAT_PROJECT_ID=' . self::PROJECT . "\n", (string) file_get_contents($this->dir . '/.env')); + } + + #[MaximumDuration(500)] + public function testAnIdThatNeedsQuotingIsNeverWritten(): void + { + foreach (['proj_abcdefghijklmnopqrstuvwxyz0 #x', "proj_abcdefghijklmnopqrstuvwxyz0\n", 'proj_abcdefghijklmnopqrstuvwxy"0', 'proj_abcdefghijklmnopqrstuvwxy$1'] as $bad) { + $this->install('agentcat:install', ['project' => $bad])->assertExitCode(1); + } + + $this->assertSame("APP_NAME=Demo\n", file_get_contents($this->dir . '/.env')); + } + + #[MaximumDuration(500)] + public function testAReadOnlyEnvIsOneErrorLineAndAFailure(): void + { + chmod($this->dir . '/.env', 0o400); + + $this->install('agentcat:install', ['project' => self::PROJECT]) + ->expectsOutputToContain('Cannot write ' . $this->dir . '/.env') + ->assertExitCode(1); + + $this->assertSame("APP_NAME=Demo\n", file_get_contents($this->dir . '/.env')); + $this->assertSame("APP_NAME=\n", file_get_contents($this->dir . '/.env.example')); + } + + #[MaximumDuration(500)] + public function testTheConfigOptionPublishesTheFile(): void + { + $this->install('agentcat:install', ['project' => self::PROJECT, '--config' => true])->assertExitCode(0); + + $this->assertFileExists($this->configFile()); + $this->assertStringStartsWith($this->dir . '/config/', $this->configFile()); + } + + /** @param array $arguments */ + private function install(string $name, array $arguments = []): PendingCommand + { + $command = $this->artisan($name, $arguments); + $this->assertInstanceOf(PendingCommand::class, $command); + + return $command; + } + + private function configFile(): string + { + $this->assertInstanceOf(Application::class, $this->app); + + return $this->app->configPath('agentcat.php'); + } +} diff --git a/tests/Unit/Laravel/EnvFileTest.php b/tests/Unit/Laravel/EnvFileTest.php new file mode 100644 index 0000000..a260d47 --- /dev/null +++ b/tests/Unit/Laravel/EnvFileTest.php @@ -0,0 +1,132 @@ +dir = getenv('HOME') . '/envfile-' . bin2hex(random_bytes(6)); + mkdir($this->dir, 0o700, true); + } + + #[\Override] + protected function tearDown(): void + { + chmod($this->dir, 0o700); + FileTree::remove($this->dir); + } + + public function testSetReplacesTheLineAndKeepsTheRest(): void + { + $path = $this->file("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=old\nAPP_ENV=local\n"); + + EnvFile::set($path, 'AGENTCAT_PROJECT_ID', 'proj_new'); + + $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=proj_new\nAPP_ENV=local\n", file_get_contents($path)); + } + + public function testSetKeepsCrlfLineEndingsAndIgnoresCommentsAndLongerKeys(): void + { + $path = $this->file("#AGENTCAT_PROJECT_ID=c\r\nXAGENTCAT_PROJECT_ID=x\r\nAGENTCAT_PROJECT_ID=old\r\nAPP_ENV=local\r\n"); + + EnvFile::set($path, 'AGENTCAT_PROJECT_ID', 'proj_new'); + + $this->assertSame("#AGENTCAT_PROJECT_ID=c\r\nXAGENTCAT_PROJECT_ID=x\r\nAGENTCAT_PROJECT_ID=proj_new\r\nAPP_ENV=local\r\n", file_get_contents($path)); + } + + public function testSetReplacesOnlyTheFirstLineAndQuotesTheKey(): void + { + $path = $this->file("A.B=1\nAXB=2\nA.B=3\n"); + + EnvFile::set($path, 'A.B', 'v'); + + $this->assertSame("A.B=v\nAXB=2\nA.B=3\n", file_get_contents($path)); + } + + public function testSetAppendsOnANewLine(): void + { + $path = $this->file('APP_NAME=Demo'); + + EnvFile::set($path, 'AGENTCAT_PROJECT_ID', 'proj_new'); + + $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=proj_new\n", file_get_contents($path)); + } + + public function testSetAppendsWithCrlfWhenTheFileUsesIt(): void + { + $path = $this->file("APP_NAME=Demo\r\nAPP_ENV=local"); + + EnvFile::set($path, 'K', 'v'); + + $this->assertSame("APP_NAME=Demo\r\nAPP_ENV=local\r\nK=v\r\n", file_get_contents($path)); + } + + public function testSetCreatesAMissingFile(): void + { + $path = $this->dir . '/.env'; + + EnvFile::set($path, 'AGENTCAT_PROJECT_ID', 'proj_new'); + + $this->assertSame("AGENTCAT_PROJECT_ID=proj_new\n", file_get_contents($path)); + } + + public function testSetWritesADollarOrBackslashValueLiterally(): void + { + $path = $this->file("K=old\n"); + + EnvFile::set($path, 'K', 'a$1\1${0}'); + $this->assertSame("K=a\$1\\1\${0}\n", file_get_contents($path)); + } + + public function testSetLeavesTheFileAloneWhenTheLineIsAlreadyRight(): void + { + $path = $this->file("K=v\n"); + chmod($path, 0o400); + + EnvFile::set($path, 'K', 'v'); + + $this->assertSame("K=v\n", file_get_contents($path)); + } + + public function testSetThrowsOnAnUnwritableFile(): void + { + $path = $this->file("K=old\n"); + chmod($path, 0o400); + + $e = Thrown::by(\RuntimeException::class, static fn() => EnvFile::set($path, 'K', 'new')); + + $this->assertSame('Cannot write ' . $path, $e->getMessage()); + $this->assertSame("K=old\n", file_get_contents($path)); + } + + public function testEnsureAddsAnEmptyKeyOnceAndNeverCreatesTheFile(): void + { + $path = $this->file("APP_NAME=Demo\n"); + + EnvFile::ensure($path, 'AGENTCAT_PROJECT_ID'); + EnvFile::ensure($path, 'AGENTCAT_PROJECT_ID'); + EnvFile::ensure($path . '.missing', 'AGENTCAT_PROJECT_ID'); + + $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=\n", file_get_contents($path)); + $this->assertFileDoesNotExist($path . '.missing'); + } + + private function file(string $contents): string + { + $path = $this->dir . '/.env' . count((array) scandir($this->dir)); + file_put_contents($path, $contents); + + return $path; + } +} From e7d33635075c360d91d2ad8b8b76c97621c3499d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 23:07:37 +0000 Subject: [PATCH 17/30] fix: refuse an unreadable .env, keep export prefixes, and test console-only registration and the --config no-overwrite rule --- src/Laravel/Internal/EnvFile.php | 16 +++-- .../InstallCommandRegistrationTest.php | 67 +++++++++++++++++++ .../Laravel/InstallCommandTest.php | 27 ++++++++ tests/Unit/Laravel/EnvFileTest.php | 25 +++++++ 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/Laravel/InstallCommandRegistrationTest.php diff --git a/src/Laravel/Internal/EnvFile.php b/src/Laravel/Internal/EnvFile.php index 0a23cb5..5d4fc57 100644 --- a/src/Laravel/Internal/EnvFile.php +++ b/src/Laravel/Internal/EnvFile.php @@ -13,13 +13,13 @@ */ final class EnvFile { - /** Replaces the first `KEY=` line with `KEY=value`, or appends one. The value is written literally. */ + /** Replaces the first `KEY=` line (an `export ` prefix stays) with `KEY=value`, or appends one. The value is written literally. */ public static function set(string $path, string $key, string $value): void { $contents = self::read($path); $line = $key . '=' . $value; $count = 0; - $changed = preg_replace_callback(self::pattern($key), static fn(): string => $line, $contents, 1, $count); + $changed = preg_replace_callback(self::pattern($key), static fn(array $match): string => ($match[1] ?? '') . $line, $contents, 1, $count); if ($changed === null || $count === 0) { $changed = self::append($contents, $line); } @@ -42,14 +42,20 @@ public static function ensure(string $path, string $key): void private static function pattern(string $key): string { - return '/^' . preg_quote($key, '/') . '=[^\r\n]*/m'; + return '/^(export[ \t]+)?' . preg_quote($key, '/') . '=[^\r\n]*/m'; } private static function read(string $path): string { - $contents = is_file($path) ? @file_get_contents($path) : ''; + if (!is_file($path)) { + return ''; + } + $contents = @file_get_contents($path); + if ($contents === false) { + throw new \RuntimeException('Cannot read ' . $path); // never write over a file whose lines we cannot see + } - return $contents === false ? '' : $contents; + return $contents; } private static function append(string $contents, string $line): string diff --git a/tests/Integration/Laravel/InstallCommandRegistrationTest.php b/tests/Integration/Laravel/InstallCommandRegistrationTest.php new file mode 100644 index 0000000..c94dc04 --- /dev/null +++ b/tests/Integration/Laravel/InstallCommandRegistrationTest.php @@ -0,0 +1,67 @@ +consoleEnvironment?->restore(); + } + + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), AgentCatServiceProvider::class]; + } + + #[MaximumDuration(500)] + public function testTheConsoleKnowsTheCommand(): void + { + $this->assertContains('agentcat:install', $this->commands()); + } + + public function testAnHttpRequestDoesNotRegisterIt(): void + { + $this->consoleEnvironment = Environment::clear('APP_RUNNING_IN_CONSOLE'); + putenv('APP_RUNNING_IN_CONSOLE=false'); + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + Artisan::forgetBootstrappers(); // the console application that setUp() booted registered it + $this->refreshApplication(); + + $this->assertInstanceOf(Application::class, $this->app); + $this->assertFalse($this->app->runningInConsole()); + $this->assertNotContains('agentcat:install', $this->commands()); + } + + /** @return list */ + private function commands(): array + { + $this->assertInstanceOf(Application::class, $this->app); + + return array_map(strval(...), array_keys($this->app->make(Kernel::class)->all())); + } +} diff --git a/tests/Integration/Laravel/InstallCommandTest.php b/tests/Integration/Laravel/InstallCommandTest.php index c099a6d..4e5d71b 100644 --- a/tests/Integration/Laravel/InstallCommandTest.php +++ b/tests/Integration/Laravel/InstallCommandTest.php @@ -132,6 +132,33 @@ public function testTheConfigOptionPublishesTheFile(): void $this->assertStringStartsWith($this->dir . '/config/', $this->configFile()); } + #[MaximumDuration(500)] + public function testAnUnreadableEnvIsOneErrorLineAndNothingIsWritten(): void + { + if (posix_geteuid() === 0) { + self::markTestSkipped('File modes do not apply to root'); + } + chmod($this->dir . '/.env', 0o200); + + $this->install('agentcat:install', ['project' => self::PROJECT]) + ->expectsOutputToContain('Cannot read ' . $this->dir . '/.env') + ->assertExitCode(1); + + chmod($this->dir . '/.env', 0o600); + $this->assertSame("APP_NAME=Demo\n", file_get_contents($this->dir . '/.env')); + $this->assertSame("APP_NAME=\n", file_get_contents($this->dir . '/.env.example')); + } + + #[MaximumDuration(500)] + public function testTheConfigOptionLeavesAnExistingFileAlone(): void + { + file_put_contents($this->configFile(), "install('agentcat:install', ['project' => self::PROJECT, '--config' => true])->assertExitCode(0); + + $this->assertSame("configFile())); + } + /** @param array $arguments */ private function install(string $name, array $arguments = []): PendingCommand { diff --git a/tests/Unit/Laravel/EnvFileTest.php b/tests/Unit/Laravel/EnvFileTest.php index a260d47..bf63bbb 100644 --- a/tests/Unit/Laravel/EnvFileTest.php +++ b/tests/Unit/Laravel/EnvFileTest.php @@ -54,6 +54,31 @@ public function testSetReplacesOnlyTheFirstLineAndQuotesTheKey(): void $this->assertSame("A.B=v\nAXB=2\nA.B=3\n", file_get_contents($path)); } + public function testSetReplacesAnExportedLineAndKeepsTheExport(): void + { + $path = $this->file("A=1\nexport K=old\nB=2\n"); + + EnvFile::set($path, 'K', 'v'); + EnvFile::ensure($path, 'K'); + + $this->assertSame("A=1\nexport K=v\nB=2\n", file_get_contents($path)); + } + + public function testSetRefusesAnUnreadableFileAndWritesNothing(): void + { + if (posix_geteuid() === 0) { + self::markTestSkipped('File modes do not apply to root'); + } + $path = $this->file("A=1\nK=old\n"); + chmod($path, 0o200); + + $e = Thrown::by(\RuntimeException::class, static fn() => EnvFile::set($path, 'K', 'new')); + chmod($path, 0o600); + + $this->assertSame('Cannot read ' . $path, $e->getMessage()); + $this->assertSame("A=1\nK=old\n", file_get_contents($path)); + } + public function testSetAppendsOnANewLine(): void { $path = $this->file('APP_NAME=Demo'); From 01e757b71e534f9c7bf431065e94464e35341ef7 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 23:40:06 +0000 Subject: [PATCH 18/30] test: prove Laravel tracking end to end over HTTP and stdio --- composer-dependency-analyser.php | 4 +- tests/E2E/Laravel/artisan.php | 20 + tests/E2E/Laravel/bootstrap.php | 65 ++++ tests/E2E/Laravel/http.php | 18 + tests/E2E/LaravelEndToEndTest.php | 409 +++++++++++++++++++++ tests/Support/Laravel/SleepingIdentify.php | 27 ++ 6 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 tests/E2E/Laravel/artisan.php create mode 100644 tests/E2E/Laravel/bootstrap.php create mode 100644 tests/E2E/Laravel/http.php create mode 100644 tests/E2E/LaravelEndToEndTest.php create mode 100644 tests/Support/Laravel/SleepingIdentify.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index be4c158..5b9aed8 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -29,4 +29,6 @@ // event() helpers. PHPat keeps Laravel out of both files (LayeringTest). ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) // 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]); + ->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]); diff --git a/tests/E2E/Laravel/artisan.php b/tests/E2E/Laravel/artisan.php new file mode 100644 index 0000000..852330d --- /dev/null +++ b/tests/E2E/Laravel/artisan.php @@ -0,0 +1,20 @@ +`: the handle comes from AGENTCAT_E2E_HANDLE, `e2e` by default. +$app = require __DIR__ . '/bootstrap.php'; +if (!$app instanceof Application) { + throw new LogicException('bootstrap.php returns the application'); +} +$handle = getenv('AGENTCAT_E2E_HANDLE'); +$kernel = $app->make(Kernel::class); +$input = new ArgvInput(['artisan', 'mcp:start', is_string($handle) && $handle !== '' ? $handle : 'e2e']); +$status = $kernel->handle($input, new ConsoleOutput()); +$kernel->terminate($input, $status); +exit($status); diff --git a/tests/E2E/Laravel/bootstrap.php b/tests/E2E/Laravel/bootstrap.php new file mode 100644 index 0000000..8573670 --- /dev/null +++ b/tests/E2E/Laravel/bootstrap.php @@ -0,0 +1,65 @@ +booted(static function () use ($app): void { + $home = (string) getenv('HOME'); + $config = $app->make('config'); + $config->set('logging.channels.e2e', ['driver' => 'single', 'path' => $home . '/laravel.log']); + $config->set('agentcat.log_channel', 'e2e'); + $config->set('app.debug', false); + // Laravel Boost's pattern: a local server with its own tools/call handler, excluded in config/agentcat.php. + $config->push('agentcat.exclude', CustomCallServer::class); + $collector = $_SERVER['HTTP_X_E2E_COLLECTOR'] ?? null; + if (is_string($collector)) { + $config->set('agentcat.api_url', $collector); + } + if (($_SERVER['HTTP_X_E2E_IDENTIFY_SLEEP'] ?? null) === '1') { + $config->set('agentcat.identify', SleepingIdentify::class); + } + if (($_SERVER['HTTP_X_E2E_CONTENT_LENGTH'] ?? null) === '1') { + // A customer's listener that sends the length of each answer: laravel/mcp sends none. + $app->make('events')->listen(RequestHandled::class, static function (RequestHandled $handled): void { + $content = $handled->response->getContent(); + if (is_string($content)) { + $handled->response->headers->set('Content-Length', (string) strlen($content)); + } + }); + } + Mcp::web('/mcp/e2e', ConformanceServer::class); + Mcp::local('e2e', ConformanceServer::class); + Mcp::local('boost-style', CustomCallServer::class); + }); + }, + options: ['extra' => ['providers' => $providers, 'dont-discover' => ['*']]], +); diff --git a/tests/E2E/Laravel/http.php b/tests/E2E/Laravel/http.php new file mode 100644 index 0000000..5b8849f --- /dev/null +++ b/tests/E2E/Laravel/http.php @@ -0,0 +1,18 @@ +make(Kernel::class); +$request = Request::capture(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php new file mode 100644 index 0000000..3aa5043 --- /dev/null +++ b/tests/E2E/LaravelEndToEndTest.php @@ -0,0 +1,409 @@ + the stdio servers of the current test */ + private array $servers = []; + + #[\Override] + public static function setUpBeforeClass(): void + { + $home = getenv('HOME'); + self::assertIsString($home); + self::$dir = $home . '/e2e-laravel-' . bin2hex(random_bytes(6)); + + try { + self::assertTrue(mkdir(self::$dir, 0o700)); + self::$collector = MockCollector::start(); + self::$httpHome = self::home(); + self::$http = BuiltInServer::start(__DIR__ . '/Laravel/http.php', self::environment(self::$httpHome)); + } catch (\Throwable $e) { + self::tearDownAfterClass(); // PHPUnit skips it when this method fails + + throw $e; + } + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$http?->stop(); + self::$http = null; + self::$collector?->stop(); + self::$collector = null; + FileTree::remove(self::$dir); + } + + #[\Override] + protected function tearDown(): void + { + foreach ($this->servers as $server) { + $server->process()->stop(0); + } + $this->servers = []; + } + + #[Group('ac-02-43')] + #[Group('ac-03-12')] + #[MaximumDuration(15000)] + public function testAnHttpCallIsDeliveredWithItsHeadersByTheEndOfTheRequest(): void + { + $scenario = $this->collector()->scenario(); + + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-AgentCat-Health-Run' => 'L']); + + $this->assertSame(200, $answer['status']); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $body = $this->event($scenario); // the shutdown function delivers within the 5 s budget (per-request SAPI) + $this->assertSame(['conformance-server', 'e2e-client', 'L'], [JsonAssert::at($body, 'server_name'), JsonAssert::at($body, 'client_name'), JsonAssert::at($body, 'parameters', 'extra', 'requestInfo', 'headers', 'x-agentcat-health-run')]); + $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT_ID . ' | sdk laravel-mcp/server', $this->laravelLog(self::$httpHome)); + } + + /** + * D57 under `php -S`: Laravel sends the answer, then AgentCat's shutdown function runs the hooks and delivers. The + * answer carries its length (a customer's listener in `bootstrap.php`), so the client has all of it at once. The + * hook sleeps 3 s; the 2 s bound leaves room for a slow runner, and the marker and the collector prove the order. + */ + #[Group('ac-02-48')] + #[Group('ac-03-1')] + #[MaximumDuration(15000)] + public function testAnAnswerWithItsLengthReachesTheClientBeforeTheHooksAndTheDelivery(): void + { + $scenario = $this->collector()->scenario(); + $this->removeMarker(); + $sentAt = microtime(true); + + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Identify-Sleep' => '1', 'X-E2E-Content-Length' => '1']); + + $this->assertLessThan(SleepingIdentify::SECONDS - 1.0, $answer['receivedAt'] - $sentAt, 'the answer arrived before the hook woke up'); + $this->assertSame((string) strlen($answer['body']), $answer['headers']['content-length'] ?? null); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $event = $this->event($scenario, self::SLEEPING_HOOK_DEADLINE_SECONDS); + $this->assertSame('e2e-user', JsonAssert::at($event, 'identify_actor_given_id')); + $this->assertGreaterThan($answer['receivedAt'], $this->hookWokeAt(), 'identify ran after the client had its answer'); + $this->assertGreaterThan($answer['receivedAt'], $this->deliveredAt($scenario), 'the delivery started after the client had its answer'); + } + + /** + * Header conflict 15: laravel/mcp's JSON answer has no `Content-Length`, so under `php -S` (and any SAPI without a + * finish-request function) the client has it only when the request ends and the server closes the connection: + * after AgentCat's hooks and delivery. Under PHP-FPM, `fastcgi_finish_request()` ends the response before both + * (Plan E2's runtime tests). + */ + #[MaximumDuration(15000)] + public function testAnAnswerWithoutItsLengthArrivesWhenTheRequestEnds(): void + { + $scenario = $this->collector()->scenario(); + $this->removeMarker(); + $sentAt = microtime(true); + + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Identify-Sleep' => '1']); + + $this->assertArrayNotHasKey('content-length', $answer['headers']); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $this->assertGreaterThanOrEqual(SleepingIdentify::SECONDS, $answer['receivedAt'] - $sentAt, 'the answer waited for the hook'); + $this->assertLessThan($answer['receivedAt'], $this->hookWokeAt(), 'identify ran before the client had its answer'); + $this->assertLessThan($answer['receivedAt'], $this->deliveredAt($scenario), 'the delivery ran before the request ended'); + } + + #[Group('ac-03-37')] + #[Group('ac-02-44')] + #[Group('ac-03-12')] + #[MaximumDuration(20000)] + public function testAStdioServerDeliversAtEofAndWritesOnlyJsonRpc(): void + { + $scenario = $this->collector()->scenario(); + $server = $this->startStdio(['AGENTCAT_API_URL' => $scenario]); + $this->call($server); + $this->assertSame([], $this->collector()->requests($scenario), 'nothing delivered the event before EOF'); + + $closedAt = microtime(true); + $server->closeInput(); + + $this->assertSame(0, $server->waitForExit(self::EXIT_SECONDS), sprintf('exit within %.1f s of stdin EOF (%.3f s); stderr: %s', self::EXIT_SECONDS, microtime(true) - $closedAt, $server->process()->getErrorOutput())); + $this->assertStdoutIsOnlyJsonRpc($server); + $this->assertSame('', $server->process()->getErrorOutput()); + $event = $this->event($scenario); + $this->assertSame('desktop', JsonAssert::at($event, 'client_name')); + $this->assertSame('conformance-server', JsonAssert::at($event, 'server_name')); + } + + /** SIGTERM with stdin open: the drain delivers the event, then the default disposition ends the process. */ + #[Group('ac-03-10')] + #[RequiresPhpExtension('pcntl')] + #[RequiresPhpExtension('posix')] + #[MaximumDuration(20000)] + public function testSigtermDeliversTheQueuedEventAndThenEndsTheProcessBySignal(): void + { + $scenario = $this->collector()->scenario(); + $server = $this->startStdio(['AGENTCAT_API_URL' => $scenario]); + $this->call($server); + $this->assertSame([], $this->collector()->requests($scenario), 'nothing delivered the event before the signal'); + + $sentAt = microtime(true); + $server->signal(self::SIGTERM); + $code = $server->waitForExit(self::EXIT_SECONDS); + + $this->assertNotNull($code, sprintf('the process ended within %.1f s', self::EXIT_SECONDS)); + $this->assertTrue($server->process()->hasBeenSignaled(), sprintf('exit code %d after %.3f s', $code, microtime(true) - $sentAt)); + $this->assertSame(self::SIGTERM, $server->process()->getTermSignal()); + $this->assertSame('echo', JsonAssert::at($this->event($scenario), 'resource_name')); + $this->assertStdoutIsOnlyJsonRpc($server); + } + + /** + * D53, Laravel Boost's pattern: a local server with its own `tools/call` handler, excluded in config/agentcat.php. + * Its whole stdio session is byte-identical to the same application without AgentCat's provider, and nothing is + * logged or sent. + */ + #[MaximumDuration(20000)] + public function testAnExcludedServerAnswersExactlyAsWithoutAgentCat(): void + { + $scenario = $this->collector()->scenario(); + + $excluded = $this->session(['AGENTCAT_API_URL' => $scenario, 'AGENTCAT_E2E_HANDLE' => 'boost-style']); + $withoutAgentCat = $this->session(['AGENTCAT_E2E_HANDLE' => 'boost-style', 'AGENTCAT_E2E_NO_PROVIDER' => '1']); + + $this->assertSame($withoutAgentCat['stdout'], $excluded['stdout']); + $lines = explode("\n", rtrim($excluded['stdout'], "\n")); + $this->assertCount(3, $lines, 'the answers to initialize, tools/list and tools/call'); + $tools = JsonAssert::at(Json::decode($lines[1]), 'result', 'tools'); + $this->assertIsList($tools); + $this->assertSame(['echo'], array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools)); + $this->assertSame('[{"type":"text","text":"echo: hi"}]', json_encode(JsonAssert::at(Json::decode($lines[2]), 'result', 'content'), JSON_THROW_ON_ERROR)); + $this->assertSame([], $this->collector()->requests($scenario)); + $this->assertStringNotContainsString('AgentCat', $this->laravelLog($excluded['home'])); + $this->assertFileDoesNotExist($excluded['home'] . '/agentcat.log'); + } + + /** + * The environment of every server: tracking on, the project, and an application in `$home/app`. The AgentCat API + * and diagnostics are closed local ports, so an event without the test's collector (`X-E2E-Collector` over HTTP, + * `AGENTCAT_API_URL` for a stdio server) never reaches the AgentCat API. + * + * @return array + */ + private static function environment(string $home): array + { + return [ + 'AGENTCAT_ENABLED' => 'true', + 'AGENTCAT_PROJECT_ID' => self::PROJECT_ID, + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'HOME' => $home, + 'APP_BASE_PATH' => $home . '/app', + ]; + } + + /** A new HOME under this class's directory, with the directories its Laravel application writes to. */ + private static function home(): string + { + $home = self::$dir . '/home-' . bin2hex(random_bytes(6)); + foreach (['bootstrap/cache', 'storage/framework/views', 'storage/logs'] as $directory) { + self::assertTrue(mkdir($home . '/app/' . $directory, 0o700, true)); + } + + return $home; + } + + /** + * `tools/call` `echo` over HTTP in the 2026-07-28 era: no handshake, the client in `_meta`. + * + * @param array $headers + * + * @return array{status: int, headers: array, body: string, receivedAt: float} + */ + private function callOverHttp(array $headers): array + { + $this->assertInstanceOf(BuiltInServer::class, self::$http); + $client = new McpHttpClient(self::$http->baseUrl() . '/mcp/e2e'); + $params = HttpHarness::modernParams(['name' => 'echo', 'arguments' => (object) ['msg' => 'hi', 'session_id' => 'start', 'context' => 'e2e']], ['name' => 'e2e-client', 'version' => '1.0.0']); + + return $client->post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => $params], [...HttpHarness::modernHeaders('tools/call', 'echo'), ...$headers]); + } + + /** @param array{status: int, headers: array, body: string, receivedAt: float} $answer */ + private function answerResult(array $answer): \stdClass + { + return HttpHarness::result(HttpHarness::decode($answer['body'], $answer['headers']['content-type'] ?? '', 1)); + } + + /** + * Starts `php artisan mcp:start` with a new HOME and its application. + * + * @param array $env more environment variables of the server + */ + private function startStdio(array $env, ?string $home = null): StdioMcpClient + { + $server = StdioMcpClient::start(__DIR__ . '/Laravel/artisan.php', [...self::environment($home ?? self::home()), ...$env]); + $this->servers[] = $server; + + return $server; + } + + /** `initialize` from the `desktop` client, as an MCP host opens a stdio session. */ + private function initialize(StdioMcpClient $server): void + { + $server->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'desktop', 'version' => '0.9']]]); + $server->receive(1); + } + + /** `initialize`, then `tools/call` `echo`, as the stdio tests send them. */ + private function call(StdioMcpClient $server): void + { + $this->initialize($server); + $server->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]]); + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($server->receive(2)), 'content', 1, 'text')); + } + + /** + * A whole stdio session with a new HOME — the handshake, `tools/list`, `tools/call` `echo`, stdin EOF — and what + * the server wrote to stdout. + * + * @param array $env + * + * @return array{stdout: string, home: string} + */ + private function session(array $env): array + { + $home = self::home(); + $server = $this->startStdio($env, $home); + $this->initialize($server); + $server->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + $server->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => new \stdClass()]); + $server->receive(2); + $server->send(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi']]]); + $server->receive(3); + $server->closeInput(); + $this->assertSame(0, $server->waitForExit(self::EXIT_SECONDS), $server->process()->getErrorOutput()); + $this->assertSame('', $server->process()->getErrorOutput()); + + return ['stdout' => $server->process()->getOutput(), 'home' => $home]; + } + + /** Every byte on stdout belongs to a JSON-RPC message, one per line. */ + private function assertStdoutIsOnlyJsonRpc(StdioMcpClient $server): void + { + $lines = $server->stdoutLines(); + $this->assertNotSame([], $lines); + $this->assertSame(implode("\n", $lines) . "\n", $server->process()->getOutput(), 'stdout holds whole lines only'); + foreach ($lines as $line) { + $message = Json::decode($line); + $this->assertInstanceOf(\stdClass::class, $message, 'stdout carries only JSON-RPC: ' . $line); + $this->assertSame('2.0', $message->jsonrpc ?? null, $line); + } + } + + /** Waits for the one event of the scenario and returns its decoded body. */ + private function event(string $scenario, float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS): \stdClass + { + $deadline = microtime(true) + $deadlineSeconds; + do { + $requests = $this->collector()->requests($scenario); + if ($requests !== []) { + break; + } + usleep(20_000); + } while (microtime(true) < $deadline); + $this->assertCount(1, $requests, 'one POST to the collector'); + $this->assertSame('POST', $requests[0]->method); + $body = $requests[0]->body; + $this->assertIsString($body); + $event = Json::decode($body); + $this->assertInstanceOf(\stdClass::class, $event, $body); + + return $event; + } + + /** When the collector got the scenario's one POST. */ + private function deliveredAt(string $scenario): float + { + $requests = $this->collector()->requests($scenario); + $this->assertCount(1, $requests); + $start = $requests[0]->start; + $this->assertIsFloat($start); + + return $start; + } + + /** When `SleepingIdentify` woke up in the `php -S` server. */ + private function hookWokeAt(): float + { + $marker = file_get_contents(self::$httpHome . '/' . SleepingIdentify::MARKER); + $this->assertIsString($marker); + $this->assertIsNumeric($marker); + + return (float) $marker; + } + + private function removeMarker(): void + { + FileTree::remove(self::$httpHome . '/' . SleepingIdentify::MARKER); + } + + /** What the application wrote to `$home/laravel.log` (its `e2e` channel), '' before its first line. */ + private function laravelLog(string $home): string + { + $path = $home . '/laravel.log'; + + return is_file($path) ? (string) file_get_contents($path) : ''; + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Support/Laravel/SleepingIdentify.php b/tests/Support/Laravel/SleepingIdentify.php new file mode 100644 index 0000000..789875e --- /dev/null +++ b/tests/Support/Laravel/SleepingIdentify.php @@ -0,0 +1,27 @@ +`, and names the actor `e2e-user`. AgentCat runs hooks in the delivery, so the marker tells when the + * delivery ran. + */ +final class SleepingIdentify +{ + public const int SECONDS = 3; + + public const string MARKER = 'identify-woke'; + + public function __invoke(): Identity + { + sleep(self::SECONDS); + file_put_contents(getenv('HOME') . '/' . self::MARKER, sprintf('%.6F', microtime(true))); + + return new Identity('e2e-user'); + } +} From e92d93d2aec30089f5fc7461e3a3f15bea8c8d82 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 00:15:18 +0000 Subject: [PATCH 19/30] test: read a collector log that has no record yet as no requests --- tests/Integration/Support/MockCollector.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Support/MockCollector.php b/tests/Integration/Support/MockCollector.php index 728f479..df356fe 100644 --- a/tests/Integration/Support/MockCollector.php +++ b/tests/Integration/Support/MockCollector.php @@ -102,8 +102,12 @@ public function requests(string $baseUrl): array } finally { fclose($handle); } + $contents = trim($contents); + if ($contents === '') { + return []; // file_put_contents() creates the log before it takes its lock: the first record is not written yet + } $requests = []; - foreach (explode("\n", trim($contents)) as $line) { + foreach (explode("\n", $contents) as $line) { $request = Json::decode($line); if (!$request instanceof \stdClass) { throw new \UnexpectedValueException('Not a request record: ' . $line); From 584a07193a07e735e5658834f7f259f993e0bded Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 00:15:59 +0000 Subject: [PATCH 20/30] feat: give a tracked laravel/mcp answer its Content-Length where the response ends with the script --- src/Internal/Delivery/FinishRequest.php | 25 +++ src/Internal/Delivery/RequestEndWait.php | 4 +- src/Laravel/AgentCatServiceProvider.php | 2 + src/Laravel/Internal/AnswerLength.php | 65 ++++++++ src/Laravel/Internal/ServerTracker.php | 5 + src/LaravelMcp/LaravelMcpAdapter.php | 4 +- tests/E2E/Laravel/bootstrap.php | 14 +- tests/E2E/LaravelEndToEndTest.php | 42 +++-- .../Integration/Laravel/AnswerLengthTest.php | 148 ++++++++++++++++++ .../Internal/Delivery/FinishRequestTest.php | 59 +++++++ tests/Unit/Laravel/AnswerLengthTest.php | 137 ++++++++++++++++ 11 files changed, 467 insertions(+), 38 deletions(-) create mode 100644 src/Laravel/Internal/AnswerLength.php create mode 100644 tests/Integration/Laravel/AnswerLengthTest.php create mode 100644 tests/Unit/Laravel/AnswerLengthTest.php diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index 2abe2c5..3031a46 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -44,6 +44,31 @@ public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = nul return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false; } + /** + * The process outlives the request: the command line (Octane on Swoole or RoadRunner, a Symfony Runtime worker, a + * stdio server) or FrankenPHP worker mode. + * + * @param array $server `$_SERVER` + * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + */ + public static function outlivesRequest(string $sapi, array $server, ?\Closure $getenv = null): bool + { + return $sapi === 'cli' || $sapi === 'phpdbg' || ($sapi === 'frankenphp' && self::isFrankenPhpWorker($server, $getenv)); + } + + /** + * A web SAPI that runs one request per script and has no finish-request function (mod_php, php-cgi, `php -S`): + * the client has a response only when the script ends, unless the response carries its length (D57, D59). + * + * @param array $server `$_SERVER` + * @param \Closure(string): bool $functionExists + * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + */ + public static function responseEndsWithTheScript(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): bool + { + return !self::outlivesRequest($sapi, $server, $getenv) && self::functionFor($sapi, $server, $functionExists, $getenv) === null; + } + /** @return (\Closure(): void)|null the finish call of this SAPI, if it has one */ public static function detect(): ?\Closure { diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php index 3f2964e..a86dcd4 100644 --- a/src/Internal/Delivery/RequestEndWait.php +++ b/src/Internal/Delivery/RequestEndWait.php @@ -36,9 +36,7 @@ public static function seconds(bool $console, string $sapi = PHP_SAPI, ?array $s if ($console) { return 0.0; } - $outlivesRequest = $sapi === 'cli' || $sapi === 'phpdbg' - || ($sapi === 'frankenphp' && FinishRequest::isFrankenPhpWorker($server ?? $_SERVER, $getenv)); - return $outlivesRequest ? self::WORKER_SECONDS : 0.0; + return FinishRequest::outlivesRequest($sapi, $server ?? $_SERVER, $getenv) ? self::WORKER_SECONDS : 0.0; } } diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index 33c09e4..81320c0 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Laravel\Console\InstallCommand; use AgentCat\Laravel\Internal\AfterRequest; +use AgentCat\Laravel\Internal\AnswerLength; use AgentCat\Laravel\Internal\ServerTracker; use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; @@ -44,6 +45,7 @@ public function boot(): void $this->app->afterResolving(Server::class, static function (Server $server, Container $app) use ($tracker): void { $tracker->track($server, $app); }); + AnswerLength::register($this->app); // D59: only where a response ends with the script } AfterRequest::register($this->app); // callbacks only: nothing runs at boot } diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php new file mode 100644 index 0000000..67a6e20 --- /dev/null +++ b/src/Laravel/Internal/AnswerLength.php @@ -0,0 +1,65 @@ + function_exists($name))) { + $app->make('events')->listen(RequestHandled::class, self::handle(...)); + } + } + + /** Marks the request that a tracked server answers, read from its HTTP transport; a stdio server answers none. */ + public static function mark(Server $server): void + { + LaravelMcpAdapter::request($server)?->attributes->set(self::TRACKED, true); + } + + /** Gives the plain answer to a marked request its length in bytes. Never throws: it runs in Laravel's HTTP kernel (D32). */ + public static 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')) { + return; + } + $content = $handled->response->getContent(); // false for a streamed answer (StreamedResponse) + if (is_string($content)) { + $headers->set('Content-Length', (string) strlen($content)); + } + } catch (\Throwable) { + // the answer goes out as it is + } + } +} diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index a64de51..7e7c28b 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -8,6 +8,8 @@ use AgentCat\Client; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\ConfigOptions; use AgentCat\Internal\Support\EnabledGate; @@ -51,6 +53,9 @@ public function track(Server $server, Container $app): void $client = FakeLog::of($client) instanceof FakeLog ? $client : ($this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null)))); $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->resolve(...)); $client->track($server, $this->project($server, $config), $this->options); + if (TrackedServers::get($server) instanceof TrackedServer) { + AnswerLength::mark($server); // D59; a server that AgentCat did not track (off, or failed) keeps its answer + } } catch (\Throwable $e) { Log::write('Warning: Failed to track server - ' . ErrorText::of($e)); } diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php index efa9af9..ecc881e 100644 --- a/src/LaravelMcp/LaravelMcpAdapter.php +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -172,9 +172,9 @@ private static function entry(array $methods, string $method): ?string /** * The request of the server's HTTP transport; null on another transport, and when the transport or its request - * cannot be read (drift), which logs the compatibility line once per server. + * cannot be read (drift), which logs the compatibility line once per server. Never throws. */ - private static function request(Server $server): ?Request + public static function request(Server $server): ?Request { try { return StrictErrors::run(static function () use ($server): ?Request { diff --git a/tests/E2E/Laravel/bootstrap.php b/tests/E2E/Laravel/bootstrap.php index 8573670..e2785d1 100644 --- a/tests/E2E/Laravel/bootstrap.php +++ b/tests/E2E/Laravel/bootstrap.php @@ -6,15 +6,15 @@ use AgentCat\Tests\Support\Laravel\SleepingIdentify; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; use AgentCat\Tests\Support\LaravelMcp\CustomCallServer; +use AgentCat\Tests\Support\LaravelMcp\StreamingServer; use Illuminate\Foundation\Application as LaravelApplication; -use Illuminate\Foundation\Http\Events\RequestHandled; use Laravel\Mcp\Facades\Mcp; use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\Foundation\Application; require dirname(__DIR__, 3) . '/vendor/autoload.php'; -// A booted Laravel application with laravel/mcp, one web and two local servers, and AgentCat only as the service +// A booted Laravel application with laravel/mcp, two web and two local servers, and AgentCat only as the service // provider that package discovery registers from composer.json (ServiceProviderTest checks that entry). Every setting // comes from the environment the test passes and, under php -S, from the request's X-E2E-* headers. // @@ -47,16 +47,8 @@ if (($_SERVER['HTTP_X_E2E_IDENTIFY_SLEEP'] ?? null) === '1') { $config->set('agentcat.identify', SleepingIdentify::class); } - if (($_SERVER['HTTP_X_E2E_CONTENT_LENGTH'] ?? null) === '1') { - // A customer's listener that sends the length of each answer: laravel/mcp sends none. - $app->make('events')->listen(RequestHandled::class, static function (RequestHandled $handled): void { - $content = $handled->response->getContent(); - if (is_string($content)) { - $handled->response->headers->set('Content-Length', (string) strlen($content)); - } - }); - } Mcp::web('/mcp/e2e', ConformanceServer::class); + Mcp::web('/mcp/e2e-stream', StreamingServer::class); Mcp::local('e2e', ConformanceServer::class); Mcp::local('boost-style', CustomCallServer::class); }); diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php index 3aa5043..e87554d 100644 --- a/tests/E2E/LaravelEndToEndTest.php +++ b/tests/E2E/LaravelEndToEndTest.php @@ -111,20 +111,20 @@ public function testAnHttpCallIsDeliveredWithItsHeadersByTheEndOfTheRequest(): v } /** - * D57 under `php -S`: Laravel sends the answer, then AgentCat's shutdown function runs the hooks and delivers. The - * answer carries its length (a customer's listener in `bootstrap.php`), so the client has all of it at once. The - * hook sleeps 3 s; the 2 s bound leaves room for a slow runner, and the marker and the collector prove the order. + * D59 with D57 under `php -S`: AgentCat's listener gives laravel/mcp's plain answer its length, Laravel sends it, and + * only then does AgentCat's shutdown function run the hooks and deliver, so the client has the whole answer first. + * The hook sleeps 3 s; the 2 s bound leaves room for a slow runner, and the marker and the collector prove the order. */ #[Group('ac-02-48')] #[Group('ac-03-1')] #[MaximumDuration(15000)] - public function testAnAnswerWithItsLengthReachesTheClientBeforeTheHooksAndTheDelivery(): void + public function testAnAnswerGetsItsLengthAndReachesTheClientBeforeTheHooksAndTheDelivery(): void { $scenario = $this->collector()->scenario(); $this->removeMarker(); $sentAt = microtime(true); - $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Identify-Sleep' => '1', 'X-E2E-Content-Length' => '1']); + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Identify-Sleep' => '1']); $this->assertLessThan(SleepingIdentify::SECONDS - 1.0, $answer['receivedAt'] - $sentAt, 'the answer arrived before the hook woke up'); $this->assertSame((string) strlen($answer['body']), $answer['headers']['content-length'] ?? null); @@ -136,25 +136,22 @@ public function testAnAnswerWithItsLengthReachesTheClientBeforeTheHooksAndTheDel } /** - * Header conflict 15: laravel/mcp's JSON answer has no `Content-Length`, so under `php -S` (and any SAPI without a - * finish-request function) the client has it only when the request ends and the server closes the connection: - * after AgentCat's hooks and delivery. Under PHP-FPM, `fastcgi_finish_request()` ends the response before both - * (Plan E2's runtime tests). + * D59 leaves a streamed answer as laravel/mcp sends it: server-sent events without `Content-Length`, the progress + * notifications, then the result with AgentCat's session block. Its event comes when the stream ends. */ #[MaximumDuration(15000)] - public function testAnAnswerWithoutItsLengthArrivesWhenTheRequestEnds(): void + public function testAStreamedAnswerKeepsItsOwnHeaders(): void { $scenario = $this->collector()->scenario(); - $this->removeMarker(); - $sentAt = microtime(true); - $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Identify-Sleep' => '1']); + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario], 'stream', '/mcp/e2e-stream'); + $this->assertSame(200, $answer['status']); + $this->assertStringStartsWith('text/event-stream', $answer['headers']['content-type'] ?? ''); $this->assertArrayNotHasKey('content-length', $answer['headers']); - $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); - $this->assertGreaterThanOrEqual(SleepingIdentify::SECONDS, $answer['receivedAt'] - $sentAt, 'the answer waited for the hook'); - $this->assertLessThan($answer['receivedAt'], $this->hookWokeAt(), 'identify ran before the client had its answer'); - $this->assertLessThan($answer['receivedAt'], $this->deliveredAt($scenario), 'the delivery ran before the request ended'); + $this->assertSame(3, substr_count($answer['body'], 'data: '), 'two notifications, then the result'); + $this->assertSame('done', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $this->assertSame('stream', JsonAssert::at($this->event($scenario), 'resource_name')); } #[Group('ac-03-37')] @@ -258,19 +255,20 @@ private static function home(): string } /** - * `tools/call` `echo` over HTTP in the 2026-07-28 era: no handshake, the client in `_meta`. + * A `tools/call` over HTTP in the 2026-07-28 era: no handshake, the client in `_meta`. `echo` gets `msg` `hi`. * * @param array $headers * * @return array{status: int, headers: array, body: string, receivedAt: float} */ - private function callOverHttp(array $headers): array + private function callOverHttp(array $headers, string $tool = 'echo', string $path = '/mcp/e2e'): array { $this->assertInstanceOf(BuiltInServer::class, self::$http); - $client = new McpHttpClient(self::$http->baseUrl() . '/mcp/e2e'); - $params = HttpHarness::modernParams(['name' => 'echo', 'arguments' => (object) ['msg' => 'hi', 'session_id' => 'start', 'context' => 'e2e']], ['name' => 'e2e-client', 'version' => '1.0.0']); + $client = new McpHttpClient(self::$http->baseUrl() . $path); + $arguments = $tool === 'echo' ? ['msg' => 'hi', 'session_id' => 'start', 'context' => 'e2e'] : ['session_id' => 'start']; + $params = HttpHarness::modernParams(['name' => $tool, 'arguments' => (object) $arguments], ['name' => 'e2e-client', 'version' => '1.0.0']); - return $client->post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => $params], [...HttpHarness::modernHeaders('tools/call', 'echo'), ...$headers]); + return $client->post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => $params], [...HttpHarness::modernHeaders('tools/call', $tool), ...$headers]); } /** @param array{status: int, headers: array, body: string, receivedAt: float} $answer */ diff --git a/tests/Integration/Laravel/AnswerLengthTest.php b/tests/Integration/Laravel/AnswerLengthTest.php new file mode 100644 index 0000000..59bdbf8 --- /dev/null +++ b/tests/Integration/Laravel/AnswerLengthTest.php @@ -0,0 +1,148 @@ + true, 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + + /** @param Router $router */ + #[\Override] + protected function defineRoutes(mixed $router): void + { + Mcp::web('/mcp/test', HeaderIdentityServer::class); + Mcp::web('/mcp/stream', StreamingServer::class); + $router->post('/plain', static fn(): string => 'plain'); + } + + /** Under the CLI the provider adds no listener: the SAPI has no request that ends with the script. */ + public function testThisSapiGetsNoListener(): void + { + $this->assertFalse(FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, function_exists(...))); + + $response = $this->toolCall('echo'); + + $response->assertOk(); + $this->assertFalse($response->headers->has('Content-Length')); + } + + public function testATrackedServersAnswerGetsItsLength(): void + { + AnswerLength::register($this->application(), true); + + $response = $this->toolCall('echo'); + + $response->assertOk(); + $content = $response->baseResponse->getContent(); + $this->assertIsString($content); + $this->assertSame((string) strlen($content), $response->headers->get('Content-Length')); + $this->assertCount(1, $this->deliveries(), 'the server was tracked'); + } + + public function testAnExcludedServersAnswerStaysAsItIs(): void + { + AnswerLength::register($this->application(), true); + config(['agentcat.exclude' => [HeaderIdentityServer::class]]); + + $response = $this->toolCall('echo'); + + $response->assertOk(); + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertSame([], $this->deliveries()); + } + + public function testAnAnswerWhileAgentCatIsOffStaysAsItIs(): void + { + AnswerLength::register($this->application(), true); + config(['agentcat.enabled' => false]); + + $response = $this->toolCall('echo'); + + $response->assertOk(); + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertSame([], $this->deliveries()); + } + + #[MaximumDuration(1000)] + public function testAStreamedAnswerStaysAsItIs(): void + { + AnswerLength::register($this->application(), true); + + $response = $this->toolCall('stream', '/mcp/stream'); + + $response->assertOk(); + $this->assertStringContainsString('"text":"done"', $response->streamedContent()); + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertCount(1, $this->deliveries(), 'the server was tracked'); + } + + public function testAnotherRouteStaysAsItIs(): void + { + AnswerLength::register($this->application(), true); + + $response = $this->post('/plain'); + + $response->assertOk(); + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** The mark lives on the request: in one application, like an Octane worker, the next request does not inherit it. */ + public function testTheNextRequestOfTheApplicationIsNotMarked(): void + { + AnswerLength::register($this->application(), true); + + $this->assertTrue($this->toolCall('echo')->headers->has('Content-Length')); + $this->assertFalse($this->post('/plain')->headers->has('Content-Length')); + } + + /** + * A 2026-07-28 `tools/call`, with the headers laravel/mcp's `ValidateMcpHeaders` requires. + * + * @return TestResponse + */ + private function toolCall(string $tool, string $uri = '/mcp/test'): TestResponse + { + return $this->postJson($uri, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => $tool === 'echo' ? ['msg' => 'hi', 'session_id' => 'start'] : ['session_id' => 'start'], '_meta' => LaravelHarness::meta([], ['name' => 'modern-client', 'version' => '2.0.0'])], + ], ['MCP-Protocol-Version' => LaravelHarness::MODERN_PROTOCOL_VERSION, 'Mcp-Method' => 'tools/call', 'Mcp-Name' => $tool]); + } + + private function application(): Application + { + $this->assertInstanceOf(Application::class, $this->app); + + return $this->app; + } +} diff --git a/tests/Unit/Internal/Delivery/FinishRequestTest.php b/tests/Unit/Internal/Delivery/FinishRequestTest.php index f7e76ee..e97abdb 100644 --- a/tests/Unit/Internal/Delivery/FinishRequestTest.php +++ b/tests/Unit/Internal/Delivery/FinishRequestTest.php @@ -83,6 +83,65 @@ public function testWorkerModeReadsTheRealEnvironmentByDefault(): void } } + /** @return iterable, bool}> */ + public static function processes(): iterable + { + yield 'Octane on Swoole or RoadRunner, a stdio server' => ['cli', [], true]; + yield 'phpdbg' => ['phpdbg', [], true]; + yield 'FrankenPHP worker mode' => ['frankenphp', ['FRANKENPHP_WORKER' => '1'], true]; + yield 'FrankenPHP classic mode' => ['frankenphp', [], false]; + yield 'PHP-FPM' => ['fpm-fcgi', [], false]; + yield 'PHP-FPM with a stray worker variable' => ['fpm-fcgi', ['FRANKENPHP_WORKER' => '1'], false]; + yield 'Apache mod_php' => ['apache2handler', [], false]; + yield 'built-in web server' => ['cli-server', [], false]; + } + + /** @param array $server */ + #[DataProvider('processes')] + public function testOutlivesRequest(string $sapi, array $server, bool $expected): void + { + $this->assertSame($expected, FinishRequest::outlivesRequest($sapi, $server, $this->environment(false))); + } + + /** @return iterable, list, bool}> */ + public static function requestEnds(): iterable + { + yield 'Apache mod_php' => ['apache2handler', [], [], true]; + yield 'php-cgi' => ['cgi-fcgi', [], [], true]; + yield 'built-in web server' => ['cli-server', [], ['fastcgi_finish_request'], true]; + yield 'PHP-FPM' => ['fpm-fcgi', [], ['fastcgi_finish_request'], false]; + yield 'PHP-FPM with the function disabled' => ['fpm-fcgi', [], [], true]; + yield 'LiteSpeed' => ['litespeed', [], ['litespeed_finish_request'], false]; + yield 'FrankenPHP classic mode' => ['frankenphp', [], ['frankenphp_finish_request'], false]; + yield 'FrankenPHP worker mode' => ['frankenphp', ['FRANKENPHP_WORKER' => '1'], ['frankenphp_finish_request'], false]; + yield 'CLI' => ['cli', [], [], false]; + yield 'phpdbg' => ['phpdbg', [], [], false]; + } + + /** + * D59: a per-request web SAPI without a finish-request function sends a response only when the script ends. + * + * @param array $server + * @param list $available + */ + #[DataProvider('requestEnds')] + public function testResponseEndsWithTheScript(string $sapi, array $server, array $available, bool $expected): void + { + $functionExists = static fn(string $name): bool => in_array($name, $available, true); + + $this->assertSame($expected, FinishRequest::responseEndsWithTheScript($sapi, $server, $functionExists, $this->environment(false))); + } + + /** FrankenPHP worker mode from the environment is no per-request SAPI, even without a finish-request function. */ + public function testResponseEndsWithTheScriptReadsWorkerModeFromTheEnvironment(): void + { + $none = static fn(string $name): bool => false; + + $this->assertFalse(FinishRequest::responseEndsWithTheScript('frankenphp', [], $none, $this->environment('1'))); + $this->assertTrue(FinishRequest::responseEndsWithTheScript('frankenphp', [], $none, $this->environment(false))); + $this->assertTrue(FinishRequest::outlivesRequest('frankenphp', [], $this->environment('1'))); + } + public function testDetectFindsNothingUnderTheCliTestRunner(): void { $this->assertSame('cli', PHP_SAPI); diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php new file mode 100644 index 0000000..d656d4b --- /dev/null +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -0,0 +1,137 @@ + 'application/json']); + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertSame('78', $response->headers->get('Content-Length'), 'bytes, not characters: é is two'); + } + + /** laravel/mcp answers a notification with 202 and no body. */ + public function testAnEmptyAnswerGetsALengthOfZero(): void + { + $response = new Response('', 202); + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertSame('0', $response->headers->get('Content-Length')); + } + + /** @return iterable */ + public static function unmarkedRequests(): iterable + { + yield 'no tracked server answered (another route, an excluded server, AgentCat off)' => [Request::create('/mcp', 'POST')]; + $other = Request::create('/mcp', 'POST'); + $other->attributes->set(AnswerLength::TRACKED, '1'); + yield 'an attribute that is not the mark' => [$other]; + } + + #[DataProvider('unmarkedRequests')] + public function testAnAnswerToAnotherRequestStaysAsItIs(Request $request): void + { + $response = new Response(self::ANSWER); + + AnswerLength::handle(new RequestHandled($request, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** A streamed answer has no content (`StreamedResponse::getContent()` is false); `Integration\Laravel` sends a real one. */ + public function testAnAnswerWithoutContentStaysAsItIs(): void + { + $response = new class extends Response { + #[\Override] + public function getContent(): string|false + { + return false; + } + }; + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + public function testAnAnswerWithItsOwnLengthKeepsIt(): void + { + $response = new Response(self::ANSWER, 200, ['Content-Length' => '7']); + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertSame('7', $response->headers->get('Content-Length')); + } + + public function testAnAnswerWithATransferEncodingGetsNoLength(): void + { + $response = new Response(self::ANSWER, 200, ['Transfer-Encoding' => 'chunked']); + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertSame('chunked', $response->headers->get('Transfer-Encoding')); + } + + /** D32: the listener runs inside Laravel's HTTP kernel, so nothing it meets may break the request. */ + public function testAResponseThatFailsLeavesTheRequestAlone(): void + { + $response = new class (self::ANSWER) extends Response { + #[\Override] + public function getContent(): string|false + { + throw new \RuntimeException('no content'); + } + }; + + AnswerLength::handle(new RequestHandled($this->marked(), $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + public function testATrackedServerMarksTheRequestItAnswersOverHttp(): void + { + $request = Request::create('/mcp', 'POST'); + + AnswerLength::mark(new HeaderIdentityServer(new HttpTransport($request))); + + $this->assertTrue($request->attributes->get(AnswerLength::TRACKED)); + } + + /** A stdio server answers no HTTP request: there is nothing to mark, and nothing fails. */ + public function testAStdioServerMarksNothing(): void + { + $this->expectNotToPerformAssertions(); + + AnswerLength::mark(new HeaderIdentityServer(new StdioTransport())); + } + + private function marked(): Request + { + $request = Request::create('/mcp', 'POST'); + $request->attributes->set(AnswerLength::TRACKED, true); + + return $request; + } +} From 88b3a3c9405caace5d3ccca1281826953f7713c5 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 00:16:11 +0000 Subject: [PATCH 21/30] test: read only this request's setup line in the Laravel HTTP end-to-end test --- tests/E2E/LaravelEndToEndTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php index e87554d..e516d5d 100644 --- a/tests/E2E/LaravelEndToEndTest.php +++ b/tests/E2E/LaravelEndToEndTest.php @@ -100,6 +100,7 @@ protected function tearDown(): void public function testAnHttpCallIsDeliveredWithItsHeadersByTheEndOfTheRequest(): void { $scenario = $this->collector()->scenario(); + FileTree::remove(self::$httpHome . '/laravel.log'); // the HTTP tests share it: only this request may write the line $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-AgentCat-Health-Run' => 'L']); From 7777e31874f00a36dab1cd0c7d81e143b25e0278 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 00:35:37 +0000 Subject: [PATCH 22/30] fix: leave a laravel/mcp answer without Content-Length after stray output --- src/Laravel/Internal/AnswerLength.php | 36 ++++++++++-- tests/E2E/Laravel/bootstrap.php | 3 + tests/E2E/LaravelEndToEndTest.php | 37 +++++++++++-- tests/Unit/Laravel/AnswerLengthTest.php | 73 ++++++++++++++++++++++--- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php index 67a6e20..14558cb 100644 --- a/src/Laravel/Internal/AnswerLength.php +++ b/src/Laravel/Internal/AnswerLength.php @@ -15,19 +15,29 @@ * 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, and one with a `Content-Length` or `Transfer-Encoding` of its own, stay as they are. PHP-FPM, LiteSpeed, - * FrankenPHP and Octane get no listener. + * 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. * * 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. * * @internal */ -final class AnswerLength +final readonly class AnswerLength { /** 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; + + /** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */ + public function __construct(?\Closure $headersSent = null) + { + $this->headersSent = $headersSent ?? static fn(): bool => headers_sent(); + } + /** * Listens for `RequestHandled` when responses end with the script. Nothing runs here. * @@ -36,7 +46,7 @@ final class AnswerLength public static function register(Application $app, ?bool $responseEndsWithTheScript = null): void { if ($responseEndsWithTheScript ?? FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, static fn(string $name): bool => function_exists($name))) { - $app->make('events')->listen(RequestHandled::class, self::handle(...)); + $app->make('events')->listen(RequestHandled::class, (new self())->handle(...)); } } @@ -47,13 +57,16 @@ public static function mark(Server $server): void } /** Gives the plain answer to a marked request its length in bytes. Never throws: it runs in Laravel's HTTP kernel (D32). */ - public static function handle(RequestHandled $handled): 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')) { 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)); @@ -62,4 +75,17 @@ public static function handle(RequestHandled $handled): void // 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/tests/E2E/Laravel/bootstrap.php b/tests/E2E/Laravel/bootstrap.php index e2785d1..76348eb 100644 --- a/tests/E2E/Laravel/bootstrap.php +++ b/tests/E2E/Laravel/bootstrap.php @@ -47,6 +47,9 @@ if (($_SERVER['HTTP_X_E2E_IDENTIFY_SLEEP'] ?? null) === '1') { $config->set('agentcat.identify', SleepingIdentify::class); } + if (($_SERVER['HTTP_X_E2E_STRAY_OUTPUT'] ?? null) === '1') { + echo "\n"; // a customer file with a newline after its closing tag, before the answer + } Mcp::web('/mcp/e2e', ConformanceServer::class); Mcp::web('/mcp/e2e-stream', StreamingServer::class); Mcp::local('e2e', ConformanceServer::class); diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php index e516d5d..338f887 100644 --- a/tests/E2E/LaravelEndToEndTest.php +++ b/tests/E2E/LaravelEndToEndTest.php @@ -155,6 +155,33 @@ public function testAStreamedAnswerKeepsItsOwnHeaders(): void $this->assertSame('stream', JsonAssert::at($this->event($scenario), 'resource_name')); } + /** + * Stray output before the answer under `php -S` with php.ini-production's `output_buffering=4096`, as on mod_php + * and php-cgi: the newline waits in PHP's output buffer and goes out before the JSON, so a length of the JSON alone + * would cut its end. AgentCat then leaves the answer as laravel/mcp sends it, and the client reads all of it when the + * request ends. Without stray output, the same buffering still gives the answer its length. + */ + #[MaximumDuration(15000)] + public function testStrayOutputBeforeTheAnswerKeepsTheAnswerWhole(): void + { + $scenario = $this->collector()->scenario(); + $server = BuiltInServer::start(__DIR__ . '/Laravel/http.php', self::environment(self::home()), ['output_buffering' => '4096']); + + try { + $sized = $this->callOverHttp(['X-E2E-Collector' => $this->collector()->scenario()], server: $server); + $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-E2E-Stray-Output' => '1'], server: $server); + } finally { + $server->stop(); + } + + $this->assertSame((string) strlen($sized['body']), $sized['headers']['content-length'] ?? null, 'an answer without stray output gets its length'); + $this->assertSame(200, $answer['status']); + $this->assertStringStartsWith("\n{", $answer['body']); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); // the whole JSON decodes + $this->assertArrayNotHasKey('content-length', $answer['headers']); + $this->assertSame('echo', JsonAssert::at($this->event($scenario), 'resource_name')); + } + #[Group('ac-03-37')] #[Group('ac-02-44')] #[Group('ac-03-12')] @@ -256,16 +283,18 @@ private static function home(): string } /** - * A `tools/call` over HTTP in the 2026-07-28 era: no handshake, the client in `_meta`. `echo` gets `msg` `hi`. + * A `tools/call` over HTTP in the 2026-07-28 era: no handshake, the client in `_meta`. `echo` gets `msg` `hi`. The + * class's `php -S` server answers unless the test passes its own. * * @param array $headers * * @return array{status: int, headers: array, body: string, receivedAt: float} */ - private function callOverHttp(array $headers, string $tool = 'echo', string $path = '/mcp/e2e'): array + private function callOverHttp(array $headers, string $tool = 'echo', string $path = '/mcp/e2e', ?BuiltInServer $server = null): array { - $this->assertInstanceOf(BuiltInServer::class, self::$http); - $client = new McpHttpClient(self::$http->baseUrl() . $path); + $server ??= self::$http; + $this->assertInstanceOf(BuiltInServer::class, $server); + $client = new McpHttpClient($server->baseUrl() . $path); $arguments = $tool === 'echo' ? ['msg' => 'hi', 'session_id' => 'start', 'context' => 'e2e'] : ['session_id' => 'start']; $params = HttpHarness::modernParams(['name' => $tool, 'arguments' => (object) $arguments], ['name' => 'e2e-client', 'version' => '1.0.0']); diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php index d656d4b..c70c471 100644 --- a/tests/Unit/Laravel/AnswerLengthTest.php +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -25,7 +25,7 @@ public function testATrackedServersPlainAnswerGetsItsLengthInBytes(): void { $response = new Response(self::ANSWER, 200, ['Content-Type' => 'application/json']); - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); $this->assertSame('78', $response->headers->get('Content-Length'), 'bytes, not characters: é is two'); } @@ -35,7 +35,7 @@ public function testAnEmptyAnswerGetsALengthOfZero(): void { $response = new Response('', 202); - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); $this->assertSame('0', $response->headers->get('Content-Length')); } @@ -54,7 +54,7 @@ public function testAnAnswerToAnotherRequestStaysAsItIs(Request $request): void { $response = new Response(self::ANSWER); - AnswerLength::handle(new RequestHandled($request, $response)); + (new AnswerLength())->handle(new RequestHandled($request, $response)); $this->assertFalse($response->headers->has('Content-Length')); } @@ -70,7 +70,7 @@ public function getContent(): string|false } }; - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); $this->assertFalse($response->headers->has('Content-Length')); } @@ -79,7 +79,7 @@ public function testAnAnswerWithItsOwnLengthKeepsIt(): void { $response = new Response(self::ANSWER, 200, ['Content-Length' => '7']); - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); $this->assertSame('7', $response->headers->get('Content-Length')); } @@ -88,7 +88,7 @@ public function testAnAnswerWithATransferEncodingGetsNoLength(): void { $response = new Response(self::ANSWER, 200, ['Transfer-Encoding' => 'chunked']); - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); $this->assertFalse($response->headers->has('Content-Length')); $this->assertSame('chunked', $response->headers->get('Transfer-Encoding')); @@ -105,7 +105,66 @@ public function getContent(): string|false } }; - AnswerLength::handle(new RequestHandled($this->marked(), $response)); + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** @return iterable}> the bytes in each output buffer, from the bottom level up */ + public static function strayOutput(): iterable + { + yield 'a BOM in the top buffer' => [["\xEF\xBB\xBF"]]; + yield 'a newline after a closing tag, in a lower buffer' => [["\n", '']]; + } + + /** + * Bytes written before the answer wait in an output buffer (mod_php and php-cgi with `output_buffering`) and go out + * first: a length of the answer alone would cut the end of the JSON. The answer stays as laravel/mcp sends it. + * + * @param list $buffers + */ + #[DataProvider('strayOutput')] + public function testStrayOutputInAnOutputBufferLeavesTheAnswerAlone(array $buffers): void + { + $response = new Response(self::ANSWER); + $level = ob_get_level(); + foreach ($buffers as $bytes) { + ob_start(); + echo $bytes; + } + + try { + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** An empty output buffer holds nothing that goes out before the answer. */ + public function testAnEmptyOutputBufferDoesNotCount(): void + { + $response = new Response(self::ANSWER); + ob_start(); + + try { + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); + } finally { + ob_end_clean(); + } + + $this->assertSame('78', $response->headers->get('Content-Length')); + } + + /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ + public function testAnAnswerAfterTheHeadersWentOutStaysAsItIs(): void + { + $response = new Response(self::ANSWER); + + (new AnswerLength(static fn(): bool => true))->handle(new RequestHandled($this->marked(), $response)); $this->assertFalse($response->headers->has('Content-Length')); } From f6bd433813499be878b19feacfbec8026a724422 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 02:56:11 +0000 Subject: [PATCH 23/30] fix: run the hooks of every waiting event before a flush returns, so each runs in its own request An Octane worker serves the next request in the same process. With five deliveries in flight, a waiting event's hooks ran at a later request's pump or wait, so a hook that reads the container (the authenticated user, an injected request) recorded the later request's actor. flush(), the request-end wait, now runs the pipeline of every waiting event whatever the free slots; the payloads wait in order for the transport and go to the endpoint of the moment. pump() and shutdown() still run an event's hooks only when it gets a slot. --- src/Internal/Delivery/EventQueue.php | 98 ++++++-- src/Internal/Delivery/ReadyEvent.php | 27 ++ src/Internal/Delivery/RequestEndWait.php | 9 +- tests/Integration/Laravel/DeliveryTest.php | 39 +++ .../Laravel/ContainerRequestIdentity.php | 20 ++ .../Laravel/InjectedRequestIdentity.php | 22 ++ .../Unit/Internal/Delivery/EventQueueTest.php | 230 ++++++++++++++++++ 7 files changed, 417 insertions(+), 28 deletions(-) create mode 100644 src/Internal/Delivery/ReadyEvent.php create mode 100644 tests/Support/Laravel/ContainerRequestIdentity.php create mode 100644 tests/Support/Laravel/InjectedRequestIdentity.php diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 5f09e83..204ee94 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -24,6 +24,11 @@ * hygiene and HTTP run in `pump()`, `flush()` or `shutdown()`. At most 5 events are in delivery at once, and * at most 10 000 wait (the oldest is dropped). No method throws. * + * An event's hooks run when it gets a free delivery slot, except in `flush()`, which runs them for every waiting event + * first, whatever the free slots: a framework's request-end wait is a `flush()`, so each hook runs in its own request, + * also in a worker that serves the next request in the same process (Octane). The payloads then wait, in order, only + * for the transport. + * * The queue also drives the side channel of the exporter and diagnostics requests (`channel()`) and its companions * (`attach()`): every pass ticks each companion (except the pass after `finish()`), then pumps the channel. `flush()` * waits for the exporter requests inside its timeout, `shutdown()` for the background ones (diagnostics) too. @@ -51,9 +56,12 @@ final class EventQueue private static ?self $global = null; - /** @var \SplQueue events not yet in delivery */ + /** @var \SplQueue events whose hooks have not run */ private readonly \SplQueue $waiting; + /** @var \SplQueue events through the pipeline (`flush()`), each older than every waiting event */ + private readonly \SplQueue $ready; + /** @var \Closure(Event): string */ private readonly \Closure $encode; @@ -89,6 +97,7 @@ public function __construct( private readonly ?SignalDrain $signalDrain = null, ) { $this->waiting = new \SplQueue(); + $this->ready = new \SplQueue(); $this->encode = $encode ?? WireEncoder::encode(...); $this->endpoint = ApiBaseUrl::endpoint(ApiBaseUrl::resolve()); // environment or default, until configure() } @@ -178,7 +187,7 @@ public function arm(): void public function hasWork(): bool { try { - if (!$this->waiting->isEmpty() || $this->transport->inFlight() > 0 || $this->channel->pending() > 0) { + if ($this->queued() > 0 || $this->transport->inFlight() > 0 || $this->channel->pending() > 0) { return true; } foreach ($this->companions as $companion) { @@ -201,9 +210,9 @@ public function add(QueuedEvent $event): void return; } - if ($this->waiting->count() >= self::MAX_QUEUE_SIZE) { + if ($this->queued() >= self::MAX_QUEUE_SIZE) { Log::write('Event queue full, dropping oldest event'); - $this->waiting->dequeue(); + $this->ready->isEmpty() ? $this->waiting->dequeue() : $this->ready->dequeue(); } $this->waiting->enqueue($event); $this->arm(); @@ -231,10 +240,14 @@ public function pump(): void * and no exporter request is pending (true), or the timeout passes (false); null = no limit. The background * requests (diagnostics) are neither counted nor waited for: they go on in later passes and at `shutdown()`. A * re-entrant call (from a hook or an exporter) returns false at once. + * + * After its first pass, and after each later one, it runs the pipeline (the hooks first) of every waiting event, + * whatever the free slots, also when the timeout has passed: so the hooks run before the call returns. The timeout + * bounds the waits for the transport, not the hooks. */ public function flush(?float $timeoutSeconds = null): bool { - return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false); + return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false, prepare: true); } /** @@ -261,8 +274,8 @@ public function shutdown(): void $this->deliverWithCompanions($deadline); } } - if (!$this->waiting->isEmpty()) { - Log::write(sprintf('Shutting down with %d events still in queue', $this->waiting->count())); + if ($this->queued() > 0) { + Log::write(sprintf('Shutting down with %d events still in queue', $this->queued())); } } @@ -281,7 +294,13 @@ public function finalShutdown(): void /** @return array{queueLength: int, activeRequests: int} */ public function stats(): array { - return ['queueLength' => $this->waiting->count(), 'activeRequests' => $this->transport->inFlight()]; + return ['queueLength' => $this->queued(), 'activeRequests' => $this->transport->inFlight()]; + } + + /** The events not yet in delivery: those that went through the pipeline, then those whose hooks have not run. */ + private function queued(): int + { + return $this->ready->count() + $this->waiting->count(); } /** @@ -334,8 +353,9 @@ private function finishReserve(): float * @param \Closure(): float $deadline read once, after the re-entrancy guard is taken * @param bool $tick false after `finish()`: the passes tick no companion * @param bool $background whether it waits for the background requests (diagnostics): `shutdown()` does + * @param bool $prepare whether each pass ends with the pipeline of every waiting event: `flush()` does */ - private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true): bool + private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true, bool $prepare = false): bool { if ($this->busy) { return false; @@ -346,14 +366,17 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg $until = $deadline(); while (true) { $this->pumpOnce($tick); - if ($this->waiting->isEmpty() && $this->transport->inFlight() === 0 && $this->channel->pending($background) === 0) { + if ($prepare) { + $this->prepareWaiting(); + } + if ($this->queued() === 0 && $this->transport->inFlight() === 0 && $this->channel->pending($background) === 0) { return true; } if ($this->transport->now() >= $until) { return false; } // Slots freed by the last pump are refilled at once; waiting would sleep with idle slots. - if ($this->waiting->isEmpty() || $this->transport->inFlight() >= self::CONCURRENCY) { + if ($this->queued() === 0 || $this->transport->inFlight() >= self::CONCURRENCY) { $this->wait($until); } } @@ -383,14 +406,18 @@ private function wait(float $until): void } /** - * One pass: starts waiting events while fewer than 5 are in delivery and pumps the transport, then ticks each - * companion (unless `$tick` is false) and pumps the side channel. Never throws. + * 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. */ private function pumpOnce(bool $tick = true): void { try { - while (!$this->waiting->isEmpty() && $this->transport->inFlight() < self::CONCURRENCY) { - $this->dispatch($this->waiting->dequeue()); + while ($this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY) { + $ready = $this->ready->isEmpty() ? $this->prepare($this->waiting->dequeue()) : $this->ready->dequeue(); + if ($ready instanceof ReadyEvent) { + $this->send($ready); + } } $this->transport->pump(); } catch (\Throwable $e) { @@ -424,31 +451,54 @@ private function finishCompanions(): void } } - /** TypeScript `processEvent` stages 0-5, then `sendEvent`. */ - private function dispatch(QueuedEvent $queued): void + /** The pipeline of every waiting event, whatever the free slots (`flush()`): the payloads wait in order. */ + private function prepareWaiting(): void + { + while (!$this->waiting->isEmpty()) { + $ready = $this->prepare($this->waiting->dequeue()); + if ($ready instanceof ReadyEvent) { + $this->ready->enqueue($ready); + } + } + } + + /** TypeScript `processEvent` stages 0-5 (the hooks first), and the body; null when the pipeline dropped the event. */ + private function prepare(QueuedEvent $queued): ?ReadyEvent { $event = $this->processor->process($queued); if (!$event instanceof Event) { - return; + return null; } if (($event->projectId ?? '') === '') { - $this->export($event); - - return; + return new ReadyEvent($event, null); } try { - $body = ($this->encode)($event); + return new ReadyEvent($event, ($this->encode)($event)); } catch (\Throwable $e) { + return new ReadyEvent($event, null, ErrorText::of($e)); + } + } + + /** + * TypeScript `sendEvent`: the exporters alone for an event without a body, else the delivery to the API endpoint of + * this moment (the last `configure()` wins). + */ + private function send(ReadyEvent $ready): void + { + $event = $ready->event; + if ($ready->body === null) { $this->export($event); - Log::write(sprintf('Failed to encode event %s: %s', $event->id, ErrorText::of($e))); + if ($ready->encodeError !== null) { + Log::write(sprintf('Failed to encode event %s: %s', $event->id, $ready->encodeError)); + } return; } foreach (WireMapper::droppedFields($event) as $key) { Log::write(sprintf('WARN: Event %s: %s is not a JSON object or array and was not sent', $event->id, $key)); } - $this->transport->start(Delivery::forEvent($event, $this->endpoint, $body, function () use ($event): void { + $this->transport->start(Delivery::forEvent($event, $this->endpoint, $ready->body, function () use ($event): void { $this->export($event); })); } diff --git a/src/Internal/Delivery/ReadyEvent.php b/src/Internal/Delivery/ReadyEvent.php new file mode 100644 index 0000000..7bd58a6 --- /dev/null +++ b/src/Internal/Delivery/ReadyEvent.php @@ -0,0 +1,27 @@ +assertSame(['ann', 'bob', 'cid'], array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'parameters', 'extra', 'requestInfo', 'headers', 'x-user'), $bodies)); } + /** + * `identify` hooks that read the container, as Laravel code reads the authenticated user. + * + * @return iterable + */ + public static function containerHooks(): iterable + { + yield 'the container\'s current request' => [ContainerRequestIdentity::class]; + yield 'a request injected into the constructor' => [InjectedRequestIdentity::class]; + } + + /** + * Review Focus 4 with a hook that reads the container and an AgentCat API that backs up. The first five deliveries + * stay in flight, so the sixth event waits for a free slot past its own request. Its hook still runs before that + * request's wait returns, in that request: the payload alone waits for the transport. + * + * @param class-string $hook + */ + #[DataProvider('containerHooks')] + #[Group('ac-03-1')] + #[MaximumDuration(2000)] + public function testAContainerReadingHookRunsInItsOwnRequestWhileTheApiIsBackedUp(string $hook): void + { + config(['agentcat.identify' => $hook]); + $this->transport->complete = false; // a slow AgentCat API: the deliveries stay in flight; the clock is simulated + $users = ['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7']; + + foreach ($users as $user) { + $this->transport->complete = $user === 'u7'; // the API answers again before the seventh request + $this->postJson('/mcp/test', $this->toolCall('echo', ['msg' => $user, 'session_id' => 'start']), [...$this->modernHeaders('echo'), 'X-User' => $user])->assertOk(); + } + + $bodies = array_map(static fn(Delivery $d): mixed => Json::decode($d->body), $this->transport->deliveries); + $this->assertSame($users, array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'parameters', 'request', 'params', 'arguments', 'msg'), $bodies)); + $this->assertSame($users, array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'identify_actor_given_id'), $bodies)); + } + /** Review Focus 4: an AgentCat API that does not answer holds each request for the cap, not for the 5 s budget. */ #[Group('ac-03-1')] public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void diff --git a/tests/Support/Laravel/ContainerRequestIdentity.php b/tests/Support/Laravel/ContainerRequestIdentity.php new file mode 100644 index 0000000..94583e5 --- /dev/null +++ b/tests/Support/Laravel/ContainerRequestIdentity.php @@ -0,0 +1,20 @@ +id()` reads the user: the current request of the container when the + * hook runs, here its `X-User` header. It ignores the `ToolCall` it gets. + */ +final class ContainerRequestIdentity +{ + public function __invoke(): Identity + { + return new Identity(Container::getInstance()->make('request')->header('X-User', 'nobody')); + } +} diff --git a/tests/Support/Laravel/InjectedRequestIdentity.php b/tests/Support/Laravel/InjectedRequestIdentity.php new file mode 100644 index 0000000..c49f35c --- /dev/null +++ b/tests/Support/Laravel/InjectedRequestIdentity.php @@ -0,0 +1,22 @@ +request->header('X-User', 'nobody')); + } +} diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index b9b2700..f5dd99a 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -483,6 +483,222 @@ public function testFlushWaitsWhileAllSlotsAreBusy(): void $this->assertSame(1, $transport->waits); } + /** + * A request-end wait is a `flush()`: before it returns, every waiting event's hooks run, also with every slot busy + * and no time left, so that a hook reads its own request (an Octane worker serves the next one in the process). + */ + #[Group('ac-03-1')] + public function testFlushRunsTheHooksOfEveryWaitingEventWhateverTheFreeSlots(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 7; ++$i) { + $queue->add($this->recorded('event-' . $i, $ran)); + } + + $this->assertFalse($queue->flush(0.0)); + + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], $ran->getArrayCopy()); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4'], $this->resourceNames($transport)); + $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); + $this->assertSame(0, $transport->waits); + $transport->complete = true; + $transport->pump(); // the API answers: the slots are free, and two payloads still wait + + $this->assertSame(['queueLength' => 2, 'activeRequests' => 0], $queue->stats()); + $this->assertTrue($queue->hasWork()); + $this->assertTrue($queue->flush(1.0)); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], $this->resourceNames($transport)); + $this->assertCount(7, $ran, 'each hook ran once'); + } + + /** Outside `flush()` an event's hooks still run when it gets a slot: a pump runs none for an event that waits. */ + public function testPumpRunsNoHookOfAnEventThatFindsNoFreeSlot(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 7; ++$i) { + $queue->add($this->recorded('event-' . $i, $ran)); + } + + $queue->pump(); + + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4'], $ran->getArrayCopy()); + $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); + } + + /** The process ends with `shutdown()`: it spends its budget on the events it can start, not on hooks of the others. */ + public function testShutdownRunsNoHookOfAnEventThatFindsNoFreeSlot(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 7; ++$i) { + $queue->add($this->recorded('event-' . $i, $ran)); + } + + $queue->shutdown(); + + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4'], $ran->getArrayCopy()); + $this->assertSame(['Shutting down with 2 events still in queue'], $this->logger->messages()); + } + + public function testEventsThatAFlushPreparedGoBeforeLaterOnes(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 6; ++$i) { + $queue->add($this->recorded('event-' . $i, $ran)); + } + $queue->flush(0.0); + $queue->add($this->recorded('later', $ran)); + $transport->complete = true; + + $queue->pump(); // the API answers the five in flight + $queue->pump(); + + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'later'], $this->resourceNames($transport)); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'later'], $ran->getArrayCopy()); + } + + /** A hook that queues an event during a flush (a custom event): the flush runs that event's hooks too. */ + public function testFlushRunsTheHooksOfAnEventThatAHookQueued(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 5; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->add(new QueuedEvent($this->event('parent'), pending: [ + function () use ($queue, $ran): void { + $ran->append('parent'); + $queue->add($this->recorded('child', $ran)); + }, + ])); + + $queue->flush(0.0); + + $this->assertSame(['parent', 'child'], $ran->getArrayCopy()); + $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); + } + + /** The cap counts the prepared payloads too, and the oldest of them goes first. */ + #[Group('ac-03-2')] + #[MaximumDuration(5000)] + public function testAFullQueueDropsTheOldestPreparedEventFirst(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + for ($i = 0; $i < 10_000; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->flush(0.0); // five in flight, 9 995 prepared + + for ($i = 0; $i < 6; ++$i) { + $queue->add(new QueuedEvent($this->event('new-' . $i))); + } + + $this->assertSame(['Event queue full, dropping oldest event'], $this->logger->messages()); + $this->assertSame(10_000, $queue->stats()['queueLength']); + $transport->complete = true; + $this->assertTrue($queue->flush()); + $names = $this->resourceNames($transport); + $this->assertCount(10_005, $names); + $this->assertSame(['event-4', 'event-6'], array_slice($names, 4, 2)); + $this->assertSame(['event-9999', 'new-0'], array_slice($names, 9_998, 2)); + } + + public function testAPreparedEventGoesToTheEndpointOfTheLastConfigure(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + for ($i = 0; $i < 6; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->flush(0.0); + + $queue->configure('https://later.example.test'); + $transport->complete = true; + $queue->pump(); + $queue->pump(); + + $this->assertCount(6, $transport->deliveries); + $this->assertSame('https://api.agentcat.com/v1/public/events/', $transport->deliveries[4]->url); + $this->assertSame('https://later.example.test/v1/public/events/', $transport->deliveries[5]->url); + } + + public function testAnEventThatAHookDropsDuringAFlushLeavesTheQueue(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + for ($i = 0; $i < 5; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->add(new QueuedEvent($this->event('dropped'), static fn(Event $event): ?Event => null)); + + $queue->flush(0.0); + + $this->assertSame(['Event dropped by redactEvent hook'], $this->logger->messages()); + $this->assertSame(['queueLength' => 0, 'activeRequests' => 5], $queue->stats()); + } + + /** + * An event that goes to the exporters only, prepared by a flush, keeps its place: the exporters get it in its turn, + * and an event that did not encode logs why after its export, as in a pass. + */ + public function testAPreparedEventWithoutABodyIsExportedInItsTurn(): void + { + $transport = new RecordingTransport(complete: false); + $exporter = new RecordingExporter(new \RuntimeException('boom')); + $queue = new EventQueue( + transport: $transport, + exporters: $exporter, + encode: static fn(Event $event): string => $event->resourceName === 'bad' ? throw new \RuntimeException('cannot encode') : '{}', + ); + for ($i = 0; $i < 5; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $telemetryOnly = $this->event('telemetry', null); + $bad = $this->event('bad'); + $queue->add(new QueuedEvent($telemetryOnly)); + $queue->add(new QueuedEvent($bad)); + + $queue->flush(0.0); + + $this->assertSame([], $this->logger->messages(), 'nothing exported yet: each export logs the exporter\'s error'); + $transport->complete = true; + $transport->pump(); + $queue->pump(); + + $this->assertCount(5, $transport->deliveries); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'telemetry', 'bad'], array_map(static fn(Event $event): ?string => $event->resourceName, $exporter->events)); + $this->assertSame([$telemetryOnly, $bad], array_slice($exporter->events, 5)); + $this->assertSame( + [...array_fill(0, 7, 'Telemetry export error: RuntimeException: boom'), 'Failed to encode event ' . $bad->id . ': RuntimeException: cannot encode'], + $this->logger->messages(), + ); + } + + #[Group('ac-03-9')] + public function testShutdownCountsThePreparedEventsStillWaiting(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + for ($i = 0; $i < 7; ++$i) { + $queue->add(new QueuedEvent($this->event())); + } + $queue->flush(0.0); + $queue->add(new QueuedEvent($this->event())); + + $queue->shutdown(); + + $this->assertSame(['Shutting down with 3 events still in queue'], $this->logger->messages()); + } + #[Group('ac-03-9')] public function testShutdownReportsEventsStillWaiting(): void { @@ -1244,6 +1460,20 @@ private function event(string $resourceName = 'tool', ?string $projectId = 'proj return new Event(projectId: $projectId, eventType: 'mcp:tools/call', resourceName: $resourceName); } + /** + * An event whose pending hook appends its resource name to `$ran` when it runs. + * + * @param \ArrayObject $ran + */ + private function recorded(string $resourceName, \ArrayObject $ran): QueuedEvent + { + return new QueuedEvent($this->event($resourceName), pending: [ + static function () use ($resourceName, $ran): void { + $ran->append($resourceName); + }, + ]); + } + /** @return list the `resource_name` of every delivery body */ private function resourceNames(RecordingTransport $transport): array { From 632b4ec3980299a55b7b7b456158d6f089077b6d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:05:04 +0000 Subject: [PATCH 24/30] fix: route every AgentCat environment name through config/agentcat.php for a cached configuration With php artisan config:cache Laravel does not load .env, so the names that config/agentcat.php did not map were lost: DISABLE_DIAGNOSTICS (a privacy opt-out that then failed open), AGENTCAT_DIAGNOSTICS_ENDPOINT and _TOKEN, MCPCAT_API_URL, and, for the shared client, the project ID, the API URL and the diagnostics opt-out; diagnostics had no deployment.environment. The file now maps them (diagnostics_endpoint, diagnostics_token, api_url falls back to MCPCAT_API_URL, disable_diagnostics lists both opt-out names and off wins), and the provider hands the configured values and app.env to every client at boot, as TestMode::configure() does for the switch. --- composer-dependency-analyser.php | 4 +- config/agentcat.php | 12 +- src/Client.php | 5 +- src/Internal/Diagnostics/Diagnostics.php | 6 +- .../Diagnostics/DiagnosticsAttributes.php | 4 +- .../Diagnostics/DiagnosticsEndpoint.php | 9 +- src/Internal/Support/BridgeConfig.php | 57 +++++ src/Internal/Support/ConfigOptions.php | 31 +++ src/Internal/Transport/ApiBaseUrl.php | 8 +- src/Laravel/AgentCatServiceProvider.php | 19 +- src/Laravel/Internal/ServerTracker.php | 2 +- tests/Integration/ClientTrackTest.php | 33 +++ .../Laravel/CachedConfigurationTest.php | 196 ++++++++++++++++++ tests/Support/LaravelMcp/LaravelTestCase.php | 2 + .../Diagnostics/DiagnosticsAttributesTest.php | 11 + .../Diagnostics/DiagnosticsEndpointTest.php | 13 ++ .../Internal/Diagnostics/DiagnosticsTest.php | 14 ++ .../Internal/Support/BridgeConfigTest.php | 81 ++++++++ .../Internal/Support/ConfigOptionsTest.php | 36 ++++ .../Internal/Transport/ApiBaseUrlTest.php | 13 ++ 20 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 src/Internal/Support/BridgeConfig.php create mode 100644 tests/Unit/Internal/Support/BridgeConfigTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 5b9aed8..53e13a6 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -26,8 +26,8 @@ ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreErrorsOnPackageAndPath('laravel/framework', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // 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 both files (LayeringTest). - ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // 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]) // 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. diff --git a/config/agentcat.php b/config/agentcat.php index d980b81..73956d8 100644 --- a/config/agentcat.php +++ b/config/agentcat.php @@ -25,7 +25,8 @@ // The log channel for AgentCat's own lines. Unset: the application's default channel. 'log_channel' => env('AGENTCAT_LOG_CHANNEL'), - 'api_url' => env('AGENTCAT_API_URL'), + // The AgentCat API. Unset: AgentCat's. MCPCAT_API_URL is the TypeScript SDK's name. + 'api_url' => env('AGENTCAT_API_URL', env('MCPCAT_API_URL')), 'enable_report_missing' => true, 'enable_tracing' => true, @@ -45,6 +46,13 @@ // Your own telemetry tools: name => ['type' => 'otlp'|'datadog'|'sentry'|'posthog', …]. A null entry is skipped. 'exporters' => null, - 'disable_diagnostics' => env('AGENTCAT_DISABLE_DIAGNOSTICS'), + // 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 is the TypeScript SDK's name. + 'disable_diagnostics' => [env('AGENTCAT_DISABLE_DIAGNOSTICS'), env('DISABLE_DIAGNOSTICS')], + + // Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs) and its bearer token. Unset: AgentCat's. + 'diagnostics_endpoint' => env('AGENTCAT_DIAGNOSTICS_ENDPOINT'), + 'diagnostics_token' => env('AGENTCAT_DIAGNOSTICS_TOKEN'), + 'disable_signal_handlers' => false, ]; diff --git a/src/Client.php b/src/Client.php index dafcbc4..adc7384 100644 --- a/src/Client.php +++ b/src/Client.php @@ -16,6 +16,7 @@ use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\QueuedEvent; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Support\Env; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Support\TestMode; @@ -87,7 +88,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio return $server; // D44 AGENTCAT_ENABLED, else D31: nothing in test environments unless fake() } $options ??= new Options(); - $projectId ??= Env::get('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only + $projectId ??= BridgeConfig::env('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only; a bridge's configuration first if ($this->telemetry) { // TS: the first statement of track(), before the server is checked; the first call in the process decides. Diagnostics::init($projectId, $options->disableDiagnostics, $this->publisher->queue()); @@ -110,7 +111,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio if (!$tracked) { $library->inspect($server); // a tracked Builder can be built by now: tracking it again changes nothing } - if (($options->apiBaseUrl ?? '') !== '' || Env::get('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) { + if (($options->apiBaseUrl ?? '') !== '' || BridgeConfig::env('AGENTCAT_API_URL') !== null || Env::get('MCPCAT_API_URL') !== null) { $this->publisher->queue()->configure($options->apiBaseUrl); // before the already-tracked check; the last call wins } $project = $projectId !== '' ? $projectId : '(telemetry-only)'; diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index 52361e9..ce14251 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Delivery\QueueCompanion; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Support\Env; use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Transport\HttpRequest; @@ -72,7 +73,8 @@ private function __construct( /** * TS `initDiagnostics`: the first call in the process decides; a later call changes nothing. A later - * `$disabled = true` while diagnostics run logs one line, once. Never throws. + * `$disabled = true` while diagnostics run logs one line, once. A framework bridge's configuration can turn them off + * too (`BridgeConfig::diagnosticsOff()`), never on. Never throws. * * @param (\Closure(): bool)|null $inTestEnvironment default `TestMode::inTestEnvironment()` (P5) * @param (\Closure(): float)|null $clock tests: monotonic seconds @@ -91,7 +93,7 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue } self::$initialized = true; $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); - if (!DiagnosticsSwitch::enabled($disabled, Env::get('AGENTCAT_DISABLE_DIAGNOSTICS'), Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { + if (!DiagnosticsSwitch::enabled($disabled || BridgeConfig::diagnosticsOff(), Env::get('AGENTCAT_DISABLE_DIAGNOSTICS'), Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { return; } $diagnostics = new self( diff --git a/src/Internal/Diagnostics/DiagnosticsAttributes.php b/src/Internal/Diagnostics/DiagnosticsAttributes.php index d753233..e153b63 100644 --- a/src/Internal/Diagnostics/DiagnosticsAttributes.php +++ b/src/Internal/Diagnostics/DiagnosticsAttributes.php @@ -5,7 +5,7 @@ namespace AgentCat\Internal\Diagnostics; use AgentCat\Internal\SdkVersion; -use AgentCat\Internal\Support\Env; +use AgentCat\Internal\Support\BridgeConfig; /** * The OTLP attributes of diagnostics (D48): PHP values in the Node vocabulary. Empty values are left out (TS `attr()`). @@ -40,7 +40,7 @@ public static function resource(string $projectId, ?\Closure $functionExists = n ['os.type', self::osType(PHP_OS_FAMILY)], ['os.version', $uname ? php_uname('r') : null], ['host.arch', $uname ? self::arch(php_uname('m')) : null], - ['deployment.environment', Env::get('APP_ENV')], + ['deployment.environment', BridgeConfig::env('APP_ENV')], // Laravel: `app.env` ]); } diff --git a/src/Internal/Diagnostics/DiagnosticsEndpoint.php b/src/Internal/Diagnostics/DiagnosticsEndpoint.php index b855f73..0995cc0 100644 --- a/src/Internal/Diagnostics/DiagnosticsEndpoint.php +++ b/src/Internal/Diagnostics/DiagnosticsEndpoint.php @@ -5,12 +5,13 @@ namespace AgentCat\Internal\Diagnostics; use AgentCat\Internal\Constants; -use AgentCat\Internal\Support\Env; +use AgentCat\Internal\Support\BridgeConfig; /** * Where diagnostics go, read at each flush (TS `resolveEndpoint`, `resolveToken`). An empty variable counts as unset. * The names carry the `AGENTCAT_` prefix (D50, a divergence from TypeScript): an application's own `DIAGNOSTICS_ENDPOINT` - * or `DIAGNOSTICS_TOKEN` is never read, so it never sends AgentCat's batches elsewhere or its secret to AgentCat. + * or `DIAGNOSTICS_TOKEN` is never read, so it never sends AgentCat's batches elsewhere or its secret to AgentCat. A + * framework bridge's configured value comes first (`BridgeConfig`). * * @internal */ @@ -19,7 +20,7 @@ final class DiagnosticsEndpoint /** `AGENTCAT_DIAGNOSTICS_ENDPOINT` or the default, trailing slashes removed, ending in `/v1/logs`. */ public static function url(): string { - $base = rtrim(Env::get('AGENTCAT_DIAGNOSTICS_ENDPOINT') ?? Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, '/'); + $base = rtrim(BridgeConfig::env('AGENTCAT_DIAGNOSTICS_ENDPOINT') ?? Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, '/'); return str_ends_with($base, '/v1/logs') ? $base : $base . '/v1/logs'; } @@ -27,6 +28,6 @@ public static function url(): string /** `AGENTCAT_DIAGNOSTICS_TOKEN` or the public default token. */ public static function token(): string { - return Env::get('AGENTCAT_DIAGNOSTICS_TOKEN') ?? Constants::DEFAULT_DIAGNOSTICS_TOKEN; + return BridgeConfig::env('AGENTCAT_DIAGNOSTICS_TOKEN') ?? Constants::DEFAULT_DIAGNOSTICS_TOKEN; } } diff --git a/src/Internal/Support/BridgeConfig.php b/src/Internal/Support/BridgeConfig.php new file mode 100644 index 0000000..37aafd6 --- /dev/null +++ b/src/Internal/Support/BridgeConfig.php @@ -0,0 +1,57 @@ + environment name => the value of the configuration */ + private static array $values = []; + + private static bool $diagnosticsOff = false; + + /** + * @param array $values environment name => the value the configuration holds for it; + * null or '' (`env()` of an unset variable) leaves it to the + * environment + * @param bool $diagnosticsOff whether the configuration turns diagnostics off + */ + public static function configure(array $values, bool $diagnosticsOff): void + { + self::$values = []; + foreach ($values as $name => $value) { + if ($value !== null && $value !== '') { + self::$values[$name] = $value; + } + } + self::$diagnosticsOff = $diagnosticsOff; + } + + /** The configured value of an environment name, else `Env::get()`. */ + public static function env(string $name): ?string + { + return self::$values[$name] ?? Env::get($name); + } + + /** Whether the configuration turns diagnostics off. It never turns them on: off wins (D50). */ + public static function diagnosticsOff(): bool + { + return self::$diagnosticsOff; + } + + /** Tests only: forgets the configured values. */ + public static function reset(): void + { + self::$values = []; + self::$diagnosticsOff = false; + } +} diff --git a/src/Internal/Support/ConfigOptions.php b/src/Internal/Support/ConfigOptions.php index 19c4c9c..333d3f8 100644 --- a/src/Internal/Support/ConfigOptions.php +++ b/src/Internal/Support/ConfigOptions.php @@ -4,6 +4,7 @@ namespace AgentCat\Internal\Support; +use AgentCat\Internal\Diagnostics\DiagnosticsSwitch; use AgentCat\Internal\Logging\Log; use AgentCat\Options; @@ -37,6 +38,8 @@ final class ConfigOptions private const array HOOKS = ['resolveSessionId', 'identify', 'redactSensitiveInformation', 'redactEvent', 'eventTags', 'eventProperties']; + private const string BAD_DIAGNOSTICS_VALUE = 'WARN: AgentCat config "agentcat.disable_diagnostics" is "%s", not one of true, 1, yes, on, false, 0, no, off; the value is treated as true'; + /** * Never throws: an unknown key or a bad value logs one line and the default is used (`Options::fromArray()`). * @@ -60,6 +63,11 @@ public static function options(array $config, array $bridgeKeys, \Closure $resol if ($value === null) { continue; // env() of an unset variable: the default } + if ($option === 'disableDiagnostics') { + $mapped[$option] = self::diagnosticsOff($value, log: true); + + continue; + } $mapped[$option] = in_array($option, self::HOOKS, true) && is_string($value) && !is_callable($value) ? self::named($value, $resolve) : $value; @@ -68,6 +76,29 @@ public static function options(array $config, array $bridgeKeys, \Closure $resol return Options::fromArray($mapped); } + /** + * The `disable_diagnostics` value of a bridge's configuration: one value, or a list of them (config/agentcat.php + * lists `AGENTCAT_DISABLE_DIAGNOSTICS` and the TypeScript name `DISABLE_DIAGNOSTICS`), each in the words of + * `DiagnosticsSwitch::option()`. True when one of them turns diagnostics off: off wins (D50). Null is unset. Any + * other value turns them off too, as an opt-out fails closed, and with `$log` it logs one line. + */ + public static function diagnosticsOff(mixed $value, bool $log = false): bool + { + $off = false; + foreach (is_array($value) ? $value : [$value] as $entry) { + if ($entry === null) { + continue; + } + $disabled = DiagnosticsSwitch::option($entry); + if ($disabled === null && $log) { + Log::write(sprintf(self::BAD_DIAGNOSTICS_VALUE, is_scalar($entry) ? (string) $entry : get_debug_type($entry))); + } + $off = $off || ($disabled ?? true); + } + + return $off; + } + /** * A hook that resolves its name when it runs. What the name resolves to must be callable; the hook guard logs * anything it throws (D32). diff --git a/src/Internal/Transport/ApiBaseUrl.php b/src/Internal/Transport/ApiBaseUrl.php index c376754..f72fadb 100644 --- a/src/Internal/Transport/ApiBaseUrl.php +++ b/src/Internal/Transport/ApiBaseUrl.php @@ -5,6 +5,7 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Constants; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Support\Env; /** @@ -17,10 +18,13 @@ final class ApiBaseUrl /** The trailing slash is required by the server. */ public const string EVENTS_PATH = '/v1/public/events/'; - /** Option > `AGENTCAT_API_URL` > `MCPCAT_API_URL` > default; empty strings are skipped, trailing `/` trimmed. */ + /** + * Option > `AGENTCAT_API_URL` (a framework bridge's configured value first, `BridgeConfig`) > `MCPCAT_API_URL` > + * default; empty strings are skipped, trailing `/` trimmed. + */ public static function resolve(?string $option = null): string { - foreach ([$option, Env::get('AGENTCAT_API_URL'), Env::get('MCPCAT_API_URL')] as $candidate) { + foreach ([$option, BridgeConfig::env('AGENTCAT_API_URL'), Env::get('MCPCAT_API_URL')] as $candidate) { if ($candidate !== null && $candidate !== '') { return rtrim($candidate, '/'); } diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index 81320c0..74961d3 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -6,6 +6,8 @@ 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; @@ -39,7 +41,17 @@ public function boot(): void } // `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. - TestMode::configure(TestMode::word($this->app->make('config')->get('agentcat.enabled'))); + $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'))); 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 { @@ -49,4 +61,9 @@ 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/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index 7e7c28b..415b187 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -33,7 +33,7 @@ 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']; + public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude', 'projects', 'log_channel', 'diagnostics_endpoint', 'diagnostics_token']; private ?Client $client = null; diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index e06975b..e1de152 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -17,6 +17,7 @@ use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Tools\GetMoreTools; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\Delivery; @@ -107,6 +108,7 @@ protected function tearDown(): void } TrackedServers::resetIncomplete(); // a failed track() marks the process TrackedServers::resetSkipped(); // so does a track() while AgentCat is off + BridgeConfig::reset(); Log::reset(); $this->environment->restore(); } @@ -341,6 +343,22 @@ public function testANullProjectReadsAgentCatProjectId(): void $this->assertCount(1, $this->transport->deliveries); } + /** A bridge's configured project (a cached Laravel configuration) comes before AGENTCAT_PROJECT_ID. */ + #[Group('ac-01-5')] + public function testANullProjectReadsTheConfiguredProjectFirst(): void + { + $project = 'proj_' . str_repeat('c', 27); + putenv('AGENTCAT_PROJECT_ID=proj_' . str_repeat('b', 27)); + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => $project], false); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + + $this->client->track($builder); + + $this->harness($builder)->callTool('echo', ['msg' => 'hi']); + $this->assertSame($project, $this->event()->projectId); + } + #[Group('ac-01-6')] public function testAnEmptyProjectStaysTelemetryOnlyWithAgentCatProjectIdSet(): void { @@ -522,6 +540,21 @@ public function testAnApiUrlVariableSetBeforeTrackIsUsed(string $variable): void $this->assertSame(['https://env.test/v1/public/events/'], $this->urls()); } + /** A bridge's configured URL (a cached Laravel configuration) is set by a track() without the option, like a variable. */ + public function testAConfiguredApiUrlIsUsedByATrackWithoutTheOption(): void + { + $this->queue->configure('https://earlier.test'); // an earlier track() with its own option + BridgeConfig::configure(['AGENTCAT_API_URL' => 'https://configured.test'], false); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + + $this->client->track($builder, self::PROJECT); + $this->harness($builder)->callTool('echo', ['msg' => 'hi']); + + $this->assertTrue($this->queue->flush()); + $this->assertSame(['https://configured.test/v1/public/events/'], $this->urls()); + } + public function testUnderPhpUnitTheDefaultClientLeavesTheBuilderAlone(): void { $client = new Client(ClientSeams::of($this->queue)); diff --git a/tests/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php index 29e4bf7..f4abda1 100644 --- a/tests/Integration/Laravel/CachedConfigurationTest.php +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -6,13 +6,22 @@ use AgentCat\AgentCat; use AgentCat\Client; +use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Diagnostics\Diagnostics; +use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Transport\Delivery; +use AgentCat\Internal\Transport\HttpRequest; +use AgentCat\Tests\Integration\Support\MockCollector; +use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\Laravel\BridgeTestCase; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; use AgentCat\Tests\Support\LaravelMcp\LaravelHarness; use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; +use AgentCat\Tests\Support\McpSdk\TrackingFixture; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\RecordingHttpChannel; use Illuminate\Container\Container; use Orchestra\Testbench\Attributes\DefineEnvironment; @@ -26,15 +35,34 @@ final class CachedConfigurationTest extends BridgeTestCase private const string PUBLISHED = "Published custom event for session ses_ccccccccccccccccccccccccccc with type 'agentcat:custom'"; + /** What `.env` held when the cache was made: AgentCat on, and the project. */ + private const array DOT_ENV = ['AGENTCAT_ENABLED' => 'true', 'AGENTCAT_PROJECT_ID' => LaravelTrackingFixture::PROJECT]; + private MemoryLogger $logger; + /** The diagnostics variables of the process; a test turns diagnostics on as a platform variable can. */ + private ?Environment $diagnosticsEnvironment = null; + + /** The collector `.env` names for diagnostics: a closed local port (ruling P8). */ + private string $cachedCollector = ''; + #[\Override] protected function setUp(): void { + $this->diagnosticsEnvironment = Environment::clear('AGENTCAT_DISABLE_DIAGNOSTICS', 'DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'MCPCAT_API_URL', 'APP_ENV'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); // tests/bootstrap.php's value: a test that wants diagnostics says so + $this->cachedCollector = MockCollector::closedPortUrl() . '/cached'; parent::setUp(); Log::setLogger($this->logger = new MemoryLogger()); // nothing goes to agentcat.log in the test HOME } + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->diagnosticsEnvironment?->restore(); + } + /** `.env` sets the project; the published file maps the server to `env('AGENTCAT_BILLING_PROJECT_ID')`, which it does not set. */ public function defineANullProjectEntry(): void { @@ -63,6 +91,63 @@ public function defineCachedUnset(): void putenv('AGENTCAT_ENABLED=true'); } + public function defineCachedProject(): void + { + self::cachedConfiguration(self::DOT_ENV); + } + + public function defineCachedApiUrl(): void + { + self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_API_URL' => 'https://cached.example.test']); + } + + /** The name the TypeScript SDK's users know, which `api_url` falls back to. */ + public function defineCachedLegacyApiUrl(): void + { + self::cachedConfiguration([...self::DOT_ENV, 'MCPCAT_API_URL' => 'https://legacy.example.test']); + } + + /** Diagnostics turned on by a variable of the platform: PHPUnit is a test environment, where they are off. */ + public function defineDiagnosticsOn(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration(self::DOT_ENV); + } + + public function defineCachedDiagnosticsEndpoint(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->cachedCollector]); + } + + public function defineCachedDiagnosticsToken(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_DIAGNOSTICS_TOKEN' => 'dgk_cached']); + } + + /** Laravel's own cached `app.env`: the process holds no APP_ENV. */ + public function defineCachedAppEnvironment(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration(self::DOT_ENV); + config(['app.env' => 'staging']); + } + + /** `.env` holds the TypeScript name of the opt-out; the platform turns diagnostics on: off wins (D50). */ + public function defineCachedTypeScriptOptOut(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration([...self::DOT_ENV, 'DISABLE_DIAGNOSTICS' => 'true']); + } + + /** `.env` holds the opt-out; the platform turns diagnostics on: the cached value decides first. */ + public function defineCachedOptOut(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'true']); + } + /** `.env` holds a word that is not a D44 word; the process turns AgentCat on. */ public function defineCachedUnknown(): void { @@ -128,4 +213,115 @@ public function testAnUnknownCachedWordLeavesTheProcessToDecide(): void $this->assertSame(self::SESSION, $this->deliveredField('session_id')); } + + /** I2 (c): an mcp/sdk Builder tracked with the shared client and no project ID gets the cached one. */ + #[DefineEnvironment('defineCachedProject')] + public function testTheSharedClientTracksWithTheCachedProjectId(): void + { + AgentCat::track(TrackingFixture::builder()); + + $started = array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_starts_with($line, 'AgentCat setup started'))); + $this->assertCount(1, $started); + $this->assertStringStartsWith('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk mcp-sdk/builder', $started[0]); + $this->assertNotContains('Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.', $this->logger->messages()); + } + + #[DefineEnvironment('defineCachedApiUrl')] + public function testTheSharedClientSendsToTheCachedApiUrl(): void + { + AgentCat::track(TrackingFixture::builder()); + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + + $this->assertSame(['https://cached.example.test/v1/public/events/'], $this->urls()); + } + + #[DefineEnvironment('defineCachedLegacyApiUrl')] + public function testTheSharedClientSendsToTheCachedLegacyApiUrl(): void + { + AgentCat::track(TrackingFixture::builder()); + AgentCat::publishCustomEvent(self::SESSION, LaravelTrackingFixture::PROJECT); + + $this->assertSame(['https://legacy.example.test/v1/public/events/'], $this->urls()); + } + + #[DefineEnvironment('defineCachedDiagnosticsEndpoint')] + public function testDiagnosticsGoToTheCachedEndpoint(): void + { + $this->assertSame($this->cachedCollector . '/v1/logs', $this->diagnosticsRequest()->url); + } + + #[DefineEnvironment('defineCachedDiagnosticsToken')] + public function testDiagnosticsCarryTheCachedToken(): void + { + $this->assertSame('Bearer dgk_cached', $this->diagnosticsRequest()->headers['Authorization'] ?? null); + } + + #[DefineEnvironment('defineCachedAppEnvironment')] + public function testDiagnosticsCarryTheCachedAppEnvironment(): void + { + $attributes = JsonAssert::at(Json::decode($this->diagnosticsRequest()->body), 'resourceLogs', 0, 'resource', 'attributes'); + $this->assertIsList($attributes); + $pairs = array_column(array_map(static fn(mixed $attribute): array => [JsonAssert::at($attribute, 'key'), JsonAssert::at($attribute, 'value', 'stringValue')], $attributes), 1, 0); + + $this->assertSame('staging', $pairs['deployment.environment'] ?? null); + } + + #[DefineEnvironment('defineDiagnosticsOn')] + public function testWithoutACachedOptOutTheDiagnosticsRun(): void + { + $request = $this->diagnosticsRequest(); + + $this->assertSame(getenv('AGENTCAT_DIAGNOSTICS_ENDPOINT') . '/v1/logs', $request->url); // tests/bootstrap.php's closed port + } + + #[DefineEnvironment('defineCachedTypeScriptOptOut')] + public function testACachedTypeScriptOptOutKeepsTheTrackerOff(): void + { + (new LaravelHarness(ConformanceServer::class))->listTools(); + + $this->assertFalse(Diagnostics::enabled()); + } + + #[DefineEnvironment('defineCachedTypeScriptOptOut')] + public function testACachedTypeScriptOptOutKeepsTheSharedClientOff(): void + { + AgentCat::track(TrackingFixture::builder()); + + $this->assertFalse(Diagnostics::enabled()); + } + + #[DefineEnvironment('defineCachedOptOut')] + public function testACachedOptOutKeepsTheSharedClientOff(): void + { + AgentCat::track(TrackingFixture::builder()); + + $this->assertFalse(Diagnostics::enabled()); + } + + /** + * The URLs of the events the API got, after a flush. + * + * @return list + */ + private function urls(): array + { + $this->assertTrue(EventQueue::global()->flush(1.0)); + + return array_map(static fn(Delivery $delivery): string => $delivery->url, $this->transport->deliveries); + } + + /** The one diagnostics request after the shared client tracked a Builder and the queue shut down. */ + private function diagnosticsRequest(): HttpRequest + { + $http = new RecordingHttpChannel(); + EventQueue::setGlobal(new EventQueue(transport: $this->transport, channel: $http)); + + AgentCat::track(TrackingFixture::builder()); + EventQueue::global()->shutdown(); + + $this->assertTrue(Diagnostics::enabled()); + $this->assertCount(1, $http->requests); + + return $http->requests[0]; + } } diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 5ae5d78..11820df 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; @@ -91,6 +92,7 @@ protected function tearDown(): void { ToolCalls::reset(); TestMode::reset(); // also the configured switch that AgentCat's service provider sets + BridgeConfig::reset(); // and the configured values Log::reset(); Diagnostics::reset(); EventQueue::setGlobal(null); diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php index dbbf60f..a47aefc 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php @@ -7,6 +7,7 @@ use AgentCat\Internal\Diagnostics\DiagnosticsAttributes; use AgentCat\Internal\Json\Json; use AgentCat\Internal\SdkVersion; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\JsonAssert; use PHPUnit\Framework\Attributes\DataProvider; @@ -27,6 +28,7 @@ protected function setUp(): void #[\Override] protected function tearDown(): void { + BridgeConfig::reset(); $this->environment->restore(); } @@ -107,6 +109,15 @@ public function testAppEnvBecomesTheDeploymentEnvironmentAtTheEnd(): void $this->assertSame('staging', $pairs['deployment.environment'] ?? null); } + /** A cached Laravel configuration hides `.env` from the process: Laravel's `app.env` comes first. */ + public function testTheConfiguredAppEnvironmentComesBeforeAppEnv(): void + { + putenv('APP_ENV=local'); + BridgeConfig::configure(['APP_ENV' => 'production'], false); + + $this->assertSame('production', $this->pairs(DiagnosticsAttributes::resource('proj_x'))['deployment.environment'] ?? null); + } + #[Group('ac-06-56')] public function testWithoutAProjectTheInstallIdComesFirst(): void { diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php index c8c7648..1c4d6a5 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Constants; use AgentCat\Internal\Diagnostics\DiagnosticsEndpoint; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use PHPUnit\Framework\Attributes\DataProvider; @@ -26,6 +27,7 @@ protected function setUp(): void #[\Override] protected function tearDown(): void { + BridgeConfig::reset(); $this->environment->restore(); } @@ -38,6 +40,17 @@ public function testWithoutVariablesTheDefaultCollectorAndTokenAreUsed(): void $this->assertSame('dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80', DiagnosticsEndpoint::token()); } + /** A cached Laravel configuration hides `.env` from the process: the configured collector and token come first. */ + public function testTheConfiguredCollectorAndTokenComeBeforeTheVariables(): void + { + putenv('AGENTCAT_DIAGNOSTICS_TOKEN=dgk_env'); + $collector = MockCollector::closedPortUrl() . '/configured/'; + BridgeConfig::configure(['AGENTCAT_DIAGNOSTICS_ENDPOINT' => $collector, 'AGENTCAT_DIAGNOSTICS_TOKEN' => 'dgk_configured'], false); + + $this->assertSame(rtrim($collector, '/') . '/v1/logs', DiagnosticsEndpoint::url()); + $this->assertSame('dgk_configured', DiagnosticsEndpoint::token()); + } + /** @return iterable */ public static function bases(): iterable { diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index b1f2306..e56610e 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -12,6 +12,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Logging\LogLine; use AgentCat\Internal\SdkVersion; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\HttpChannel; use AgentCat\Internal\Transport\HttpRequest; @@ -67,6 +68,7 @@ protected function setUp(): void protected function tearDown(): void { Diagnostics::reset(); + BridgeConfig::reset(); Log::reset(); $this->environment->restore(); } @@ -445,6 +447,18 @@ public function testTheUnprefixedVariableCanOnlyTurnDiagnosticsOff(): void $this->assertFalse(Diagnostics::enabled(), 'off wins over AGENTCAT_DISABLE_DIAGNOSTICS=false'); } + /** A bridge's configuration (a cached Laravel configuration) can turn diagnostics off, also over a false variable. */ + public function testAConfiguredOptOutTurnsDiagnosticsOff(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + BridgeConfig::configure([], true); + + $this->init('p', testEnvironment: false); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertSame([], $this->logger->messages()); + } + /** A10 (D50): an application's own `DIAGNOSTICS_ENDPOINT` and `DIAGNOSTICS_TOKEN` never reach AgentCat. */ #[Group('ac-06-55')] public function testTheApplicationsOwnCollectorVariablesAreIgnored(): void diff --git a/tests/Unit/Internal/Support/BridgeConfigTest.php b/tests/Unit/Internal/Support/BridgeConfigTest.php new file mode 100644 index 0000000..198ff89 --- /dev/null +++ b/tests/Unit/Internal/Support/BridgeConfigTest.php @@ -0,0 +1,81 @@ +environment = Environment::clear('AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL'); + } + + #[\Override] + protected function tearDown(): void + { + BridgeConfig::reset(); + $this->environment->restore(); + } + + public function testWithoutAConfigurationTheEnvironmentDecides(): void + { + putenv('AGENTCAT_PROJECT_ID=proj_env'); + + $this->assertSame('proj_env', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertNull(BridgeConfig::env('AGENTCAT_API_URL')); + $this->assertFalse(BridgeConfig::diagnosticsOff()); + } + + public function testAConfiguredValueComesBeforeTheEnvironment(): void + { + putenv('AGENTCAT_PROJECT_ID=proj_env'); + + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => 'proj_configured', 'AGENTCAT_API_URL' => 'https://configured.test'], true); + + $this->assertSame('proj_configured', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertSame('https://configured.test', BridgeConfig::env('AGENTCAT_API_URL')); + $this->assertTrue(BridgeConfig::diagnosticsOff()); + } + + /** `env()` of an unset variable gives null, or '' for an empty one: the environment still decides. */ + public function testANullOrEmptyValueLeavesTheNameToTheEnvironment(): void + { + putenv('AGENTCAT_PROJECT_ID=proj_env'); + putenv('AGENTCAT_API_URL=https://env.test'); + + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => null, 'AGENTCAT_API_URL' => ''], false); + + $this->assertSame('proj_env', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertSame('https://env.test', BridgeConfig::env('AGENTCAT_API_URL')); + } + + public function testTheLastCallWins(): void + { + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => 'proj_first', 'AGENTCAT_API_URL' => 'https://first.test'], true); + + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => 'proj_second'], false); + + $this->assertSame('proj_second', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertNull(BridgeConfig::env('AGENTCAT_API_URL')); + $this->assertFalse(BridgeConfig::diagnosticsOff()); + } + + public function testResetForgetsTheConfiguration(): void + { + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => 'proj_configured'], true); + + BridgeConfig::reset(); + + $this->assertNull(BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertFalse(BridgeConfig::diagnosticsOff()); + } +} diff --git a/tests/Unit/Internal/Support/ConfigOptionsTest.php b/tests/Unit/Internal/Support/ConfigOptionsTest.php index af99297..ab7b33b 100644 --- a/tests/Unit/Internal/Support/ConfigOptionsTest.php +++ b/tests/Unit/Internal/Support/ConfigOptionsTest.php @@ -168,6 +168,42 @@ public function testBadValuesLogOneLineEachAndNeverThrow(): void $this->assertInstanceOf(\Closure::class, $options->identify); // fails when it runs, inside the hook guard (D32) } + /** @return iterable */ + public static function diagnosticsValues(): iterable + { + yield 'unset' => [null, false]; + yield 'true' => [true, true]; + yield 'the word off: on' => ['off', false]; + yield 'the file\'s list, both unset' => [[null, null], false]; + yield 'the file\'s list, AGENTCAT_DISABLE_DIAGNOSTICS=true' => [['true', null], true]; + yield 'the file\'s list, DISABLE_DIAGNOSTICS=1' => [[null, '1'], true]; + yield 'off wins: false and yes' => [[false, 'yes'], true]; + yield 'off wins: yes and false' => [['yes', false], true]; + yield 'both false' => [[false, 'no'], false]; + } + + /** D50 in config/agentcat.php: each value can turn diagnostics off, and off wins. */ + #[DataProvider('diagnosticsValues')] + public function testDisableDiagnosticsTakesOneValueOrAListWhereOffWins(mixed $value, bool $off): void + { + $this->assertSame($off, ConfigOptions::diagnosticsOff($value)); + $this->assertSame($off, ConfigOptions::options(['disable_diagnostics' => $value], ServerTracker::BRIDGE_KEYS, $this->resolve)->disableDiagnostics); + $this->assertSame([], $this->logger->messages()); + } + + /** An opt-out fails closed: an unknown value turns diagnostics off, and the options log it once, by the key's name. */ + public function testAnUnknownDisableDiagnosticsValueTurnsThemOff(): void + { + $this->assertTrue(ConfigOptions::diagnosticsOff([null, 'maybe'])); + $this->assertSame([], $this->logger->messages(), 'the provider reads it without a line'); + + $this->assertTrue(ConfigOptions::options(['disable_diagnostics' => ['false', 'maybe', [1]]], ServerTracker::BRIDGE_KEYS, $this->resolve)->disableDiagnostics); + $this->assertSame([ + 'WARN: AgentCat config "agentcat.disable_diagnostics" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the value is treated as true', + 'WARN: AgentCat config "agentcat.disable_diagnostics" is "array", not one of true, 1, yes, on, false, 0, no, off; the value is treated as true', + ], $this->logger->messages()); + } + public function testAnUnknownKeyLeavesTheKeysAfterIt(): void { $options = ConfigOptions::options([0 => 'stray', 'enable_tracing' => false], ServerTracker::BRIDGE_KEYS, $this->resolve); diff --git a/tests/Unit/Internal/Transport/ApiBaseUrlTest.php b/tests/Unit/Internal/Transport/ApiBaseUrlTest.php index ebb7fa5..fc24c03 100644 --- a/tests/Unit/Internal/Transport/ApiBaseUrlTest.php +++ b/tests/Unit/Internal/Transport/ApiBaseUrlTest.php @@ -4,6 +4,7 @@ namespace AgentCat\Tests\Unit\Internal\Transport; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Transport\ApiBaseUrl; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -28,6 +29,7 @@ protected function setUp(): void #[\Override] protected function tearDown(): void { + BridgeConfig::reset(); foreach ($this->saved as $name => $saved) { putenv($saved['getenv'] === false ? $name : $name . '=' . $saved['getenv']); unset($_ENV[$name], $_SERVER[$name]); @@ -68,6 +70,17 @@ public function testAgentCatVariableBeatsMcpCatVariable(): void $this->assertSame('https://a.test', ApiBaseUrl::resolve()); } + /** A bridge's configuration (a cached Laravel configuration) comes after the option, before both variables. */ + public function testAConfiguredUrlComesAfterTheOptionAndBeforeTheVariables(): void + { + putenv('AGENTCAT_API_URL=https://a.test'); + putenv('MCPCAT_API_URL=https://b.test'); + BridgeConfig::configure(['AGENTCAT_API_URL' => 'https://configured.test/'], false); + + $this->assertSame('https://configured.test', ApiBaseUrl::resolve()); + $this->assertSame('https://opt.test', ApiBaseUrl::resolve('https://opt.test')); + } + #[Group('ac-01-57')] #[Group('ac-07-3')] public function testMcpCatVariableAlone(): void From 53873d4d64e9e56a6a51b85422dc6b52a650b55c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:08:56 +0000 Subject: [PATCH 25/30] feat: log the Laravel bridge's lines to storage/logs/agentcat.log by default D60. laravel/mcp builds a server per HTTP request, so AgentCat's setup lines, and an ERROR line for a tool that declares its own session_id, repeated per request in the application's default channel and reached its error trackers. Without log_channel, the bridge now builds a single channel at storage/logs/agentcat.log through Laravel's logging, the analogue of TypeScript's ~/mcpcat.log; log_channel still routes the lines into any application channel. --- config/agentcat.php | 2 +- src/Laravel/Internal/ServerTracker.php | 19 ++++++---- tests/E2E/Laravel/bootstrap.php | 5 +-- tests/E2E/LaravelEndToEndTest.php | 26 +++++++++++--- .../Laravel/CachedConfigurationTest.php | 18 ++++++++++ .../Laravel/ServiceProviderTest.php | 35 +++++++++++++++++-- tests/Support/Laravel/BridgeTestCase.php | 8 +++++ 7 files changed, 95 insertions(+), 18 deletions(-) diff --git a/config/agentcat.php b/config/agentcat.php index 73956d8..94c43cf 100644 --- a/config/agentcat.php +++ b/config/agentcat.php @@ -22,7 +22,7 @@ // App\Mcp\Servers\BillingServer::class => env('AGENTCAT_BILLING_PROJECT_ID'), ], - // The log channel for AgentCat's own lines. Unset: the application's default channel. + // The log channel for AgentCat's own lines, for example stack. Unset: their own file, storage/logs/agentcat.log. 'log_channel' => env('AGENTCAT_LOG_CHANNEL'), // The AgentCat API. Unset: AgentCat's. MCPCAT_API_URL is the TypeScript SDK's name. diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index 415b187..b879644 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -96,15 +96,22 @@ private function matches(Server $server, mixed $classes): ?string } /** - * D30: AgentCat's lines go to the configured log channel, else to the default one. 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. The - * channel is resolved only while no logger is chosen: Laravel does not keep the emergency logger of a channel it - * cannot create, and logs that failure each time it is asked. + * D30, D60: AgentCat's lines go to the configured log channel, else to their own file, a `single` channel at + * `storage/logs/agentcat.log` built through Laravel's logging (TypeScript writes `~/mcpcat.log`): laravel/mcp builds + * a server per HTTP request, 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. The channel is resolved only while no logger is chosen: + * Laravel does not keep the emergency logger of a channel it cannot create, and logs that failure each time it is + * asked. */ private function adoptLogger(Container $app, mixed $channel): void { - if (!Log::hasLogger()) { - Log::adopt($app->make('log')->channel(is_string($channel) && $channel !== '' ? $channel : null)); + if (Log::hasLogger()) { + return; } + $log = $app->make('log'); + Log::adopt(is_string($channel) && $channel !== '' + ? $log->channel($channel) + : $log->build(['driver' => 'single', 'name' => 'agentcat', 'path' => storage_path('logs/agentcat.log')])); } } diff --git a/tests/E2E/Laravel/bootstrap.php b/tests/E2E/Laravel/bootstrap.php index 76348eb..8ef4cce 100644 --- a/tests/E2E/Laravel/bootstrap.php +++ b/tests/E2E/Laravel/bootstrap.php @@ -33,11 +33,8 @@ basePath: $base, resolvingCallback: static function (LaravelApplication $app): void { $app->booted(static function () use ($app): void { - $home = (string) getenv('HOME'); $config = $app->make('config'); - $config->set('logging.channels.e2e', ['driver' => 'single', 'path' => $home . '/laravel.log']); - $config->set('agentcat.log_channel', 'e2e'); - $config->set('app.debug', false); + $config->set('app.debug', false); // AgentCat logs to storage/logs/agentcat.log, its default (D60) // Laravel Boost's pattern: a local server with its own tools/call handler, excluded in config/agentcat.php. $config->push('agentcat.exclude', CustomCallServer::class); $collector = $_SERVER['HTTP_X_E2E_COLLECTOR'] ?? null; diff --git a/tests/E2E/LaravelEndToEndTest.php b/tests/E2E/LaravelEndToEndTest.php index 338f887..17ded66 100644 --- a/tests/E2E/LaravelEndToEndTest.php +++ b/tests/E2E/LaravelEndToEndTest.php @@ -50,7 +50,7 @@ final class LaravelEndToEndTest extends TestCase /** The directory of this class under the test HOME (Ruling R8): one HOME per server, each with its application. */ private static string $dir = ''; - /** The HOME of the `php -S` server: its laravel.log and the hook's marker. */ + /** The HOME of the `php -S` server: its application, with AgentCat's log, and the hook's marker. */ private static string $httpHome = ''; /** @var list the stdio servers of the current test */ @@ -100,7 +100,7 @@ protected function tearDown(): void public function testAnHttpCallIsDeliveredWithItsHeadersByTheEndOfTheRequest(): void { $scenario = $this->collector()->scenario(); - FileTree::remove(self::$httpHome . '/laravel.log'); // the HTTP tests share it: only this request may write the line + FileTree::remove($this->agentCatLogPath(self::$httpHome)); // the HTTP tests share it: only this request may write the line $answer = $this->callOverHttp(['X-E2E-Collector' => $scenario, 'X-AgentCat-Health-Run' => 'L']); @@ -108,7 +108,8 @@ public function testAnHttpCallIsDeliveredWithItsHeadersByTheEndOfTheRequest(): v $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); $body = $this->event($scenario); // the shutdown function delivers within the 5 s budget (per-request SAPI) $this->assertSame(['conformance-server', 'e2e-client', 'L'], [JsonAssert::at($body, 'server_name'), JsonAssert::at($body, 'client_name'), JsonAssert::at($body, 'parameters', 'extra', 'requestInfo', 'headers', 'x-agentcat-health-run')]); - $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT_ID . ' | sdk laravel-mcp/server', $this->laravelLog(self::$httpHome)); + $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT_ID . ' | sdk laravel-mcp/server', $this->agentCatLog(self::$httpHome)); // D60 + $this->assertStringNotContainsString('AgentCat', $this->laravelLog(self::$httpHome)); } /** @@ -249,6 +250,7 @@ public function testAnExcludedServerAnswersExactlyAsWithoutAgentCat(): void $this->assertSame('[{"type":"text","text":"echo: hi"}]', json_encode(JsonAssert::at(Json::decode($lines[2]), 'result', 'content'), JSON_THROW_ON_ERROR)); $this->assertSame([], $this->collector()->requests($scenario)); $this->assertStringNotContainsString('AgentCat', $this->laravelLog($excluded['home'])); + $this->assertFileDoesNotExist($this->agentCatLogPath($excluded['home'])); $this->assertFileDoesNotExist($excluded['home'] . '/agentcat.log'); } @@ -420,10 +422,24 @@ private function removeMarker(): void FileTree::remove(self::$httpHome . '/' . SleepingIdentify::MARKER); } - /** What the application wrote to `$home/laravel.log` (its `e2e` channel), '' before its first line. */ + /** Where AgentCat's lines go in the application of `$home`: its own file (D60). */ + private function agentCatLogPath(string $home): string + { + return $home . '/app/storage/logs/agentcat.log'; + } + + /** What AgentCat wrote to its own file in the application of `$home`, '' before its first line. */ + private function agentCatLog(string $home): string + { + $path = $this->agentCatLogPath($home); + + return is_file($path) ? (string) file_get_contents($path) : ''; + } + + /** What the application of `$home` wrote to its own log (Laravel's default channel), '' before its first line. */ private function laravelLog(string $home): string { - $path = $home . '/laravel.log'; + $path = $home . '/app/storage/logs/laravel.log'; return is_file($path) ? (string) file_get_contents($path) : ''; } diff --git a/tests/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php index f4abda1..3a737c8 100644 --- a/tests/Integration/Laravel/CachedConfigurationTest.php +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -148,6 +148,13 @@ public function defineCachedOptOut(): void self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'true']); } + /** `.env` names the application channel for AgentCat's lines (D60: their own file otherwise). */ + public function defineCachedLogChannel(): void + { + self::cachedConfiguration([...self::DOT_ENV, 'AGENTCAT_LOG_CHANNEL' => 'agentcat-cached']); + config(['logging.channels.agentcat-cached' => ['driver' => 'single', 'path' => $this->laravelDirectory . '/cached-channel.log']]); + } + /** `.env` holds a word that is not a D44 word; the process turns AgentCat on. */ public function defineCachedUnknown(): void { @@ -298,6 +305,17 @@ public function testACachedOptOutKeepsTheSharedClientOff(): void $this->assertFalse(Diagnostics::enabled()); } + #[DefineEnvironment('defineCachedLogChannel')] + public function testTheLinesGoToTheCachedLogChannel(): void + { + Log::setLogger(null); // the tracker chooses the logger + + (new LaravelHarness(ConformanceServer::class))->listTools(); + + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', (string) file_get_contents($this->laravelDirectory . '/cached-channel.log')); + $this->assertSame('', $this->agentCatLog()); + } + /** * The URLs of the events the API got, after a flush. * diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php index eb30913..4dccd1d 100644 --- a/tests/Integration/Laravel/ServiceProviderTest.php +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -170,14 +170,45 @@ public static function defaultChannels(): iterable yield 'not a name' => [5]; } + /** D60: without a channel name, AgentCat's lines go to its own file, `storage/logs/agentcat.log`, not to the application's log. */ #[DataProvider('defaultChannels')] - public function testWithoutALogChannelNameTheLinesGoToTheDefaultChannel(mixed $channel): void + public function testWithoutALogChannelNameTheLinesGoToStorageLogsAgentCatLog(mixed $channel): void { config(['agentcat.log_channel' => $channel]); (new LaravelHarness(ConformanceServer::class))->listTools(); + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->agentCatLog()); + $this->assertStringNotContainsString('AgentCat', $this->defaultChannelLog()); + } + + /** D60: `log_channel` routes the lines into any channel of the application, its default one too. */ + public function testALogChannelNameRoutesTheLinesIntoTheApplicationsChannel(): void + { + config(['agentcat.log_channel' => 'single']); + + (new LaravelHarness(ConformanceServer::class))->listTools(); + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->defaultChannelLog()); + $this->assertSame('', $this->agentCatLog()); + } + + /** + * I3: laravel/mcp builds a server per HTTP request, so the setup lines, and an ERROR line for a tool that declares + * its own `session_id`, repeat per request. They stay out of the application's log and its error trackers. + */ + public function testRequestsWriteNoAgentCatEntryIntoTheApplicationsLog(): void + { + $harness = new LaravelHarness(ConformanceServer::class); + + for ($i = 0; $i < 3; ++$i) { + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + } + $harness->listTools(); + + $this->assertSame('', $this->defaultChannelLog()); + $this->assertStringContainsString('agentcat.ERROR: ERROR: Tool "own" already declares', $this->agentCatLog()); + $this->assertSame(4, substr_count($this->agentCatLog(), 'AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT)); } /** Laravel does not keep the emergency logger of a channel it cannot create: AgentCat asks for it once. */ @@ -203,7 +234,7 @@ public function testAfterAFakeTheNextServerRoutesTheLinesToTheChannel(): void (new LaravelHarness(ConformanceServer::class))->listTools(); - $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->defaultChannelLog()); + $this->assertStringContainsString('AgentCat setup started | project ' . LaravelTrackingFixture::PROJECT . ' | sdk laravel-mcp/server', $this->agentCatLog()); } public function testOneTrackerServesTheApplication(): void diff --git a/tests/Support/Laravel/BridgeTestCase.php b/tests/Support/Laravel/BridgeTestCase.php index 9daafc7..42fdd86 100644 --- a/tests/Support/Laravel/BridgeTestCase.php +++ b/tests/Support/Laravel/BridgeTestCase.php @@ -97,4 +97,12 @@ protected function defaultChannelLog(): string return is_file($path) ? (string) file_get_contents($path) : ''; } + + /** What AgentCat's own log file, `storage/logs/agentcat.log` (D60), holds; '' before its first line. */ + protected function agentCatLog(): string + { + $path = $this->laravelDirectory . '/storage/logs/agentcat.log'; + + return is_file($path) ? (string) file_get_contents($path) : ''; + } } From 4d6fc0895f52845aae793cbfd5f1fb071c813653 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:13:03 +0000 Subject: [PATCH 26/30] fix: read the Laravel bridge's own keys with their types and log a bad value once A string exclude (App\Mcp\InternalServer::class instead of a list) excluded nothing, so the server was tracked, and a wrong type of projects, project_id, log_channel or the diagnostics keys fell back without a line. A string exclude is now a list of one class; any other wrong type logs one WARN line, once per process, and the default is used (exclude: Laravel Boost; projects: none; an entry of a wrong type is ignored). --- src/Laravel/Internal/BridgeKeys.php | 101 ++++++++++++++++++ src/Laravel/Internal/ServerTracker.php | 56 +++++++--- .../Laravel/ServiceProviderTest.php | 95 ++++++++++++++++ tests/Unit/Laravel/BridgeKeysTest.php | 82 ++++++++++++++ tests/Unit/Laravel/ConfigFileTest.php | 3 +- 5 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 src/Laravel/Internal/BridgeKeys.php create mode 100644 tests/Unit/Laravel/BridgeKeysTest.php diff --git a/src/Laravel/Internal/BridgeKeys.php b/src/Laravel/Internal/BridgeKeys.php new file mode 100644 index 0000000..80f56a0 --- /dev/null +++ b/src/Laravel/Internal/BridgeKeys.php @@ -0,0 +1,101 @@ + $config the bridge's `agentcat` configuration + * @param \Closure(string): void $warn gets the line of each bad value + */ + public function __construct( + private array $config, + private \Closure $warn, + ) {} + + /** `project_id`, `log_channel`, `diagnostics_endpoint` or `diagnostics_token`: a string, or null. */ + public function string(string $key): ?string + { + $value = $this->config[$key] ?? null; + if ($value === null || is_string($value)) { + return $value; + } + $this->wrongType($key, 'string or null', $value); + + return null; + } + + /** + * The server classes AgentCat never tracks. + * + * @return list + */ + public function exclude(): array + { + $value = $this->config['exclude'] ?? self::DEFAULT_EXCLUDE; + if (is_string($value)) { + return [$value]; + } + if (!is_array($value)) { + $this->wrongType('exclude', 'array or string', $value); + + return self::DEFAULT_EXCLUDE; + } + $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))); + } + } + + return $classes; + } + + /** + * Server class => project ID; a null project is the configured `project_id`. + * + * @return array + */ + public function projects(): array + { + $value = $this->config['projects'] ?? []; + if (!is_array($value)) { + $this->wrongType('projects', 'array or null', $value); + + return []; + } + $projects = []; + foreach ($value as $class => $project) { + if ($project === null || is_string($project)) { + $projects[$class] = $project; + } else { + ($this->warn)(sprintf(self::WRONG_ENTRY, 'projects', $class, 'string or null', get_debug_type($project))); + } + } + + return $projects; + } + + private function wrongType(string $key, string $expected, mixed $value): void + { + ($this->warn)(sprintf(self::WRONG_TYPE, $key, $expected, get_debug_type($value))); + } +} diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index b879644..f888065 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -39,20 +39,31 @@ final class ServerTracker private ?Options $options = null; + /** @var array the lines of bad bridge values logged so far: each once per process */ + private array $warned = []; + public function track(Server $server, Container $app): void { try { $config = $app->make('config')->get('agentcat'); $config = is_array($config) ? $config : []; - if ($this->matches($server, $config['exclude'] ?? []) !== null) { - return; + $bad = new \ArrayObject(); + $keys = new BridgeKeys($config, static function (string $line) use ($bad): void { + $bad->append($line); + }); + if ($this->matches($server, $keys->exclude()) !== null) { + return; // an excluded server touches neither the log nor diagnostics } - $this->adoptLogger($app, $config['log_channel'] ?? null); + $this->adoptLogger($app, $keys->string('log_channel')); + $project = $this->project($server, $keys); + $keys->string('diagnostics_endpoint'); // read at boot (BridgeConfig); here for the line of a bad value + $keys->string('diagnostics_token'); + $this->warnOnce($bad->getArrayCopy()); $client = AgentCat::client(); // A fake records every server (header conflict 18); the shared client is never replaced (header conflict 11). $client = FakeLog::of($client) instanceof FakeLog ? $client : ($this->client ??= new Client(ClientSeams::of(EventQueue::global(), EnabledGate::of($config['enabled'] ?? null)))); $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->resolve(...)); - $client->track($server, $this->project($server, $config), $this->options); + $client->track($server, $project, $this->options); if (TrackedServers::get($server) instanceof TrackedServer) { AnswerLength::mark($server); // D59; a server that AgentCat did not track (off, or failed) keeps its answer } @@ -70,23 +81,25 @@ private function resolve(string $class): mixed return GlobalContainer::getInstance()->make($class); } - /** @param array $config */ - private function project(Server $server, array $config): ?string + private function project(Server $server, BridgeKeys $keys): ?string { - $projects = is_array($config['projects'] ?? null) ? $config['projects'] : []; + $projects = $keys->projects(); $class = $this->matches($server, array_keys($projects)); // A null entry (env() of an unset variable) is the configured project: under config:cache the process does // not hold .env, so AGENTCAT_PROJECT_ID is read only when the configuration has no project either (D36). $project = $class !== null ? ($projects[$class] ?? null) : null; - $project ??= $config['project_id'] ?? null; - return is_string($project) ? $project : null; + return $project ?? $keys->string('project_id'); } - /** The first listed class the server is an instance of; a class also matches its subclasses. */ - private function matches(Server $server, mixed $classes): ?string + /** + * The first listed class the server is an instance of; a class also matches its subclasses. + * + * @param list $classes + */ + private function matches(Server $server, array $classes): ?string { - foreach (is_array($classes) ? $classes : [] as $class) { + foreach ($classes as $class) { if (is_string($class) && $server instanceof $class) { return $class; } @@ -95,6 +108,21 @@ private function matches(Server $server, mixed $classes): ?string return null; } + /** + * Each line once per process: laravel/mcp builds a server per HTTP request, and the configuration does not change. + * + * @param array $lines + */ + private function warnOnce(array $lines): void + { + foreach ($lines as $line) { + if (!isset($this->warned[$line])) { + $this->warned[$line] = true; + Log::write($line); + } + } + } + /** * D30, D60: AgentCat's lines go to the configured log channel, else to their own file, a `single` channel at * `storage/logs/agentcat.log` built through Laravel's logging (TypeScript writes `~/mcpcat.log`): laravel/mcp builds @@ -104,13 +132,13 @@ private function matches(Server $server, mixed $classes): ?string * Laravel does not keep the emergency logger of a channel it cannot create, and logs that failure each time it is * asked. */ - private function adoptLogger(Container $app, mixed $channel): void + private function adoptLogger(Container $app, ?string $channel): void { if (Log::hasLogger()) { return; } $log = $app->make('log'); - Log::adopt(is_string($channel) && $channel !== '' + Log::adopt($channel !== null && $channel !== '' ? $log->channel($channel) : $log->build(['driver' => 'single', 'name' => 'agentcat', 'path' => storage_path('logs/agentcat.log')])); } diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php index 4dccd1d..854fda2 100644 --- a/tests/Integration/Laravel/ServiceProviderTest.php +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -62,6 +62,28 @@ public function defineBadConfiguration(): void ]); } + /** Review Focus 3 for the bridge's own keys: each of a wrong type, before the providers boot. */ + public function defineBadBridgeKeys(): void + { + $this->ownConfiguration = true; + config([ + 'agentcat.enabled' => true, + 'agentcat.project_id' => LaravelTrackingFixture::PROJECT, + 'agentcat.exclude' => 42, + 'agentcat.projects' => [ConformanceServer::class => 7, PagedServer::class => self::OTHER_PROJECT], + 'agentcat.log_channel' => ['single'], + 'agentcat.diagnostics_endpoint' => 5, + 'agentcat.diagnostics_token' => false, + ]); + } + + public function defineANonStringProjectId(): void + { + $this->ownConfiguration = true; + config(['agentcat.enabled' => true, 'agentcat.project_id' => 12345]); + putenv('AGENTCAT_PROJECT_ID=' . self::OTHER_PROJECT); // BridgeTestCase puts the variable back after the test + } + public function defineAgentCatOff(): void { $this->ownConfiguration = true; @@ -101,6 +123,79 @@ public function testBoostIsExcludedByDefaultAndConfigExcludesMore(): void $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); } + /** M2: a string is a list of one class, so the server the customer meant to exclude is not tracked. */ + public function testAStringExcludeIsAListOfOneClass(): void + { + config(['agentcat.exclude' => ConformanceServer::class]); + + $this->assertUntouched(); + } + + /** M2: an entry that is no class name logs one line; the other entries still exclude, and a null one is unset. */ + public function testAnExcludeEntryOfAnotherTypeIsIgnoredWithOneLine(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + config(['agentcat.exclude' => [null, 7, ConformanceServer::class]]); + + $tools = (new LaravelHarness(ConformanceServer::class))->listTools()->tools; + $harness = new LaravelHarness(PagedServer::class); + $harness->listTools(); + $harness->listTools(); + + $this->assertIsArray($tools); + $this->assertNotSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + $this->assertSame(['WARN: AgentCat option "agentcat.exclude.1" must be string, got int; the entry is ignored'], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'WARN:')))); + } + + /** M2: the bridge's keys of a wrong type log one line each, once, and their defaults are used. */ + #[DefineEnvironment('defineBadBridgeKeys')] + public function testBridgeKeysOfAWrongTypeLogOneLineEachAndFallBack(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + (new LaravelHarness(PagedServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame([ + 'WARN: AgentCat option "agentcat.exclude" must be array or string, got int; the default is used', + 'WARN: AgentCat option "agentcat.log_channel" must be string or null, got array; the default is used', + 'WARN: AgentCat option "agentcat.projects.' . ConformanceServer::class . '" must be string or null, got int; the entry is ignored', + 'WARN: AgentCat option "agentcat.diagnostics_endpoint" must be string or null, got int; the default is used', + 'WARN: AgentCat option "agentcat.diagnostics_token" must be string or null, got bool; the default is used', + ], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'WARN:')))); + $events = $this->deliveries(); + $this->assertSame([LaravelTrackingFixture::PROJECT, LaravelTrackingFixture::PROJECT, self::OTHER_PROJECT], array_map(static fn(mixed $event): mixed => JsonAssert::at($event, 'project_id'), $events)); + } + + /** M2: `projects` that is not a map logs one line, and every server goes to `project_id`. */ + public function testProjectsOfAnotherTypeLogOneLineAndTheProjectIdIsUsed(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + config(['agentcat.projects' => self::OTHER_PROJECT]); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(LaravelTrackingFixture::PROJECT, $this->deliveredField('project_id')); + $this->assertContains('WARN: AgentCat option "agentcat.projects" must be array or null, got string; the default is used', $logger->messages()); + } + + /** M2: a `project_id` that is not a string logs one line, and AGENTCAT_PROJECT_ID of the process decides. */ + #[DefineEnvironment('defineANonStringProjectId')] + public function testAProjectIdOfAnotherTypeLogsOneLineAndTheVariableDecides(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(self::OTHER_PROJECT, $this->deliveredField('project_id')); + $this->assertContains('WARN: AgentCat option "agentcat.project_id" must be string or null, got int; the default is used', $logger->messages()); + } + public function testAnExcludedClassAlsoExcludesItsSubclasses(): void { config(['agentcat.exclude' => ['App\Mcp\Missing', Server::class]]); diff --git a/tests/Unit/Laravel/BridgeKeysTest.php b/tests/Unit/Laravel/BridgeKeysTest.php new file mode 100644 index 0000000..f7a6f25 --- /dev/null +++ b/tests/Unit/Laravel/BridgeKeysTest.php @@ -0,0 +1,82 @@ + */ + private \ArrayObject $lines; + + #[\Override] + protected function setUp(): void + { + $this->lines = new \ArrayObject(); + } + + public function testAStringKeyIsAStringOrNull(): void + { + $keys = $this->keys(['project_id' => 'proj_x', 'log_channel' => null, 'diagnostics_token' => 7]); + + $this->assertSame('proj_x', $keys->string('project_id')); + $this->assertNull($keys->string('log_channel')); + $this->assertNull($keys->string('diagnostics_endpoint')); + $this->assertNull($keys->string('diagnostics_token')); + $this->assertSame(['WARN: AgentCat option "agentcat.diagnostics_token" must be string or null, got int; the default is used'], $this->lines->getArrayCopy()); + } + + public function testExcludeIsTheDefaultWhenUnsetOrNull(): void + { + $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys([])->exclude()); + $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys(['exclude' => null])->exclude()); + $this->assertSame([], $this->keys(['exclude' => []])->exclude()); + $this->assertSame([], $this->lines->getArrayCopy()); + } + + public function testAStringExcludeIsAListOfOneClass(): void + { + $this->assertSame(['App\Mcp\Internal'], $this->keys(['exclude' => 'App\Mcp\Internal'])->exclude()); + $this->assertSame([], $this->lines->getArrayCopy()); + } + + public function testAnExcludeOfAnotherTypeIsTheDefaultWithOneLine(): void + { + $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys(['exclude' => 1.5])->exclude()); + $this->assertSame(['WARN: AgentCat option "agentcat.exclude" must be array or string, got float; the default is used'], $this->lines->getArrayCopy()); + } + + public function testAnExcludeEntryOfAnotherTypeIsIgnoredWithOneLineAndANullOneSilently(): void + { + $this->assertSame(['App\A', 'App\B'], $this->keys(['exclude' => ['App\A', null, ['App\C'], 'x' => 'App\B']])->exclude()); + $this->assertSame(['WARN: AgentCat option "agentcat.exclude.2" must be string, got array; the entry is ignored'], $this->lines->getArrayCopy()); + } + + public function testProjectsAreAMapOfStringsOrNulls(): void + { + $this->assertSame([], $this->keys([])->projects()); + $this->assertSame([], $this->keys(['projects' => null])->projects()); + $this->assertSame(['App\A' => 'proj_a', 'App\B' => null, 'App\D' => 'proj_d'], $this->keys(['projects' => ['App\A' => 'proj_a', 'App\B' => null, 'App\C' => 5, 'App\D' => 'proj_d']])->projects()); + $this->assertSame(['WARN: AgentCat option "agentcat.projects.App\C" must be string or null, got int; the entry is ignored'], $this->lines->getArrayCopy()); + } + + public function testProjectsOfAnotherTypeAreNoneWithOneLine(): void + { + $this->assertSame([], $this->keys(['projects' => 'proj_x'])->projects()); + $this->assertSame(['WARN: AgentCat option "agentcat.projects" must be array or null, got string; the default is used'], $this->lines->getArrayCopy()); + } + + /** @param array $config */ + private function keys(array $config): BridgeKeys + { + $lines = $this->lines; + + return new BridgeKeys($config, static function (string $line) use ($lines): void { + $lines->append($line); + }); + } +} diff --git a/tests/Unit/Laravel/ConfigFileTest.php b/tests/Unit/Laravel/ConfigFileTest.php index 26de004..79ef540 100644 --- a/tests/Unit/Laravel/ConfigFileTest.php +++ b/tests/Unit/Laravel/ConfigFileTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Unit\Laravel; use AgentCat\Internal\Support\ConfigOptions; +use AgentCat\Laravel\Internal\BridgeKeys; use AgentCat\Laravel\Internal\ServerTracker; use PHPUnit\Framework\TestCase; @@ -18,7 +19,7 @@ public function testTheConfigFileHasNoClosureAndOnlyKnownKeys(): void array_walk_recursive($config, function (mixed $value): void { $this->assertNotInstanceOf(\Closure::class, $value); // config:cache }); - $this->assertSame(['Laravel\Boost\Mcp\Boost'], $config['exclude'] ?? null); + $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $config['exclude'] ?? null); // also the default of a bad value $this->assertSame([], array_diff(array_keys($config), [...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)])); } From bae996f430f8cc5e681e1a28e7257eeded1aa80d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:19:09 +0000 Subject: [PATCH 27/30] test: reset diagnostics before each cached-configuration test The first Diagnostics::init() of the process decides, and a test of another class that tracks with a telemetry client can make it first: the cached-configuration tests that need diagnostics on failed under some random orders (seed 1792379705). --- tests/Integration/Laravel/CachedConfigurationTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php index 3a737c8..ec2b3d1 100644 --- a/tests/Integration/Laravel/CachedConfigurationTest.php +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -52,6 +52,7 @@ protected function setUp(): void $this->diagnosticsEnvironment = Environment::clear('AGENTCAT_DISABLE_DIAGNOSTICS', 'DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'MCPCAT_API_URL', 'APP_ENV'); putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); // tests/bootstrap.php's value: a test that wants diagnostics says so $this->cachedCollector = MockCollector::closedPortUrl() . '/cached'; + Diagnostics::reset(); // the first init() of the process decides: a test of another class can have made it parent::setUp(); Log::setLogger($this->logger = new MemoryLogger()); // nothing goes to agentcat.log in the test HOME } From 77193398751b6c452e1e581b8eedee51cf520848 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:20:26 +0000 Subject: [PATCH 28/30] fix: let the Laravel bridge's bool keys take the D44 words and name a bad value by its key 'enable_tracing' => env('AGENTCAT_ENABLE_TRACING') with AGENTCAT_ENABLE_TRACING=0 gives the string "0", which logged 'option "enableTracing" must be bool' and left tracing on, against the customer's intent. Every bool key of config/agentcat.php now takes true, 1, yes, on / false, 0, no, off (trimmed, any case) like enabled and disable_diagnostics, a blank value is unset, and a bad value of any key logs one line that names it as written, agentcat., before the default is used. --- src/Internal/Support/ConfigOptions.php | 75 +++++++++++++--- .../Laravel/ServiceProviderTest.php | 16 +++- .../Internal/Support/ConfigOptionsTest.php | 88 ++++++++++++++++++- 3 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/Internal/Support/ConfigOptions.php b/src/Internal/Support/ConfigOptions.php index 333d3f8..1475dcd 100644 --- a/src/Internal/Support/ConfigOptions.php +++ b/src/Internal/Support/ConfigOptions.php @@ -13,6 +13,10 @@ * their own config style onto `Options::fromArray()`). A hook is the name of something the bridge resolves when the * hook runs — never on the request path (D04) — so the configuration holds no closure and can be cached. * + * The values come from `env()` as often as not, so the bool keys take the D44 words, as `enabled` does (`env()` gives + * `AGENTCAT_ENABLE_TRACING=0` as the string "0"). A bad value logs one line that names the key as the customer wrote + * it, `agentcat.`, and the default is used. + * * @internal */ final class ConfigOptions @@ -38,10 +42,18 @@ final class ConfigOptions private const array HOOKS = ['resolveSessionId', 'identify', 'redactSensitiveInformation', 'redactEvent', 'eventTags', 'eventProperties']; + private const array BOOLS = ['enableReportMissing', 'enableTracing', 'enableToolCallContext', 'enableAgentTracking', 'disableSignalHandlers']; + + private const array STRINGS = ['customContextDescription', 'apiBaseUrl']; + + private const string BAD_WORD = 'WARN: AgentCat config "agentcat.%s" is "%s", not one of true, 1, yes, on, false, 0, no, off; the default is used'; + + private const string WRONG_TYPE = 'WARN: AgentCat option "agentcat.%s" must be %s, got %s; the default is used'; + private const string BAD_DIAGNOSTICS_VALUE = 'WARN: AgentCat config "agentcat.disable_diagnostics" is "%s", not one of true, 1, yes, on, false, 0, no, off; the value is treated as true'; /** - * Never throws: an unknown key or a bad value logs one line and the default is used (`Options::fromArray()`). + * Never throws: an unknown key or a bad value logs one line and the default is used. * * @param array $config the bridge's `agentcat` configuration * @param list $bridgeKeys keys the bridge reads itself (not options) @@ -60,17 +72,10 @@ public static function options(array $config, array $bridgeKeys, \Closure $resol continue; } - if ($value === null) { - continue; // env() of an unset variable: the default - } - if ($option === 'disableDiagnostics') { - $mapped[$option] = self::diagnosticsOff($value, log: true); - - continue; + $value = $value === null ? null : self::value((string) $key, $option, $value, $resolve); + if ($value !== null) { + $mapped[$option] = $value; // null: env() of an unset variable, or a bad value; the default } - $mapped[$option] = in_array($option, self::HOOKS, true) && is_string($value) && !is_callable($value) - ? self::named($value, $resolve) - : $value; } return Options::fromArray($mapped); @@ -91,7 +96,7 @@ public static function diagnosticsOff(mixed $value, bool $log = false): bool } $disabled = DiagnosticsSwitch::option($entry); if ($disabled === null && $log) { - Log::write(sprintf(self::BAD_DIAGNOSTICS_VALUE, is_scalar($entry) ? (string) $entry : get_debug_type($entry))); + Log::write(sprintf(self::BAD_DIAGNOSTICS_VALUE, self::shown($entry))); } $off = $off || ($disabled ?? true); } @@ -99,6 +104,52 @@ public static function diagnosticsOff(mixed $value, bool $log = false): bool return $off; } + /** + * The value of a key for `Options::fromArray()`, of the type it takes; null for a bad value (one line) or a blank + * bool word (unset). + * + * @param \Closure(string): mixed $resolve + */ + private static function value(string $key, string $option, mixed $value, \Closure $resolve): mixed + { + if ($option === 'disableDiagnostics') { + return self::diagnosticsOff($value, log: true); + } + if (in_array($option, self::BOOLS, true)) { + $word = TestMode::word($value); + if ($word === null && (!is_string($value) || trim($value) !== '')) { + Log::write(sprintf(self::BAD_WORD, $key, self::shown($value))); + } + + return $word; + } + if (in_array($option, self::STRINGS, true)) { + return is_string($value) ? $value : self::reject($key, 'string or null', $value); + } + if (in_array($option, self::HOOKS, true)) { + if (is_string($value) && !is_callable($value)) { + return self::named($value, $resolve); + } + + return is_callable($value) ? $value : self::reject($key, 'callable or null', $value); + } + + return is_array($value) ? $value : self::reject($key, 'array or null', $value); // exporters + } + + private static function reject(string $key, string $expected, mixed $value): null + { + Log::write(sprintf(self::WRONG_TYPE, $key, $expected, get_debug_type($value))); + + return null; + } + + /** A value as a bad-value line shows it: a scalar as itself, anything else by its type. */ + private static function shown(mixed $value): string + { + return is_scalar($value) ? (string) $value : get_debug_type($value); + } + /** * A hook that resolves its name when it runs. What the name resolves to must be callable; the hook guard logs * anything it throws (D32). diff --git a/tests/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php index 854fda2..abdb95c 100644 --- a/tests/Integration/Laravel/ServiceProviderTest.php +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -55,7 +55,8 @@ public function defineBadConfiguration(): void config([ 'agentcat.enabled' => true, 'agentcat.project_id' => null, - 'agentcat.enable_tracing' => 'yes', + 'agentcat.enable_tracing' => 'yes', // a D44 word since M3: tracing stays on, no line + 'agentcat.enable_report_missing' => 'maybe', 'agentcat.identify' => 'App\Missing\Identify', 'agentcat.exporters' => ['otlp' => null], 'agentcat.unknown_key' => 1, @@ -410,8 +411,8 @@ public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void $complete = 'AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=0'; $own = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; // the fixture's own tool, for every server $this->assertSame([ + 'WARN: AgentCat config "agentcat.enable_report_missing" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', 'WARN: Unknown AgentCat option "agentcat.unknown_key" ignored', // once: the options serve every server - 'WARN: AgentCat option "enableTracing" must be bool, got string; the default is used', $started, $nowhere, $complete, @@ -425,6 +426,17 @@ public function testBadConfigValuesLogOneLineEachAndNothingThrows(): void ], array_map(static fn(string $line): string => str_starts_with($line, $own) ? $own : $line, $logger->messages())); } + /** M3: `'enable_tracing' => env('AGENTCAT_ENABLE_TRACING')` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ + public function testAZeroFromEnvTurnsTracingOff(): void + { + config(['agentcat.enable_tracing' => '0']); + + $answer = (new LaravelHarness(ConformanceServer::class))->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([], $this->deliveries()); + } + /** Review Focus 3: boot, resolve, `tools/call` and flush with `AGENTCAT_ENABLED=maybe`. */ #[DefineEnvironment('defineAnUnknownEnabledWord')] public function testAnUnknownEnabledWordLogsOneLineAndTracksNothing(): void diff --git a/tests/Unit/Internal/Support/ConfigOptionsTest.php b/tests/Unit/Internal/Support/ConfigOptionsTest.php index ab7b33b..1c225f7 100644 --- a/tests/Unit/Internal/Support/ConfigOptionsTest.php +++ b/tests/Unit/Internal/Support/ConfigOptionsTest.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\ConfigOptions; use AgentCat\Laravel\Internal\ServerTracker; +use AgentCat\Options; use AgentCat\Tests\Support\Laravel\ArgumentsHook; use AgentCat\Tests\Support\Laravel\InvokableIdentify; use AgentCat\Tests\Support\MemoryLogger; @@ -112,7 +113,7 @@ public function testAHookThatIsNeitherANameNorACallableIsTheDefault(): void $options = ConfigOptions::options(['identify' => 7], ServerTracker::BRIDGE_KEYS, $this->resolve); $this->assertNull($options->identify); - $this->assertSame(['WARN: AgentCat option "identify" must be callable or null, got int; the default is used'], $this->logger->messages()); + $this->assertSame(['WARN: AgentCat option "agentcat.identify" must be callable or null, got int; the default is used'], $this->logger->messages()); } public function testAHookWhoseClassIsNotInvokableThrowsWhenItRuns(): void @@ -152,17 +153,19 @@ public function testBadValuesLogOneLineEachAndNeverThrow(): void Log::setLogger($logger); $options = ConfigOptions::options([ - 'enable_tracing' => 'yes', + 'enable_tracing' => 'yes', // a D44 word (M3) + 'enable_agent_tracking' => 'maybe', 'identify' => 'App\Missing\Identify', 'exporters' => ['otlp' => null, 'dd' => ['type' => 'datadog']], 'unknown_key' => 1, ], ServerTracker::BRIDGE_KEYS, $this->resolve); $this->assertTrue($options->enableTracing); + $this->assertFalse($options->enableAgentTracking); $this->assertSame([], $options->exporters); $this->assertSame([ + 'WARN: AgentCat config "agentcat.enable_agent_tracking" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', 'WARN: Unknown AgentCat option "agentcat.unknown_key" ignored', - 'WARN: AgentCat option "enableTracing" must be bool, got string; the default is used', 'Failed to initialize exporter dd: InvalidArgumentException: exporter option "apiKey" must be string, got null', ], $logger->messages()); $this->assertInstanceOf(\Closure::class, $options->identify); // fails when it runs, inside the hook guard (D32) @@ -204,6 +207,85 @@ public function testAnUnknownDisableDiagnosticsValueTurnsThemOff(): void ], $this->logger->messages()); } + /** + * M3: what `env()` gives a bool key. Laravel turns `true` and `false` into bools; `0`, `1`, `yes`, `on`, `no` + * and `off` stay strings. + * + * @return iterable + */ + public static function boolWords(): iterable + { + yield 'bool true' => [true, true]; + yield 'bool false' => [false, false]; + yield 'int 1' => [1, true]; + yield 'int 0' => [0, false]; + yield '"1"' => ['1', true]; + yield '"0"' => ['0', false]; + yield '"yes"' => ['yes', true]; + yield '"no"' => ['no', false]; + yield '" ON " (trimmed, any case)' => [' ON ', true]; + yield '"Off"' => ['Off', false]; + } + + /** @return iterable */ + public static function boolKeys(): iterable + { + yield 'enable_report_missing' => ['enable_report_missing', 'enableReportMissing']; + yield 'enable_tracing' => ['enable_tracing', 'enableTracing']; + yield 'enable_tool_call_context' => ['enable_tool_call_context', 'enableToolCallContext']; + yield 'enable_agent_tracking' => ['enable_agent_tracking', 'enableAgentTracking']; + yield 'disable_signal_handlers' => ['disable_signal_handlers', 'disableSignalHandlers']; + } + + /** M3: every bool key takes the D44 words, as `enabled` and `disable_diagnostics` do. */ + #[DataProvider('boolWords')] + public function testEveryBoolKeyTakesTheD44Words(mixed $value, bool $expected): void + { + foreach (self::boolKeys() as [$key, $option]) { + $options = get_object_vars(ConfigOptions::options([$key => $value], ServerTracker::BRIDGE_KEYS, $this->resolve)); + + $this->assertSame($expected, $options[$option] ?? null, $key); + } + $this->assertSame([], $this->logger->messages()); + } + + /** M3: any other value of a bool key logs one line that names the key as written, and its default is used. */ + #[DataProvider('boolKeys')] + public function testABadBoolValueNamesTheKeyAsWrittenAndTheDefaultIsUsed(string $key, string $option): void + { + $default = get_object_vars(new Options())[$option] ?? null; + + $options = get_object_vars(ConfigOptions::options([$key => 'maybe'], ServerTracker::BRIDGE_KEYS, $this->resolve)); + $blank = get_object_vars(ConfigOptions::options([$key => ' '], ServerTracker::BRIDGE_KEYS, $this->resolve)); + $other = get_object_vars(ConfigOptions::options([$key => 2.5], ServerTracker::BRIDGE_KEYS, $this->resolve)); + + $this->assertIsBool($default); + $this->assertSame([$default, $default, $default], [$options[$option] ?? null, $blank[$option] ?? null, $other[$option] ?? null]); + $this->assertSame([ + sprintf('WARN: AgentCat config "agentcat.%s" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', $key), + sprintf('WARN: AgentCat config "agentcat.%s" is "2.5", not one of true, 1, yes, on, false, 0, no, off; the default is used', $key), + ], $this->logger->messages(), 'blank is unset: no line'); + } + + /** M3: a value of another type of any key is named as written, `agentcat.`, and its default is used. */ + public function testAValueOfAnotherTypeNamesTheKeyAsWritten(): void + { + $options = ConfigOptions::options([ + 'custom_context_description' => 5, + 'api_url' => ['http://127.0.0.1:1'], + 'event_tags' => 1.5, + 'exporters' => 'otlp', + ], ServerTracker::BRIDGE_KEYS, $this->resolve); + + $this->assertSame([null, null, null, null], [$options->customContextDescription, $options->apiBaseUrl, $options->eventTags, $options->exporters]); + $this->assertSame([ + 'WARN: AgentCat option "agentcat.custom_context_description" must be string or null, got int; the default is used', + 'WARN: AgentCat option "agentcat.api_url" must be string or null, got array; the default is used', + 'WARN: AgentCat option "agentcat.event_tags" must be callable or null, got float; the default is used', + 'WARN: AgentCat option "agentcat.exporters" must be array or null, got string; the default is used', + ], $this->logger->messages()); + } + public function testAnUnknownKeyLeavesTheKeysAfterIt(): void { $options = ConfigOptions::options([0 => 'stray', 'enable_tracing' => false], ServerTracker::BRIDGE_KEYS, $this->resolve); From 4abb20e3a2bc0d6a6d202977e7b06acb2a95e80a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:23:46 +0000 Subject: [PATCH 29/30] fix: build laravel/mcp's CallTool through the container on a tracked server, so a customer's decorator runs AgentCat answered tools/call with its CapturingCallTool unless CallTool was bound, so an extend() decorator of CallTool, for example an authorization check, and the resolving() callbacks of CallTool were skipped on every tracked server. The container now builds CallTool at each call, as laravel/mcp does: when it builds exactly CallTool, AgentCat answers with CapturingCallTool and keeps the tool's exception; anything else answers itself. --- src/LaravelMcp/Internal/TrackedCallTool.php | 29 +++++++-- tests/Integration/LaravelMcp/CallToolTest.php | 61 +++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/LaravelMcp/Internal/TrackedCallTool.php b/src/LaravelMcp/Internal/TrackedCallTool.php index 8992624..7a993b5 100644 --- a/src/LaravelMcp/Internal/TrackedCallTool.php +++ b/src/LaravelMcp/Internal/TrackedCallTool.php @@ -17,10 +17,12 @@ /** * Answers `tools/call` (TS callWrap): `CallInterceptor` resolves the handles, strips them, lets the handler the - * server's entry named before AgentCat's run the tool, decorates the answer and queues one event. For laravel/mcp's - * own `CallTool`, AgentCat answers with `CapturingCallTool`, which keeps the tool's exception (plan E1 header - * conflict 5); a customer's handler, or a container binding of `CallTool`, answers itself (header conflict 4). A call - * without a string tool name goes to the handler untraced. + * server's entry named before AgentCat's run the tool, decorates the answer and queues one event. A customer's handler + * answers itself (header conflict 4). laravel/mcp's own `CallTool` is built by the container at each call, as + * laravel/mcp does, so a binding, an `extend()` decorator (an authorization check, say) and the `resolving()` + * callbacks of `CallTool` run: when the container builds exactly `CallTool`, AgentCat answers with + * `CapturingCallTool`, which keeps the tool's exception (plan E1 header conflict 5); anything else answers itself. A + * call without a string tool name goes to the handler untraced. * * @internal */ @@ -47,8 +49,8 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl $this->publisher->pump(); // stdio servers deliver between requests $original = $this->original; $stash = $this->stash; - $delegate = $original === CallTool::class && !Container::getInstance()->bound(CallTool::class) - ? static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => (new CapturingCallTool($stash))->handle($sent, $context) + $delegate = $original === CallTool::class + ? static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => self::callTool($stash, $sent, $context) : static fn(JsonRpcRequest $sent): iterable|JsonRpcResponse => Delegate::handle($original, $sent, $context); if (!is_string($request->get('name'))) { return $delegate($request); // TypeScript and mcp/sdk reject it before any handler: no event (D15) @@ -65,4 +67,19 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl return $this->interceptor->intercept($this->server, $seam); } + + /** + * laravel/mcp's `CallTool` as the container builds it. The container's extenders and resolving callbacks are not + * public, so it is always built; with none, it is exactly `CallTool`. + * + * @return iterable|JsonRpcResponse + */ + private static function callTool(ThrowableStash $stash, JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $handler = Container::getInstance()->make(CallTool::class); + + return $handler::class === CallTool::class + ? (new CapturingCallTool($stash))->handle($request, $context) + : $handler->handle($request, $context); + } } diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 9978361..1593d6d 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -31,6 +31,7 @@ use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use Illuminate\Http\Request; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Contracts\Method; use Laravel\Mcp\Server\Contracts\Transport; use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\ServerContext; @@ -372,6 +373,66 @@ public function handle(JsonRpcRequest $request, ServerContext $context): \Genera $this->assertObjectNotHasProperty('type', $error); } + /** + * M4: a customer's decorator of `CallTool` from `extend()`, here an authorization check, runs on a tracked server + * as without AgentCat: the container builds the handler at each call, and AgentCat answers with what it built. + */ + public function testAnExtendDecoratorOfCallToolStillRuns(): void + { + $checks = new \ArrayObject(); + $this->app?->extend(CallTool::class, static fn(CallTool $tool): Method => new readonly class ($tool, $checks) implements Method { + /** @param \ArrayObject $checks */ + public function __construct(private CallTool $inner, private \ArrayObject $checks) {} + + #[\Override] + public function handle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $this->checks->append((string) $request->get('name')); + + return $request->get('name') === 'boom' ? JsonRpcResponse::error($request->id, -32001, 'Forbidden.') : $this->inner->handle($request, $context); + } + }); + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $allowed = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $refused = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertSame(['echo', 'boom'], $checks->getArrayCopy()); + $this->assertSame('echo: hi', JsonAssert::at($allowed, 'result', 'content', 0, 'text')); + $this->assertSame('Forbidden.', JsonAssert::at($refused, 'error', 'message')); + $events = $fixture->events(); + $this->assertCount(2, $events); + $this->assertNotTrue($events[0]->isError); + $this->assertTrue($events[1]->isError); + $this->assertSame('Forbidden.', JsonAssert::at($events[1]->error, 'message')); + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo'), 'the decorator passed the stripped arguments on'); + } + + /** + * M4: a `resolving()` callback of `CallTool` runs at each call, as without AgentCat; the container still builds + * laravel/mcp's own `CallTool`, so AgentCat keeps the tool's exception. + */ + public function testAResolvingCallbackOfCallToolRunsAndTheToolExceptionIsKept(): void + { + $resolved = new \ArrayObject(); + $this->app?->resolving(CallTool::class, static function (CallTool $tool) use ($resolved): void { + $resolved->append($tool::class); + }); + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(ConformanceServer::class, $fixture->tracker()); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $answer = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertSame([CallTool::class, CallTool::class], $resolved->getArrayCopy()); + $this->assertTrue(JsonAssert::at($answer, 'result', 'isError')); + $events = $fixture->events(); + $this->assertCount(2, $events); + $this->assertSame('RuntimeException', JsonAssert::at($events[1]->error, 'type')); + $this->assertSame('tool boom', JsonAssert::at($events[1]->error, 'message')); + } + public function testAJsonRpcErrorFromACustomHandlerIsOneErrorEventAndPassesUnchanged(): void { $this->answer(static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::error($request->id, -32602, 'bad arguments')); From 70d1812c96b605e1dde96bcc4e1d5402469d62f6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 03:55:06 +0000 Subject: [PATCH 30/30] fix: keep a 0 s flush one non-blocking pass and bound each prepare pass to the events waiting at its start N1: flush(0.0), the console request-end wait and AgentCat::flush(0), ran the hooks of the whole backlog with no time limit before the 5 s shutdown deadline started. Only a flush with a window (a positive timeout, or none) now prepares the waiting events; a 0 s flush runs only the hooks of the events it starts, and the shutdown function of the same request or process runs the others. N2: a hook that queues an event whose hook queues the next one (a redactEvent hook that publishes a custom event) kept a flush from ever returning, which would hang an Octane worker in terminate(). Each prepare pass now takes only the events that waited when it began; later passes take the new ones, and the flush deadline ends the loop. --- src/Internal/Delivery/EventQueue.php | 29 ++-- src/Internal/Delivery/RequestEndWait.php | 11 +- .../Unit/Internal/Delivery/EventQueueTest.php | 134 ++++++++++++++++-- 3 files changed, 147 insertions(+), 27 deletions(-) diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 204ee94..0f026a5 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -24,10 +24,10 @@ * hygiene and HTTP run in `pump()`, `flush()` or `shutdown()`. At most 5 events are in delivery at once, and * at most 10 000 wait (the oldest is dropped). No method throws. * - * An event's hooks run when it gets a free delivery slot, except in `flush()`, which runs them for every waiting event - * first, whatever the free slots: a framework's request-end wait is a `flush()`, so each hook runs in its own request, - * also in a worker that serves the next request in the same process (Octane). The payloads then wait, in order, only - * for the transport. + * An event's hooks run when it gets a free delivery slot, except in a `flush()` with a window, which runs them for + * every waiting event first, whatever the free slots: a worker's request-end wait is such a `flush()`, so each hook runs + * in its own request, also in a worker that serves the next request in the same process (Octane). The payloads then + * wait, in order, only for the transport. A 0 s `flush()` stays one non-blocking pass. * * The queue also drives the side channel of the exporter and diagnostics requests (`channel()`) and its companions * (`attach()`): every pass ticks each companion (except the pass after `finish()`), then pumps the channel. `flush()` @@ -241,13 +241,16 @@ public function pump(): void * requests (diagnostics) are neither counted nor waited for: they go on in later passes and at `shutdown()`. A * re-entrant call (from a hook or an exporter) returns false at once. * - * After its first pass, and after each later one, it runs the pipeline (the hooks first) of every waiting event, - * whatever the free slots, also when the timeout has passed: so the hooks run before the call returns. The timeout - * bounds the waits for the transport, not the hooks. + * With a window (a positive timeout, or none), each pass ends with the pipeline (the hooks first) of the events that + * waited when it began, whatever the free slots: so the hooks of those events run before the call returns, also + * when the deadline has passed. An event a hook queues meanwhile goes to the next pass, so a hook that keeps queueing + * cannot hold the call past its deadline. Without a window (0, negative or NaN) it is one non-blocking pass, which + * runs only the hooks of the events it starts; the others run later, in the shutdown function of the same request + * or process at the latest. */ public function flush(?float $timeoutSeconds = null): bool { - return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false, prepare: true); + return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false, prepare: $timeoutSeconds === null || $timeoutSeconds > 0); } /** @@ -353,7 +356,7 @@ private function finishReserve(): float * @param \Closure(): float $deadline read once, after the re-entrancy guard is taken * @param bool $tick false after `finish()`: the passes tick no companion * @param bool $background whether it waits for the background requests (diagnostics): `shutdown()` does - * @param bool $prepare whether each pass ends with the pipeline of every waiting event: `flush()` does + * @param bool $prepare whether each pass ends with the pipeline of the waiting events: a `flush()` with a window */ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true, bool $prepare = false): bool { @@ -451,10 +454,14 @@ private function finishCompanions(): void } } - /** The pipeline of every waiting event, whatever the free slots (`flush()`): the payloads wait in order. */ + /** + * 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. + */ private function prepareWaiting(): void { - while (!$this->waiting->isEmpty()) { + for ($left = $this->waiting->count(); $left > 0; --$left) { $ready = $this->prepare($this->waiting->dequeue()); if ($ready instanceof ReadyEvent) { $this->ready->enqueue($ready); diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php index 0f8709b..37ca6ab 100644 --- a/src/Internal/Delivery/RequestEndWait.php +++ b/src/Internal/Delivery/RequestEndWait.php @@ -13,11 +13,12 @@ * deadline (D47). * * The wait is `EventQueue::flush()`: a window of its own that fixes no shutdown deadline, keeps the queue open, ticks - * the companions without finishing them, and waits for events and exporter requests but not for diagnostics. Before it - * returns, the hooks of every waiting event have run, in the request of their event. What is left — payloads waiting - * for a free slot, transfers in flight, retries scheduled for later — moves on at the next request's `pump()` and - * wait, and at the worker's stop, whose `shutdown()` fixes the single 5 s deadline and keeps its last 10 % (at most - * 0.5 s) for the companions' `finish()`. + * the companions without finishing them, and waits for events and exporter requests but not for diagnostics. A wait of + * `WORKER_SECONDS` runs the hooks of every waiting event before it returns, in the request of their event; the 0 s + * pass runs only those of the events it starts, and the shutdown function runs the others in the same request or + * process. What is left — payloads waiting for a free slot, transfers in flight, retries scheduled for later — moves on + * at the next request's `pump()` and wait, and at the worker's stop, whose `shutdown()` fixes the single 5 s deadline + * and keeps its last 10 % (at most 0.5 s) for the companions' `finish()`. * * @internal */ diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index f5dd99a..8f81cea 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -36,6 +36,9 @@ final class EventQueueTest extends TestCase { private const string GAVE_UP = 'Failed to deliver AgentCat event after retries: '; + /** How long a slow hook takes on the simulated clock of `RecordingTransport`. */ + private const float HOOK_SECONDS = 0.01; + /** Read by `ApiBaseUrl::resolve()` through `getenv()`, `$_ENV` and `$_SERVER`. */ private const array URL_VARIABLES = ['AGENTCAT_API_URL', 'MCPCAT_API_URL']; @@ -484,8 +487,8 @@ public function testFlushWaitsWhileAllSlotsAreBusy(): void } /** - * A request-end wait is a `flush()`: before it returns, every waiting event's hooks run, also with every slot busy - * and no time left, so that a hook reads its own request (an Octane worker serves the next one in the process). + * A worker's request-end wait is a `flush()` with a window: before it returns, every waiting event's hooks run, also + * with every slot busy, so that a hook reads its own request (an Octane worker serves the next one in the process). */ #[Group('ac-03-1')] public function testFlushRunsTheHooksOfEveryWaitingEventWhateverTheFreeSlots(): void @@ -497,12 +500,12 @@ public function testFlushRunsTheHooksOfEveryWaitingEventWhateverTheFreeSlots(): $queue->add($this->recorded('event-' . $i, $ran)); } - $this->assertFalse($queue->flush(0.0)); + $this->assertFalse($queue->flush(1.0)); $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], $ran->getArrayCopy()); $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4'], $this->resourceNames($transport)); $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); - $this->assertSame(0, $transport->waits); + $this->assertSame(1, $transport->waits, 'the window: the API did not answer'); $transport->complete = true; $transport->pump(); // the API answers: the slots are free, and two payloads still wait @@ -529,6 +532,99 @@ public function testPumpRunsNoHookOfAnEventThatFindsNoFreeSlot(): void $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); } + /** + * N1: a 0 s flush (a console command's request-end wait, `AgentCat::flush(0)`) stays one non-blocking pass. A + * backlog's hooks wait for the shutdown function of the same request or process, inside its 5 s budget; the pass + * runs only those of the events it starts. A negative or NaN timeout is no window either. + * + * @return iterable + */ + public static function noWindow(): iterable + { + yield '0 s' => [0.0]; + yield 'negative' => [-1.0]; + yield 'NaN' => [NAN]; + } + + #[DataProvider('noWindow')] + public function testAFlushWithoutAWindowRunsNoHookPastTheFreeSlots(float $timeout): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + for ($i = 0; $i < 1000; ++$i) { + $queue->add($this->slow('event-' . $i, $ran, $transport)); + } + + $this->assertFalse($queue->flush($timeout)); + + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4'], $ran->getArrayCopy()); + $this->assertSame(['queueLength' => 995, 'activeRequests' => 5], $queue->stats()); + $this->assertEqualsWithDelta(5 * self::HOOK_SECONDS, $transport->now(), 1e-9, 'only the hooks of the five started events ran'); + } + + /** + * N2: a hook that queues an event whose own hook queues the next one (a `redactEvent` hook that publishes a custom + * event: custom events carry the same hook) keeps the queue busy, and the flush still ends at its deadline. Each + * pass prepares the events that waited when it began, each once; later passes take the new ones. + */ + public function testAFlushEndsAtItsDeadlineWhileAHookKeepsQueueing(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $ran = new \ArrayObject(); + $children = 0; + $redact = null; + $redact = function (Event $event) use (&$redact, &$children, $queue, $transport): Event { + $transport->waitForActivity($transport->now() + self::HOOK_SECONDS); // the hook takes its time + if ($children < 10_000) { // a bound for the test only: without it a broken queue would hang it + ++$children; + $queue->add(new QueuedEvent($this->event('child'), $redact)); + } + + return $event; + }; + for ($i = 0; $i < 7; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i), $redact, pending: [ + static function (Event $event) use ($ran): void { + $ran->append((string) $event->resourceName); + }, + ])); + } + + $this->assertFalse($queue->flush(1.0)); + + $this->assertLessThan(1.0 + 1.0, $transport->now(), 'the deadline, plus the hooks of the last pass'); + $this->assertLessThan(100, $children); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], $ran->getArrayCopy(), 'each event that waited at the start, once'); + } + + /** N2: an event that a hook queues during a pass waits for the next pass, after the window's wait. */ + public function testAPassPreparesOnlyTheEventsThatWaitedWhenItBegan(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + $at = new \ArrayObject(); + for ($i = 0; $i < 5; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->add(new QueuedEvent($this->event('parent'), pending: [ + function () use ($queue, $transport, $at): void { + $at->append(['parent', $transport->now()]); + $queue->add(new QueuedEvent($this->event('child'), pending: [ + static function () use ($transport, $at): void { + $at->append(['child', $transport->now()]); + }, + ])); + }, + ])); + + $this->assertFalse($queue->flush(1.0)); + + $this->assertSame([['parent', 0.0], ['child', 1.0]], $at->getArrayCopy()); + $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); + } + /** The process ends with `shutdown()`: it spends its budget on the events it can start, not on hooks of the others. */ public function testShutdownRunsNoHookOfAnEventThatFindsNoFreeSlot(): void { @@ -553,7 +649,7 @@ public function testEventsThatAFlushPreparedGoBeforeLaterOnes(): void for ($i = 0; $i < 6; ++$i) { $queue->add($this->recorded('event-' . $i, $ran)); } - $queue->flush(0.0); + $queue->flush(1.0); $queue->add($this->recorded('later', $ran)); $transport->complete = true; @@ -580,7 +676,7 @@ function () use ($queue, $ran): void { }, ])); - $queue->flush(0.0); + $queue->flush(1.0); $this->assertSame(['parent', 'child'], $ran->getArrayCopy()); $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); @@ -596,7 +692,7 @@ public function testAFullQueueDropsTheOldestPreparedEventFirst(): void for ($i = 0; $i < 10_000; ++$i) { $queue->add(new QueuedEvent($this->event('event-' . $i))); } - $queue->flush(0.0); // five in flight, 9 995 prepared + $queue->flush(1.0); // five in flight, 9 995 prepared for ($i = 0; $i < 6; ++$i) { $queue->add(new QueuedEvent($this->event('new-' . $i))); @@ -619,7 +715,7 @@ public function testAPreparedEventGoesToTheEndpointOfTheLastConfigure(): void for ($i = 0; $i < 6; ++$i) { $queue->add(new QueuedEvent($this->event('event-' . $i))); } - $queue->flush(0.0); + $queue->flush(1.0); $queue->configure('https://later.example.test'); $transport->complete = true; @@ -640,7 +736,7 @@ public function testAnEventThatAHookDropsDuringAFlushLeavesTheQueue(): void } $queue->add(new QueuedEvent($this->event('dropped'), static fn(Event $event): ?Event => null)); - $queue->flush(0.0); + $queue->flush(1.0); $this->assertSame(['Event dropped by redactEvent hook'], $this->logger->messages()); $this->assertSame(['queueLength' => 0, 'activeRequests' => 5], $queue->stats()); @@ -667,7 +763,7 @@ public function testAPreparedEventWithoutABodyIsExportedInItsTurn(): void $queue->add(new QueuedEvent($telemetryOnly)); $queue->add(new QueuedEvent($bad)); - $queue->flush(0.0); + $queue->flush(1.0); $this->assertSame([], $this->logger->messages(), 'nothing exported yet: each export logs the exporter\'s error'); $transport->complete = true; @@ -691,7 +787,7 @@ public function testShutdownCountsThePreparedEventsStillWaiting(): void for ($i = 0; $i < 7; ++$i) { $queue->add(new QueuedEvent($this->event())); } - $queue->flush(0.0); + $queue->flush(1.0); $queue->add(new QueuedEvent($this->event())); $queue->shutdown(); @@ -1460,6 +1556,22 @@ private function event(string $resourceName = 'tool', ?string $projectId = 'proj return new Event(projectId: $projectId, eventType: 'mcp:tools/call', resourceName: $resourceName); } + /** + * An event whose pending hook takes `HOOK_SECONDS` on the transport's simulated clock and appends its resource name + * to `$ran`. + * + * @param \ArrayObject $ran + */ + private function slow(string $resourceName, \ArrayObject $ran, RecordingTransport $transport): QueuedEvent + { + return new QueuedEvent($this->event($resourceName), pending: [ + static function () use ($resourceName, $ran, $transport): void { + $transport->waitForActivity($transport->now() + self::HOOK_SECONDS); + $ran->append($resourceName); + }, + ]); + } + /** * An event whose pending hook appends its resource name to `$ran` when it runs. *