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 4d2ca7a..53e13a6 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. @@ -19,4 +19,16 @@ // 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/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 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. + ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]); diff --git a/composer.json b/composer.json index af4e296..bac6b5f 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", @@ -86,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..94c43cf --- /dev/null +++ b/config/agentcat.php @@ -0,0 +1,58 @@ + 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, 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. + 'api_url' => env('AGENTCAT_API_URL', env('MCPCAT_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, + + // 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/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/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 0fa2eb3..adc7384 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,18 +9,20 @@ 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; 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; use AgentCat\Internal\Support\Values; use AgentCat\Internal\Tags\TagValidator; use AgentCat\Internal\Telemetry\TelemetryManager; -use AgentCat\McpSdk\McpSdkAdapter; use Psr\Log\LoggerInterface; /** @@ -66,7 +68,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 * @@ -86,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()); @@ -94,25 +96,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) { + 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)'; - 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 +129,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 +191,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/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index b3223d7..0f026a5 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 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()` * 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,18 +97,20 @@ 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() } /** - * 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(); @@ -177,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) { @@ -200,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(); @@ -230,10 +240,17 @@ 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. + * + * 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); + return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false, prepare: $timeoutSeconds === null || $timeoutSeconds > 0); } /** @@ -260,8 +277,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())); } } @@ -280,7 +297,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(); } /** @@ -333,8 +356,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 the waiting events: a `flush()` with a window */ - 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; @@ -345,14 +369,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); } } @@ -382,14 +409,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) { @@ -423,31 +454,58 @@ private function finishCompanions(): void } } - /** TypeScript `processEvent` stages 0-5, then `sendEvent`. */ - private function dispatch(QueuedEvent $queued): void + /** + * The pipeline of the events that waited when the pass began, whatever the free slots (`flush()`): the payloads wait + * in order. An event a hook queues meanwhile waits for the next pass. A hook can only add waiting events (the cap + * drops a prepared one first, and a waiting one only for a new one), so `$left` never outruns the queue. + */ + private function prepareWaiting(): void + { + for ($left = $this->waiting->count(); $left > 0; --$left) { + $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/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index 0f7ced6..3031a46 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -15,23 +15,60 @@ 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; + } + + /** + * 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/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 @@ + $session && session_status() === PHP_SESSION_ACTIVE, + static function (): void { + session_write_close(); + }, + 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, $ini): void { + if (self::ignoreUserAbort($abort, $ini)) { + 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 + { + 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. + */ + 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/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php new file mode 100644 index 0000000..37ca6ab --- /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; + } + + return FinishRequest::outlivesRequest($sapi, $server ?? $_SERVER, $getenv) ? self::WORKER_SECONDS : 0.0; + } +} 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/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/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/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/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php new file mode 100644 index 0000000..94daed0 --- /dev/null +++ b/src/Internal/Engine/Libraries.php @@ -0,0 +1,62 @@ + */ + public static function all(): array + { + return [new McpSdkLibrary(), new LaravelMcpLibrary()]; + } + + 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/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/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/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/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/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/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 new file mode 100644 index 0000000..1475dcd --- /dev/null +++ b/src/Internal/Support/ConfigOptions.php @@ -0,0 +1,172 @@ +`, and the default is used. + * + * @internal + */ +final class ConfigOptions +{ + /** @var array 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']; + + 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. + * + * @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; + } + $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 + } + } + + 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, self::shown($entry))); + } + $off = $off || ($disabled ?? true); + } + + 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). + * + * @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/Support/TestMode.php b/src/Internal/Support/TestMode.php index d771a0d..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,21 +60,35 @@ 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 { - $value = self::normalized('AGENTCAT_ENABLED'); + return self::$configured ?? 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. */ @@ -70,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/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/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 new file mode 100644 index 0000000..74961d3 --- /dev/null +++ b/src/Laravel/AgentCatServiceProvider.php @@ -0,0 +1,69 @@ +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 ($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. + $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 { + $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 + } + + private function string(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} 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/AfterRequest.php b/src/Laravel/Internal/AfterRequest.php new file mode 100644 index 0000000..fdd75a8 --- /dev/null +++ b/src/Laravel/Internal/AfterRequest.php @@ -0,0 +1,60 @@ +terminating(static function (Application $current): void { + self::guarded(static function () use ($current): void { + AgentCat::client()->flush(RequestEndWait::seconds(self::truthy($current->runningInConsole()))); + }); + }); + $app->make('events')->listen(self::OCTANE_WORKER_STOPPING, static function (): void { + self::guarded(static function (): void { + AgentCat::client()->shutdown(); + }); + }); + } + + /** + * 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). + * + * @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/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php new file mode 100644 index 0000000..14558cb --- /dev/null +++ b/src/Laravel/Internal/AnswerLength.php @@ -0,0 +1,91 @@ +headersSent = $headersSent ?? static fn(): bool => headers_sent(); + } + + /** + * Listens for `RequestHandled` when responses end with the script. Nothing runs here. + * + * @param bool|null $responseEndsWithTheScript null: this process's SAPI decides + */ + 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, (new 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 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)); + } + } catch (\Throwable) { + // the answer goes out as it is + } + } + + /** Some output buffer level holds bytes, which PHP sends before the answer. */ + private function outputWaits(): bool + { + foreach (ob_get_status(true) as $status) { + $used = is_array($status) ? $status['buffer_used'] ?? null : null; + if (is_int($used) && $used > 0) { + return true; + } + } + + return false; + } +} diff --git a/src/Laravel/Internal/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/EnvFile.php b/src/Laravel/Internal/EnvFile.php new file mode 100644 index 0000000..5d4fc57 --- /dev/null +++ b/src/Laravel/Internal/EnvFile.php @@ -0,0 +1,67 @@ + ($match[1] ?? '') . $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 '/^(export[ \t]+)?' . preg_quote($key, '/') . '=[^\r\n]*/m'; + } + + private static function read(string $path): string + { + 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; + } + + 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/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php new file mode 100644 index 0000000..f888065 --- /dev/null +++ b/src/Laravel/Internal/ServerTracker.php @@ -0,0 +1,145 @@ + The keys of config/agentcat.php that the bridge reads itself; the others are options. */ + public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude', 'projects', 'log_channel', 'diagnostics_endpoint', 'diagnostics_token']; + + private ?Client $client = null; + + 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 : []; + $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, $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, $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 + } + } 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); + } + + private function project(Server $server, BridgeKeys $keys): ?string + { + $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; + + return $project ?? $keys->string('project_id'); + } + + /** + * 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 ($classes as $class) { + if (is_string($class) && $server instanceof $class) { + return $class; + } + } + + 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 + * 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, ?string $channel): void + { + if (Log::hasLogger()) { + return; + } + $log = $app->make('log'); + Log::adopt($channel !== null && $channel !== '' + ? $log->channel($channel) + : $log->build(['driver' => 'single', 'name' => 'agentcat', 'path' => storage_path('logs/agentcat.log')])); + } +} 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/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 new file mode 100644 index 0000000..3acd972 --- /dev/null +++ b/src/LaravelMcp/Internal/Delegate.php @@ -0,0 +1,35 @@ + $class + * + * @return iterable|JsonRpcResponse + */ + public static function handle(string $class, JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + // @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/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/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..5ea1a65 --- /dev/null +++ b/src/LaravelMcp/Internal/LaravelHttpContext.php @@ -0,0 +1,45 @@ +headers->all() as $name => $values) { + if (!in_array($name, self::MADE_UP_HEADERS, true)) { + $headers[$name] = implode(', ', $values); // a null value is '' + } + } + // The Host header, not the server variable: a request that a worker builds from PSR-7 has only the header. + $url = HttpContext::url(['HTTPS' => $request->server->get('HTTPS'), 'HTTP_HOST' => $request->headers->get('host'), 'REQUEST_URI' => $request->server->get('REQUEST_URI')]); + + return new HttpContext($request->getMethod(), $url, $headers); + } +} 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/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 `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 + ? 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) + } + $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); + } + + /** + * 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/src/LaravelMcp/Internal/TrackedInitialize.php b/src/LaravelMcp/Internal/TrackedInitialize.php new file mode 100644 index 0000000..39df0a1 --- /dev/null +++ b/src/LaravelMcp/Internal/TrackedInitialize.php @@ -0,0 +1,30 @@ + $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::handle($this->original, $request, $context); + } +} diff --git a/src/LaravelMcp/Internal/TrackedListTools.php b/src/LaravelMcp/Internal/TrackedListTools.php new file mode 100644 index 0000000..48efaa9 --- /dev/null +++ b/src/LaravelMcp/Internal/TrackedListTools.php @@ -0,0 +1,50 @@ + $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::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 + } + 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) { + ToolListing::injectionFailed($e); + + return $response; + } + } +} diff --git a/src/LaravelMcp/LaravelMcpAdapter.php b/src/LaravelMcp/LaravelMcpAdapter.php new file mode 100644 index 0000000..ecc881e --- /dev/null +++ b/src/LaravelMcp/LaravelMcpAdapter.php @@ -0,0 +1,200 @@ + + */ + 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. `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} + */ + 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 = $server->createContext()->implementation; + + return [$implementation->name, $implementation->version]; + } catch (\Throwable) { + return [null, null]; + } + }; + } + + /** + * 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()`. 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 `X-HTTP-Method-Override` header, which the + * client sends), and nothing is logged. + * + * @return \Closure(): ?HttpContext + */ + public static function httpContext(Server $server): \Closure + { + $reference = \WeakReference::create($server); + + return static function () use ($reference): ?HttpContext { + $server = $reference->get(); + $request = $server instanceof Server ? self::request($server) : null; + try { + return $request instanceof Request ? LaravelHttpContext::of($request) : null; + } catch (\Throwable) { + return null; // Laravel cannot read the request, which the client controls: this is no drift + } + }; + } + + /** + * 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)); + } + } + + /** + * 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; + } + + /** + * 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. Never throws. + */ + public 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 + { + $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/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/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/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 5ca0c37..7b76491 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -7,6 +7,8 @@ use AgentCat\Internal\Engine\CallContext; 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; @@ -48,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] @@ -129,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/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 2b595b6..fb66bd1 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; @@ -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/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/src/Options.php b/src/Options.php index fe702f0..c1b28b0 100644 --- a/src/Options.php +++ b/src/Options.php @@ -79,7 +79,9 @@ 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. 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 @@ -158,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/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/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/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..fd359e0 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,11 @@ 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\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)', 'Mcp\Server::$protocol', @@ -34,6 +40,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 +72,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/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/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/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/E2E/BufferedOutputTest.php b/tests/E2E/BufferedOutputTest.php new file mode 100644 index 0000000..be929c3 --- /dev/null +++ b/tests/E2E/BufferedOutputTest.php @@ -0,0 +1,102 @@ + '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; + FileTree::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; + } +} 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/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/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..8ef4cce --- /dev/null +++ b/tests/E2E/Laravel/bootstrap.php @@ -0,0 +1,57 @@ +booted(static function () use ($app): void { + $config = $app->make('config'); + $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; + 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_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); + 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..17ded66 --- /dev/null +++ b/tests/E2E/LaravelEndToEndTest.php @@ -0,0 +1,453 @@ + 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(); + 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']); + + $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->agentCatLog(self::$httpHome)); // D60 + $this->assertStringNotContainsString('AgentCat', $this->laravelLog(self::$httpHome)); + } + + /** + * 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 testAnAnswerGetsItsLengthAndReachesTheClientBeforeTheHooksAndTheDelivery(): void + { + $scenario = $this->collector()->scenario(); + $this->removeMarker(); + $sentAt = microtime(true); + + $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); + $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'); + } + + /** + * 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 testAStreamedAnswerKeepsItsOwnHeaders(): void + { + $scenario = $this->collector()->scenario(); + + $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(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')); + } + + /** + * 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')] + #[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($this->agentCatLogPath($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; + } + + /** + * 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', ?BuiltInServer $server = null): array + { + $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']); + + 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 */ + 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); + } + + /** 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 . '/app/storage/logs/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/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/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/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index 9a540de..e1de152 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -13,14 +13,17 @@ 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; +use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Tools\GetMoreTools; use AgentCat\Internal\Transport\CurlTransport; 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 +50,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.'; @@ -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(); } @@ -118,6 +120,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 +211,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 +233,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'); @@ -333,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 { @@ -514,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)); @@ -564,7 +605,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/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php new file mode 100644 index 0000000..d1e9e77 --- /dev/null +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -0,0 +1,361 @@ + $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 + { + $tools = $this->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()); + } + + /** + * 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 + { + $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's validation of the tool's own arguments fails: the tool does not run, and the event records it. */ + #[Group('ac-05-E6')] + 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..fdcc815 --- /dev/null +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -0,0 +1,180 @@ + 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(); + } + + /** 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 new file mode 100644 index 0000000..d40b6ef --- /dev/null +++ b/tests/Integration/Conformance/McpSdkConformanceTest.php @@ -0,0 +1,174 @@ +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(); + } + + /** 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/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/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/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/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/Integration/Laravel/CachedConfigurationTest.php b/tests/Integration/Laravel/CachedConfigurationTest.php new file mode 100644 index 0000000..ec2b3d1 --- /dev/null +++ b/tests/Integration/Laravel/CachedConfigurationTest.php @@ -0,0 +1,346 @@ + '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'; + 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 + } + + #[\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 + { + 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'); + } + + 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` 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 + { + 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')); + } + + /** 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()); + } + + #[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. + * + * @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/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..b53371a --- /dev/null +++ b/tests/Integration/Laravel/DeliveryTest.php @@ -0,0 +1,367 @@ +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; + } + + /** + * 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'); + $this->setRunningInConsole(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 (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(); + try { + $broken = new class extends Application { + public bool $failing = false; + + public int $thrown = 0; + + #[\Override] + public function runningInConsole(): bool + { + if (!$this->failing) { + return parent::runningInConsole(); // the constructor asks too + } + ++$this->thrown; + + throw new \RuntimeException('no environment'); + } + }; + } 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'); + }); + $reached = null; + + 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 + { + 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)); + } + + /** + * `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 + { + $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]; + } + + /** + * 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); + + return $this->app; + } +} 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 new file mode 100644 index 0000000..4e5d71b --- /dev/null +++ b/tests/Integration/Laravel/InstallCommandTest.php @@ -0,0 +1,177 @@ +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()); + } + + #[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 + { + $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/Integration/Laravel/ServiceProviderTest.php b/tests/Integration/Laravel/ServiceProviderTest.php new file mode 100644 index 0000000..abdb95c --- /dev/null +++ b/tests/Integration/Laravel/ServiceProviderTest.php @@ -0,0 +1,558 @@ +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', // 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, + ]); + } + + /** 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; + 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 + { + $this->ownConfiguration = true; + putenv('AGENTCAT_ENABLED=maybe'); + config(['agentcat.enabled' => 'maybe', 'agentcat.project_id' => LaravelTrackingFixture::PROJECT]); + } + + 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')); + } + + /** 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]]); + + $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')); + } + + /** 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'; + 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]; + } + + /** 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 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. */ + 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(); + (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->agentCatLog()); + } + + 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); + } + + public function testAnExcludedServerKeepsItsMethodsAndItsAnswers(): void + { + config(['agentcat.exclude' => [ConformanceServer::class]]); + + $this->assertUntouched(); + } + + #[DefineEnvironment('defineAgentCatOff')] + public function testWithAgentCatOffAServerKeepsItsMethodsAndItsAnswers(): void + { + $this->assertUntouched(); + } + + /** 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. */ + #[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'; + $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: 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 + $started, + $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)', + '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())); + } + + /** 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 + { + $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 + { + // 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; + Log::reset(); // closes the file logger of this HOME + } finally { + $home->restore(); + } + + $this->assertIsArray($tools); + $this->assertSame('own', JsonAssert::at(end($tools), 'name')); + $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 + { + $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')); + } + + /** + * 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 + { + $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()); + } + + /** + * 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/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php new file mode 100644 index 0000000..1593d6d --- /dev/null +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -0,0 +1,680 @@ +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')); + } + + /** Task 5 review minor 2: the client controls the headers; Symfony refuses an invalid method override. */ + public function testAnInvalidMethodOverrideCostsTheHttpContextOnly(): void + { + $fixture = new LaravelTrackingFixture(); + $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: $headers); + + $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()); + } + + /** 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 + { + 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 + { + $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); + } + + /** + * 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')); + $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')); + } + + /** + * 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 + { + $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()); + } + + /** + * 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); + + 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 new file mode 100644 index 0000000..5361dcb --- /dev/null +++ b/tests/Integration/LaravelMcp/LaravelMcpAdapterTest.php @@ -0,0 +1,322 @@ + */ + 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('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); + $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'); + // @phpstan-ignore argument.type (see above) + $this->addMethod('tools/call', 'customer.call'); + unset($this->methods['initialize']); + } + }; + + (new LaravelTrackingFixture())->track($server); + + $methods = ServerAccess::methods($server); + $this->assertSame('customer.list', $methods['tools/list'] ?? null); + $this->assertSame('customer.call', $methods['tools/call'] ?? 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()); + } + + /** + * `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); + $server = new class (new LineTransport()) extends Server { + #[\Override] + public function createContext(): ServerContext + { + throw new \RuntimeException('no context'); + } + }; + $deprecated = 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(['Laravel MCP Server', '0.0.1'], (LaravelMcpAdapter::serverInfo($deprecated))(), 'a deprecation is no failure'); + $this->assertSame([], $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); + } + + /** + * 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_X_HTTP_METHOD_OVERRIDE' => 'NOT A METHOD']))); + + $this->assertNull((LaravelMcpAdapter::httpContext($server))()); + $this->assertSame([], $logger->records); + } + + 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(Transport $transport = new LineTransport()): ConformanceServer + { + $server = $this->app?->make(ConformanceServer::class, ['transport' => $transport]); + $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..fece5c5 --- /dev/null +++ b/tests/Integration/LaravelMcp/ListToolsTest.php @@ -0,0 +1,354 @@ + '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()); + } + + /** 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()); + $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()); + $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 + { + $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/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/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/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/Integration/Support/MockCollector.php b/tests/Integration/Support/MockCollector.php index 6f5680d..df356fe 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 { @@ -100,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); 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/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 @@ + */ + public function __invoke(mixed ...$arguments): array + { + return array_values($arguments); + } +} diff --git a/tests/Support/Laravel/BridgeTestCase.php b/tests/Support/Laravel/BridgeTestCase.php new file mode 100644 index 0000000..42fdd86 --- /dev/null +++ b/tests/Support/Laravel/BridgeTestCase.php @@ -0,0 +1,108 @@ +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) : ''; + } + + /** 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) : ''; + } +} 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/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/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/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/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'); + } +} 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/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/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 @@ +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/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/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/LaravelHarness.php b/tests/Support/LaravelMcp/LaravelHarness.php new file mode 100644 index 0000000..25049bc --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelHarness.php @@ -0,0 +1,124 @@ + $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 + { + $server = ['CONTENT_TYPE' => 'application/json', 'HTTP_HOST' => 'localhost']; + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + + 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) { + ($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..b21d722 --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelStdioHarness.php @@ -0,0 +1,63 @@ +|(\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|\Closure $server, ?\Closure $track = null) + { + $this->transport = new LineTransport(); + $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); + } + $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/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php new file mode 100644 index 0000000..11820df --- /dev/null +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -0,0 +1,107 @@ + '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(); + 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); + AgentCat::setClient(null); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + + parent::tearDown(); + $this->environment?->restore(); + FileTree::remove($this->laravelDirectory); + } +} 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/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/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/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/RecordingMethod.php b/tests/Support/LaravelMcp/RecordingMethod.php new file mode 100644 index 0000000..2c2f1af --- /dev/null +++ b/tests/Support/LaravelMcp/RecordingMethod.php @@ -0,0 +1,38 @@ + 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 + * @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): iterable|JsonRpcResponse + { + $this->requests[] = $request; + if ($this->observe instanceof \Closure) { + $this->seen[] = ($this->observe)(); + } + + return $this->answer instanceof \Closure ? ($this->answer)($request) : JsonRpcResponse::result($request->id, ['ok' => true]); + } +} 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..5831fa8 --- /dev/null +++ b/tests/Support/LaravelMcp/StreamingServer.php @@ -0,0 +1,37 @@ +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/Support/LaravelMcp/ToolCalls.php b/tests/Support/LaravelMcp/ToolCalls.php new file mode 100644 index 0000000..58957fe --- /dev/null +++ b/tests/Support/LaravelMcp/ToolCalls.php @@ -0,0 +1,31 @@ +>> */ + 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] ?? []; + } + + /** Forgets every call, `CustomCallTool`'s too. */ + public static function reset(): void + { + self::$calls = []; + CustomCallTool::reset(); + } +} 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..d7ee05d --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/EchoTool.php @@ -0,0 +1,39 @@ +`. 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')] +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 + { + $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/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/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/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/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/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/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/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/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); + }; + } +} 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/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; + } +} 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/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index b9b2700..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']; @@ -483,6 +486,315 @@ public function testFlushWaitsWhileAllSlotsAreBusy(): void $this->assertSame(1, $transport->waits); } + /** + * 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 + { + $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(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(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 + + $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()); + } + + /** + * 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 + { + $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(1.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(1.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(1.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(1.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(1.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(1.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(1.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 +1556,36 @@ 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. + * + * @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 { diff --git a/tests/Unit/Internal/Delivery/FinishRequestTest.php b/tests/Unit/Internal/Delivery/FinishRequestTest.php index 75ec113..e97abdb 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,98 @@ 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(); + } + } + + /** @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); @@ -79,6 +172,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..b9749a9 --- /dev/null +++ b/tests/Unit/Internal/Delivery/RequestEndTest.php @@ -0,0 +1,372 @@ + */ + 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); + } + FileTree::remove($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'); + } + + /** 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); + $level = $this->openBuffers(); + + RequestEnd::detect(false, 'cli-server')->prepare(5.0); + + $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); + $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. 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); + $level = $this->openBuffers(); + + RequestEnd::detect(false, 'cli-server', static fn(string $function): bool => false)->prepare(5.0); + + $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 + { + 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]; + } + + /** + * 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); + } +} 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 + } +} 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 { 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 57bca24..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(); } @@ -91,7 +93,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'); @@ -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/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/Internal/Engine/LibrariesTest.php b/tests/Unit/Internal/Engine/LibrariesTest.php new file mode 100644 index 0000000..a78153d --- /dev/null +++ b/tests/Unit/Internal/Engine/LibrariesTest.php @@ -0,0 +1,54 @@ +assertCount(2, $libraries); + $this->assertInstanceOf(McpSdkLibrary::class, $libraries[0]); + $this->assertInstanceOf(LaravelMcpLibrary::class, $libraries[1]); + } + + 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 + { + $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 laravel/mcp 1.0+ (the Laravel service provider tracks every server).', + 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/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/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..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(); @@ -319,7 +351,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/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()); + } +} 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 new file mode 100644 index 0000000..1c225f7 --- /dev/null +++ b/tests/Unit/Internal/Support/ConfigOptionsTest.php @@ -0,0 +1,296 @@ +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 "agentcat.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', // 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', + '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) + } + + /** @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()); + } + + /** + * 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); + + $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/Support/TestModeTest.php b/tests/Unit/Internal/Support/TestModeTest.php index 55b3b87..ba2301c 100644 --- a/tests/Unit/Internal/Support/TestModeTest.php +++ b/tests/Unit/Internal/Support/TestModeTest.php @@ -179,6 +179,61 @@ 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)); + } + + /** 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/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/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 diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php new file mode 100644 index 0000000..c70c471 --- /dev/null +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -0,0 +1,196 @@ + 'application/json']); + + (new 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); + + (new 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); + + (new 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; + } + }; + + (new 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']); + + (new 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']); + + (new 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'); + } + }; + + (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')); + } + + 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; + } +} 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 new file mode 100644 index 0000000..79ef540 --- /dev/null +++ b/tests/Unit/Laravel/ConfigFileTest.php @@ -0,0 +1,33 @@ +assertIsArray($config); + array_walk_recursive($config, function (mixed $value): void { + $this->assertNotInstanceOf(\Closure::class, $value); // config:cache + }); + $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)])); + } + + 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/Laravel/EnvFileTest.php b/tests/Unit/Laravel/EnvFileTest.php new file mode 100644 index 0000000..bf63bbb --- /dev/null +++ b/tests/Unit/Laravel/EnvFileTest.php @@ -0,0 +1,157 @@ +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 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'); + + 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; + } +} 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/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..c84a334 --- /dev/null +++ b/tests/Unit/LaravelMcp/LaravelHttpContextTest.php @@ -0,0 +1,77 @@ + '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); + } + + /** 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'); + $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); + } +} 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/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; 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 diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index d326403..0e6e7e1 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -254,12 +254,21 @@ 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()); } + /** 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]);