From 94a3b557aea9cc5419145c35e4859ea0fcaeead2 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 10:43:06 +0000 Subject: [PATCH 01/68] test: run the mcp/sdk leg on the 0.9.0 tag --- .github/workflows/ci.yml | 10 +++++----- tests/E2E/StdioEndToEndTest.php | 4 ++-- .../Conformance/SymfonyConformanceTest.php | 2 +- tests/Integration/McpSdk/CallToolTest.php | 4 ++-- tests/Integration/McpSdk/ResponseEventTest.php | 6 +++--- tests/Integration/Symfony/WithoutMcpBundleTest.php | 2 +- tests/Support/McpSdk/McpSdkVersion.php | 9 ++++++--- tests/Support/Symfony/SymfonyTestCase.php | 2 +- tests/bin/{mcp-sdk-main.sh => mcp-sdk-leg.sh} | 12 +++++++----- 9 files changed, 28 insertions(+), 23 deletions(-) rename tests/bin/{mcp-sdk-main.sh => mcp-sdk-leg.sh} (65%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d76c4b..0f85352 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,8 +132,8 @@ jobs: if: github.event_name != 'pull_request' run: vendor/bin/infection --threads=max --logger-github - mcp-sdk-main: - name: mcp/sdk main (PHP 8.5) + mcp-sdk-0-9: + name: mcp/sdk 0.9.0 (PHP 8.5) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -148,8 +148,8 @@ jobs: dependency-versions: highest - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 run: composer remove --dev --no-update symfony/mcp-bundle - - name: Install mcp/sdk main at the commit this release was verified against - run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" + - name: Install the mcp/sdk 0.9.0 tag + run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:0.9.0" - run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E @@ -180,7 +180,7 @@ jobs: all-green: name: All green if: always() - needs: [static, tests, coverage, mutation, mcp-sdk-main, runtime] + needs: [static, tests, coverage, mutation, mcp-sdk-0-9, runtime] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index 9065ca4..f7feda6 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -272,11 +272,11 @@ public function testStdinEofSendsTheDiagnosticsOnce(): void $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); } - /** mcp/sdk main serves the 2026-07-28 era over stdio when the first request carries its envelope. */ + /** mcp/sdk 0.9.0 and main serve the 2026-07-28 era over stdio when the first request carries its envelope. */ #[MaximumDuration(15000)] public function testAModernEraFirstRequestIsTrackedWithoutAHandshake(): void { - if (!McpSdkVersion::isMain()) { + if (!McpSdkVersion::hasModernEra()) { self::markTestSkipped('mcp/sdk 0.8.1 serves only the handshake era over stdio'); } $client = $this->start(); diff --git a/tests/Integration/Conformance/SymfonyConformanceTest.php b/tests/Integration/Conformance/SymfonyConformanceTest.php index 83fd6ae..8b70181 100644 --- a/tests/Integration/Conformance/SymfonyConformanceTest.php +++ b/tests/Integration/Conformance/SymfonyConformanceTest.php @@ -45,7 +45,7 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); if (!class_exists(McpBundle::class)) { - return; // the mcp/sdk main leg: every test skips + return; // the mcp/sdk 0.9 leg: every test skips } self::$apiUrl = MockCollector::closedPortUrl(); WarmUp::run(static function (): void { diff --git a/tests/Integration/McpSdk/CallToolTest.php b/tests/Integration/McpSdk/CallToolTest.php index 2f2a332..071c6b1 100644 --- a/tests/Integration/McpSdk/CallToolTest.php +++ b/tests/Integration/McpSdk/CallToolTest.php @@ -263,7 +263,7 @@ public function testStructuredOutputEchoesTheAgentAndTheActiveSession(): void /** * Header conflict 4: mcp/sdk cannot tell `{}` from `[]`, so an empty structuredContent is an empty object to the - * mirror and to the event. On main (C3), mcp/sdk itself rejects `[]` against an object outputSchema first. + * mirror and to the event. On 0.9.0 and main (C3), mcp/sdk itself rejects `[]` against an object outputSchema first. */ public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void { @@ -276,7 +276,7 @@ public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); $event = $this->fixture->event(); - if (McpSdkVersion::isMain()) { + if (McpSdkVersion::hasModernEra()) { $this->assertTrue(JsonAssert::at($result, 'isError')); $texts = $this->texts($result); $this->assertCount(2, $texts); diff --git a/tests/Integration/McpSdk/ResponseEventTest.php b/tests/Integration/McpSdk/ResponseEventTest.php index 98afb08..5efcda4 100644 --- a/tests/Integration/McpSdk/ResponseEventTest.php +++ b/tests/Integration/McpSdk/ResponseEventTest.php @@ -131,11 +131,11 @@ public function testModernEraListenersSeeTheLibrarysTypes(): void $listing = $harness->listToolsModern(); $call = HttpHarness::result($harness->callToolModern('echo', ['msg' => 'hi'])); - // mcp/sdk 0.8.1 dispatches no ResponseEvent in the 2026-07-28 era; main does. + // mcp/sdk 0.8.1 dispatches no ResponseEvent in the 2026-07-28 era; 0.9.0 and main do. $lists = $this->dispatcher->results('tools/list'); $calls = $this->dispatcher->results('tools/call'); - $this->assertCount(McpSdkVersion::isMain() ? 1 : 0, $lists); - $this->assertCount(McpSdkVersion::isMain() ? 1 : 0, $calls); + $this->assertCount(McpSdkVersion::hasModernEra() ? 1 : 0, $lists); + $this->assertCount(McpSdkVersion::hasModernEra() ? 1 : 0, $calls); foreach ($lists as $result) { $this->assertInstanceOf(ListToolsResult::class, $result); $this->assertWireCarries($listing, $result); diff --git a/tests/Integration/Symfony/WithoutMcpBundleTest.php b/tests/Integration/Symfony/WithoutMcpBundleTest.php index b5903c3..3172273 100644 --- a/tests/Integration/Symfony/WithoutMcpBundleTest.php +++ b/tests/Integration/Symfony/WithoutMcpBundleTest.php @@ -22,7 +22,7 @@ /** * AgentCatBundle in a Symfony application without symfony/mcp-bundle, FrameworkBundle and AgentCatBundle only: the Flex * recipe registers the bundle in every application that requires agentcat/sdk, a plain mcp/sdk one too. These tests - * need no symfony/mcp-bundle, so they run in the mcp/sdk main leg too (`tests/bin/mcp-sdk-main.sh`), which removes it. + * need no symfony/mcp-bundle, so they run in the mcp/sdk 0.9 leg too (`tests/bin/mcp-sdk-leg.sh`), which removes it. */ final class WithoutMcpBundleTest extends SymfonyTestCase { diff --git a/tests/Support/McpSdk/McpSdkVersion.php b/tests/Support/McpSdk/McpSdkVersion.php index 47c1e46..d42467c 100644 --- a/tests/Support/McpSdk/McpSdkVersion.php +++ b/tests/Support/McpSdk/McpSdkVersion.php @@ -6,11 +6,14 @@ use AgentCat\Internal\SdkVersion; -/** Which mcp/sdk revision the suite runs against: the locked release, or main through tests/bin/mcp-sdk-main.sh. */ +/** Which mcp/sdk revision the suite runs against: the locked release, or another one through tests/bin/mcp-sdk-leg.sh. */ final class McpSdkVersion { - public static function isMain(): bool + /** Whether the installed mcp/sdk serves the 2026-07-28 era over stdio: 0.9.0 and later, or dev-main. Composer's pretty version of a tag can carry the `v` (v0.9.0), which version_compare() ranks below 0.9.0. */ + public static function hasModernEra(): bool { - return SdkVersion::installed('mcp/sdk') === 'dev-main'; + $installed = SdkVersion::installed('mcp/sdk'); + + return $installed === 'dev-main' || ($installed !== null && version_compare(ltrim($installed, 'v'), '0.9.0', '>=')); } } diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 86a69c6..ef91704 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -27,7 +27,7 @@ /** * Symfony applications with symfony/mcp-bundle, for the bundle tests. Skipped where the bundle is not installed (the - * mcp/sdk main leg removes it), unless a test class needs no symfony/mcp-bundle (`needsTheMcpBundle()`). Each kernel compiles its container into a directory of this test under the test HOME, + * mcp/sdk 0.9 leg removes it), unless a test class needs no symfony/mcp-bundle (`needsTheMcpBundle()`). Each kernel compiles its container into a directory of this test under the test HOME, * removed after the test (Ruling R8). The AGENTCAT_* variables of the process that runs the tests are cleared, so the * configuration decides, and the global event queue records its deliveries. */ diff --git a/tests/bin/mcp-sdk-main.sh b/tests/bin/mcp-sdk-leg.sh similarity index 65% rename from tests/bin/mcp-sdk-main.sh rename to tests/bin/mcp-sdk-leg.sh index 1a67fdf..44f777d 100755 --- a/tests/bin/mcp-sdk-main.sh +++ b/tests/bin/mcp-sdk-leg.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Runs PHPStan and the adapter suites against mcp/sdk main (82e19d1) in a throwaway copy, so vendor/ stays on 0.8.1. +# Runs PHPStan and the adapter suites against another mcp/sdk in a throwaway copy, so vendor/ stays on 0.8.1. +# Usage: tests/bin/mcp-sdk-leg.sh [constraint] (default 0.9.0; for example "dev-main as 0.9.99" for the nightly) # Without network access to GitHub, set MCP_SDK_REPO to a local clone (read-only), for example /home/ubuntu/misc/mcp-php-sdk. set -euo pipefail +constraint="${1:-0.9.0}" repo="$(cd "$(dirname "$0")/../.." && pwd)" -copy="$repo/.cache/mcp-sdk-main" +copy="$repo/.cache/mcp-sdk-leg" rm -rf "$copy" && mkdir -p "$copy" # Copy the tracked files and the untracked ones that git does not ignore. A tracked file deleted in the working tree # is skipped. @@ -15,10 +17,10 @@ git ls-files -z -co --exclude-standard | while IFS= read -r -d '' file; do done | tar --null -T - -cf - | tar -C "$copy" -xf - cd "$copy" if [ -n "${MCP_SDK_REPO:-}" ]; then - composer config repositories.mcp-sdk-main vcs "$MCP_SDK_REPO" + composer config repositories.mcp-sdk-leg vcs "$MCP_SDK_REPO" fi -# symfony/mcp-bundle requires mcp/sdk ^0.8.1, which main does not satisfy: the Symfony tests skip without it. +# symfony/mcp-bundle requires mcp/sdk ^0.8.1, which 0.9 and main do not satisfy: the Symfony tests skip without it. composer remove --dev --no-update symfony/mcp-bundle -composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" +composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:${constraint}" vendor/bin/phpstan analyse --no-progress --memory-limit=1G --configuration=phpstan.no-bundle.neon vendor/bin/phpunit --testsuite "${SUITES:-Contract,Integration,E2E}" From d92b881d8b0f994dfa6ccd2f47488d91c2ca46bb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 11:26:01 +0000 Subject: [PATCH 02/68] fix: read every tools/list page, so no listed tool falls back to the heuristic strip (D66) --- src/Internal/Engine/CallInterceptor.php | 2 +- src/Internal/Engine/CallSeam.php | 7 +- src/Internal/Engine/ToolListInjector.php | 15 +- src/Internal/Engine/ToolListing.php | 128 +++++- src/Internal/Engine/TrackedServer.php | 14 +- src/LaravelMcp/Internal/LaravelCallSeam.php | 17 +- src/LaravelMcp/Internal/TrackedListTools.php | 7 +- src/McpSdk/Internal/ListToolsInterceptor.php | 7 +- src/McpSdk/Internal/McpCallSeam.php | 8 +- .../Conformance/AdapterConformance.php | 26 ++ .../Conformance/LaravelMcpConformanceTest.php | 6 + .../Conformance/McpSdkConformanceTest.php | 6 + .../Conformance/SymfonyConformanceTest.php | 14 + .../Laravel/CustomerTestSuiteTest.php | 29 +- tests/Integration/LaravelMcp/CallToolTest.php | 89 +++++ .../Integration/LaravelMcp/ListToolsTest.php | 8 +- tests/Integration/McpSdk/CallToolTest.php | 106 +++++ tests/Integration/McpSdk/ListToolsTest.php | 27 +- tests/Support/FakeCallSeam.php | 22 +- .../LaravelMcp/LaravelConformanceHarness.php | 8 + tests/Support/LaravelMcp/PagedOwnServer.php | 29 ++ .../McpSdk/McpSdkConformanceHarness.php | 16 + .../Symfony/FullPage/SecondPageOwnTool.php | 24 ++ tests/Support/Symfony/TestKernel.php | 6 + .../Internal/Engine/CallInterceptorTest.php | 20 + .../Internal/Engine/ToolListInjectorTest.php | 25 ++ .../Unit/Internal/Engine/ToolListingTest.php | 372 ++++++++++++++++-- .../Internal/Engine/TrackedServerTest.php | 16 +- tests/Unit/LaravelMcp/LaravelCallSeamTest.php | 18 +- 29 files changed, 975 insertions(+), 97 deletions(-) create mode 100644 tests/Support/LaravelMcp/PagedOwnServer.php create mode 100644 tests/Support/Symfony/FullPage/SecondPageOwnTool.php diff --git a/src/Internal/Engine/CallInterceptor.php b/src/Internal/Engine/CallInterceptor.php index 393e6b3..90935be 100644 --- a/src/Internal/Engine/CallInterceptor.php +++ b/src/Internal/Engine/CallInterceptor.php @@ -59,7 +59,7 @@ public function intercept(TrackedServer $server, CallSeam $seam): mixed $start = ($this->clock)(); try { - ToolListing::ensureRegistries($server, $seam->listOriginal(...)); + ToolListing::ensureRegistries($server, $name, $seam->listOriginal(...)); $arguments = $seam->arguments(); $resolution = HandleResolver::resolve($arguments, $server->hookMode(), $options->enableAgentTracking, !$server->injection->hasDeclaredSessionParam($name)); $call = $this->start($server, $name, $arguments, $resolution, $seam->describe(), $start, $intercept); diff --git a/src/Internal/Engine/CallSeam.php b/src/Internal/Engine/CallSeam.php index c7dd50e..bb8de3b 100644 --- a/src/Internal/Engine/CallSeam.php +++ b/src/Internal/Engine/CallSeam.php @@ -65,11 +65,12 @@ public function replace(mixed $result, \stdClass $tree): mixed; public function answer(\stdClass $tree): mixed; /** - * The `tools` of the library's own first `tools/list` page, or null when there is none. May throw. + * One page of the library's own `tools/list`: the tools of the page at `$cursor` (null for the first) and its next + * cursor (`ToolListing::nextCursor()`; null on the last page), or null when the answer has no tool list. May throw. * - * @return list|null + * @return array{list, ?string}|null */ - public function listOriginal(): ?array; + public function listOriginal(?string $cursor): ?array; /** * For a result that completes later (a stream), a result that calls `$complete` with the final result, and yields diff --git a/src/Internal/Engine/ToolListInjector.php b/src/Internal/Engine/ToolListInjector.php index e9e8d88..0028982 100644 --- a/src/Internal/Engine/ToolListInjector.php +++ b/src/Internal/Engine/ToolListInjector.php @@ -13,7 +13,7 @@ /** * Port of the TypeScript `buildInjectedList`: adds `session_id`, `agent_id` and `context` to each listed tool's - * input schema, appends get_more_tools, and declares `mcp_session` in output schemas. + * input schema, appends get_more_tools to the last page (D66), and declares `mcp_session` in output schemas. * * @internal */ @@ -36,17 +36,20 @@ final class ToolListInjector private function __construct( private readonly InjectionConfig $config, private readonly InjectionState $state, + private readonly bool $lastPage, ) {} /** * Never throws. The input is never mutated; changed tools are deep copies. * * @param list $tools the `tools` of a `tools/list` result + * @param bool $lastPage the page has no next cursor: only the last page gets get_more_tools (D66, B10). A + * one-page listing is its own last page, as in TypeScript. */ - public static function inject(array $tools, InjectionConfig $config, InjectionState $state): InjectedList + public static function inject(array $tools, InjectionConfig $config, InjectionState $state, bool $lastPage = true): InjectedList { try { - return (new self($config, $state))->build($tools); + return (new self($config, $state, $lastPage))->build($tools); } catch (\Throwable $throwable) { ToolListing::injectionFailed($throwable); @@ -90,7 +93,7 @@ private function appendGetMoreTools(array $tools): array } foreach ($tools as $tool) { if ($this->nameOf($tool) === GetMoreTools::NAME) { - // The customer's tool stays listed, but AgentCat answers its calls. + // The customer's tool stays listed, but AgentCat answers its calls, on whichever page it is. if ($this->state->warnShadowOnce()) { Log::write(self::SHADOW_WARNING); } @@ -98,7 +101,9 @@ private function appendGetMoreTools(array $tools): array return $tools; } } - $tools[] = GetMoreTools::descriptor(); + if ($this->lastPage) { + $tools[] = GetMoreTools::descriptor(); + } return $tools; } diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index 8dbf1b8..ccedb76 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -6,10 +6,23 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\ErrorText; +use AgentCat\Internal\Tools\GetMoreTools; /** @internal */ final class ToolListing { + /** TS `REGISTRY_REBUILD_TIMEOUT_MS`: a rebuild asks for no further page after this many seconds (D66). */ + public const float REBUILD_BUDGET_SECONDS = 5.0; + + /** The most pages one rebuild reads (D66): 1,500 tools at laravel/mcp's default page size, 5,000 at mcp/sdk's. */ + public const int REBUILD_MAX_PAGES = 100; + + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + + private const string REBUILD_FAILED = 'Warning: registry rebuild-on-demand failed - '; + + private const string REBUILD_STOPPED = 'Warning: registry rebuild-on-demand stopped after page %d - %s'; + /** The listing is served as the library answered it, after this line (TS listWrap, ErrorText::message()). */ public static function injectionFailed(\Throwable $e): void { @@ -19,19 +32,21 @@ public static function injectionFailed(\Throwable $e): void /** * 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 - * (Plan A ledger carry). + * (Plan A ledger carry). PHP divergence (D66, B10 fixed): only the last page gets get_more_tools, and each page's + * registries merge into the server's. * * @param list|null $tools null when the answer has no tool list + * @param bool $lastPage the answer has no next cursor (`nextCursor()`) * @return list|null the tools to advertise, or null to serve the original listing */ - public static function apply(TrackedServer $server, ?array $tools): ?array + public static function apply(TrackedServer $server, ?array $tools, bool $lastPage): ?array { if ($tools === null || $tools === []) { Log::write(ToolListInjector::NO_TOOLS_WARNING); return null; } - $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection); + $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection, $lastPage); if ($list->degraded) { return null; } @@ -41,28 +56,117 @@ public static function apply(TrackedServer $server, ?array $tools): ?array } /** - * TS ensureRegistries (D17: no cache; runs on every call until a listing succeeds). No time bound in synchronous PHP. + * The cursor of the next page: a non-empty string. Anything else ends the listing, as `while (cursor)` does in the + * TypeScript client. `'0'` is a cursor. + */ + public static function nextCursor(mixed $cursor): ?string + { + return is_string($cursor) && $cursor !== '' ? $cursor : null; + } + + /** + * TS ensureRegistries (D17: no cache), reading the pages from the first (D66, B10 fixed). It runs on a call whose + * tool no listing of this server named, until one rebuild has read every page; under PHP-FPM that is every call, + * as before. It stops at the first page that names the tool (Plan F spec conflict 3), so a tool of page 1 costs one + * page, as before; a tool that no page names reads every page. AgentCat answers get_more_tools itself, so a call + * for it needs no rebuild. A page that cannot be read ends the rebuild: the pages before it are kept, the other + * tools keep the heuristic strip, and the next call for one of them tries again. A cursor already followed ends + * the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next page. * - * @param \Closure(): (list|null) $listOriginal the library's own first page; may throw + * @param \Closure(?string): (array{list, ?string}|null) $listPage the library's own page at a cursor (null + * for the first): its tools and its next + * cursor, or null when the answer has no + * tool list; may throw + * @param (\Closure(): float)|null $clock seconds on a monotonic clock; null reads `hrtime()` */ - public static function ensureRegistries(TrackedServer $server, \Closure $listOriginal): void + public static function ensureRegistries(TrackedServer $server, string $toolName, \Closure $listPage, ?\Closure $clock = null): void { - if ($server->registry !== null) { + if ( + $server->everyPageRead + || ($server->options->enableReportMissing && $toolName === GetMoreTools::NAME) + || ($server->registry !== null && array_key_exists($toolName, $server->registry)) + ) { return; } try { - $tools = $listOriginal(); - if ($tools === null) { + $read = self::readPages($toolName, $listPage, $clock ?? static fn(): float => hrtime(true) / 1e9); + if ($read === null) { return; } - $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection); + [$tools, $lastPage, $stopped] = $read; + $list = ToolListInjector::inject($tools, $server->injectionConfig(), $server->injection, $lastPage); if ($list->degraded) { return; } $server->adopt($list); - Log::write('Rebuilt injection registries on demand (tools/call before tools/list on this instance)'); + if ($stopped !== null) { + Log::write($stopped); + + return; + } + $server->everyPageRead = $lastPage; + Log::write(self::REBUILT); } catch (\Throwable $e) { - Log::write('Warning: registry rebuild-on-demand failed - ' . ErrorText::of($e)); + Log::write(self::REBUILD_FAILED . ErrorText::of($e)); } } + + /** + * Reads the pages from the first one, following each next cursor, up to the first page that names the tool. Null + * when the first page has no tool list (nothing is logged, as before); what listing the first page throws passes. + * + * @param \Closure(?string): (array{list, ?string}|null) $listPage + * @param \Closure(): float $clock + * @return array{list, bool, ?string}|null every tool read, whether the last page was read, and the line that + * says why the read stopped before the tool's page and the last page + * (null when it reached one of them) + */ + private static function readPages(string $toolName, \Closure $listPage, \Closure $clock): ?array + { + $deadline = $clock() + self::REBUILD_BUDGET_SECONDS; + $tools = []; + $followed = []; // the cursors followed, as keys and values + $cursor = null; + $read = 0; // pages read + while (true) { + try { + $page = $listPage($cursor); + } catch (\Throwable $e) { + if ($read === 0) { + throw $e; + } + + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, ErrorText::of($e))]; + } + if ($page === null) { + return $read === 0 ? null : [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('page %d has no tool list', $read + 1))]; + } + ++$read; + [$listed, $cursor] = $page; + $named = false; + foreach ($listed as $tool) { + $tools[] = $tool; + $named = $named || self::names($tool, $toolName); + } + if ($cursor === null || isset($followed[$cursor])) { + return [$tools, true, null]; // the last page, or a cursor back to a page already read + } + if ($named) { + return [$tools, false, null]; + } + $followed[$cursor] = $cursor; + if ($read === self::REBUILD_MAX_PAGES) { + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('the limit is %d pages', self::REBUILD_MAX_PAGES))]; + } + if ($clock() >= $deadline) { + return [$tools, false, sprintf(self::REBUILD_STOPPED, $read, sprintf('the budget of %s s is used up', self::REBUILD_BUDGET_SECONDS))]; + } + } + } + + /** The tool is named `$name`. A JSON tree's members are read without accessors, so this never throws. */ + private static function names(mixed $tool, string $name): bool + { + return $tool instanceof \stdClass && (get_object_vars($tool)['name'] ?? null) === $name; + } } diff --git a/src/Internal/Engine/TrackedServer.php b/src/Internal/Engine/TrackedServer.php index 2bc7bd3..4ad5d0b 100644 --- a/src/Internal/Engine/TrackedServer.php +++ b/src/Internal/Engine/TrackedServer.php @@ -22,6 +22,9 @@ final class TrackedServer /** @var list|null tools whose outputSchema declares `mcp_session` */ public ?array $outputRegistry = null; + /** True once a rebuild read every page (D66): a tool the registry lacks is then in no listing. */ + public bool $everyPageRead = false; + /** * @param string $projectId '' in telemetry-only mode * @param \Closure(): array{0: ?string, 1: ?string} $serverInfo server name and version, read when an event is published @@ -69,11 +72,16 @@ public function mirrorAllowed(string $toolName): bool return $this->outputRegistry === null || in_array($toolName, $this->outputRegistry, true); } - /** Replaces both registries wholesale. */ + /** + * Merges one page's registries into the server's (D66, B10 fixed; TypeScript replaces them): the page's entries + * replace those of the same tools, and the output registry keeps the tools of the other pages. `array_replace()` + * keeps the int keys that PHP makes of numeric-string names, and `array_diff()` compares them as strings. Every + * tool of a page's output registry has an entry in its registry, so the merged output registry has no duplicate. + */ public function adopt(InjectedList $list): void { - $this->registry = $list->registry; - $this->outputRegistry = $list->outputRegistry; + $this->registry = array_replace($this->registry ?? [], $list->registry); + $this->outputRegistry = [...array_diff($this->outputRegistry ?? [], array_keys($list->registry)), ...$list->outputRegistry]; } /** @return array{0: ?string, 1: ?string} */ diff --git a/src/LaravelMcp/Internal/LaravelCallSeam.php b/src/LaravelMcp/Internal/LaravelCallSeam.php index 46adf79..8ddbc1d 100644 --- a/src/LaravelMcp/Internal/LaravelCallSeam.php +++ b/src/LaravelMcp/Internal/LaravelCallSeam.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\ThrowableStash; +use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Json\JsonTree; use Illuminate\Container\Container; use Laravel\Mcp\Request as McpRequest; @@ -139,11 +140,19 @@ public function answer(\stdClass $tree): JsonRpcResponse } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { - return $this->listDelegate instanceof \Closure - ? LaravelTrees::tools(($this->listDelegate)(new JsonRpcRequest('agentcat-rebuild', 'tools/list', []))) - : null; + if (!$this->listDelegate instanceof \Closure) { + return null; + } + $answer = ($this->listDelegate)(new JsonRpcRequest('agentcat-rebuild', 'tools/list', $cursor === null ? [] : ['cursor' => $cursor])); + $tools = LaravelTrees::tools($answer); + if ($tools === null || !$answer instanceof JsonRpcResponse) { + return null; + } + $result = $answer->content['result'] ?? null; + + return [$tools, ToolListing::nextCursor(is_array($result) ? ($result['nextCursor'] ?? null) : null)]; } /** diff --git a/src/LaravelMcp/Internal/TrackedListTools.php b/src/LaravelMcp/Internal/TrackedListTools.php index 48efaa9..e333686 100644 --- a/src/LaravelMcp/Internal/TrackedListTools.php +++ b/src/LaravelMcp/Internal/TrackedListTools.php @@ -14,8 +14,8 @@ /** * Answers `tools/list` (TS listWrap): the handler the server's entry named before AgentCat's lists, then AgentCat - * injects its parameters and appends get_more_tools to every page. Streams, JSON-RPC errors, exceptions and answers - * without a tool list pass unchanged (02 AC-10). + * injects its parameters into every page and appends get_more_tools to the last one (D66). Streams, JSON-RPC errors, + * exceptions and answers without a tool list pass unchanged (02 AC-10). * * @internal */ @@ -34,8 +34,9 @@ public function handle(JsonRpcRequest $request, ServerContext $context): iterabl } 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; + $lastPage = ToolListing::nextCursor(is_array($result) ? ($result['nextCursor'] ?? null) : null) === null; + $tools = ToolListing::apply($this->server, LaravelTrees::tools($response), $lastPage); if ($tools === null || !is_array($result)) { return $response; } diff --git a/src/McpSdk/Internal/ListToolsInterceptor.php b/src/McpSdk/Internal/ListToolsInterceptor.php index a4f7209..2f2f43b 100644 --- a/src/McpSdk/Internal/ListToolsInterceptor.php +++ b/src/McpSdk/Internal/ListToolsInterceptor.php @@ -15,8 +15,8 @@ use Mcp\Server\Session\SessionInterface; /** - * Answers `tools/list` first (TS listWrap): the next handler lists, then AgentCat injects its parameters and - * appends get_more_tools. Errors, exceptions and other answers pass unchanged (02 AC-10). + * Answers `tools/list` first (TS listWrap): the next handler lists, then AgentCat injects its parameters into the page + * and appends get_more_tools to the last one (D66). Errors, exceptions and other answers pass unchanged (02 AC-10). * * @implements RequestHandlerInterface * @@ -53,7 +53,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { // The library's own wire tree: AgentCat changes only its `tools`. Null: another answer, or no tool list. $listing = Trees::listing($response->result); - $tools = ToolListing::apply($this->server, $listing[2] ?? null); + $lastPage = ToolListing::nextCursor($listing[1]->nextCursor ?? null) === null; + $tools = ToolListing::apply($this->server, $listing[2] ?? null, $lastPage); if ($listing === null || $tools === null) { return $response; } diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index 7b76491..ef35622 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Engine\CallSeam; use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\ThrowableStash; +use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Json\JsonTree; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -121,15 +122,16 @@ public function answer(\stdClass $tree): Response } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { $delegate = ($this->listDelegate)(); if (!$delegate instanceof RequestHandlerInterface) { return null; } - $answer = $delegate->handle((new ListToolsRequest())->withId('agentcat-rebuild'), $this->session); + $answer = $delegate->handle((new ListToolsRequest($cursor))->withId('agentcat-rebuild'), $this->session); + $listing = $answer instanceof Response ? Trees::listing($answer->result) : null; - return $answer instanceof Response ? (Trees::listing($answer->result)[2] ?? null) : null; + return $listing === null ? null : [$listing[2], ToolListing::nextCursor($listing[1]->nextCursor ?? null)]; } /** mcp/sdk answers are complete when `handle()` returns: a tool that waits suspends its fiber inside it. */ diff --git a/tests/Integration/Conformance/AdapterConformance.php b/tests/Integration/Conformance/AdapterConformance.php index d1e9e77..6f590f1 100644 --- a/tests/Integration/Conformance/AdapterConformance.php +++ b/tests/Integration/Conformance/AdapterConformance.php @@ -44,6 +44,12 @@ abstract protected function reusedObjectId(): ConformanceHarness; */ abstract protected function fullPage(): ConformanceHarness; + /** + * A server whose listing has two pages of 15: `echo` and `t01` to `t14` on page 1, then `own` (its own required + * `session_id`) alone on page 2. + */ + abstract protected function paged(): ConformanceHarness; + /** * The `parameters.extra.requestInfo` that the leg records for one `tools/call` of `echo` (a supplied session) that * a web server hands to PHP with these server variables. The mcp/sdk adapter reads the SAPI's variables, so its @@ -295,6 +301,26 @@ public function testConformanceOneFullPage(): void $this->assertSame(array_fill(0, 14, ['session_id', 'context']), array_map(fn(mixed $tool): array => $this->memberNames(JsonAssert::at($tool, 'inputSchema', 'properties')), array_slice($tools, 1, 14))); } + /** + * D66 (B10 fixed): a client lists page 1 only, then calls `own` from page 2. A long-running leg now holds page 1's + * registries, which do not name `own`, so the call rebuilds to page 2: `own` keeps its own `session_id`. + */ + #[Group('ac-02-8')] + #[Group('ac-02-19')] + public function testConformanceSecondPage(): void + { + $harness = $this->paged(); + $first = $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'theirs']); + + $names = array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $this->listedTools($first)); + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $names); + $this->assertObjectHasProperty('nextCursor', $first); + $this->assertSame([['session_id' => 'theirs']], $harness->received('own')); + $this->assertSame('foreign', $this->onlyEvent($harness)->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + /** 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 diff --git a/tests/Integration/Conformance/LaravelMcpConformanceTest.php b/tests/Integration/Conformance/LaravelMcpConformanceTest.php index 226f508..7c3a5dc 100644 --- a/tests/Integration/Conformance/LaravelMcpConformanceTest.php +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -165,6 +165,12 @@ protected function fullPage(): ConformanceHarness return LaravelConformanceHarness::fullPage(); } + #[\Override] + protected function paged(): ConformanceHarness + { + return LaravelConformanceHarness::paged(); + } + /** One HTTP request to a tracked server, built from the server variables like `Request::capture()` does. */ #[\Override] protected function requestInfoOf(array $server): \stdClass diff --git a/tests/Integration/Conformance/McpSdkConformanceTest.php b/tests/Integration/Conformance/McpSdkConformanceTest.php index d40b6ef..262dc40 100644 --- a/tests/Integration/Conformance/McpSdkConformanceTest.php +++ b/tests/Integration/Conformance/McpSdkConformanceTest.php @@ -159,6 +159,12 @@ protected function fullPage(): ConformanceHarness return McpSdkConformanceHarness::fullPage(); } + #[\Override] + protected function paged(): ConformanceHarness + { + return McpSdkConformanceHarness::paged(); + } + /** The adapter's reader with what `HttpContext::capture()` gives under PHP-FPM for these server variables. */ #[\Override] protected function requestInfoOf(array $server): \stdClass diff --git a/tests/Integration/Conformance/SymfonyConformanceTest.php b/tests/Integration/Conformance/SymfonyConformanceTest.php index 8b70181..9cb53d8 100644 --- a/tests/Integration/Conformance/SymfonyConformanceTest.php +++ b/tests/Integration/Conformance/SymfonyConformanceTest.php @@ -11,6 +11,7 @@ use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\Symfony\FullPage\FullPageTools; +use AgentCat\Tests\Support\Symfony\FullPage\SecondPageOwnTool; use AgentCat\Tests\Support\Symfony\SymfonyConformanceHarness; use AgentCat\Tests\Support\Symfony\SymfonyTestCase; use AgentCat\Tests\Support\Symfony\TestKernel; @@ -57,6 +58,7 @@ public static function setUpBeforeClass(): void $compile->conformanceKernel(new Options()); $compile->conformanceKernel(new Options(enableTracing: false)); $compile->fullPageKernel(); + $compile->pagedKernel(); $compiled = true; } finally { $compile->tearDown(); @@ -123,6 +125,12 @@ protected function fullPage(): ConformanceHarness return SymfonyConformanceHarness::of($this->fullPageKernel(...)); } + #[\Override] + protected function paged(): ConformanceHarness + { + return SymfonyConformanceHarness::of($this->pagedKernel(...)); + } + /** The adapter's reader with the bundle's request context: a `RequestStack` whose main request has these values. */ #[\Override] protected function requestInfoOf(array $server): \stdClass @@ -150,6 +158,12 @@ private function fullPageKernel(): TestKernel return $this->compiled($this->config(new Options()), TestKernel::FULL_PAGE, [FullPageTools::class => FullPageTools::class]); } + /** A kernel of `TestKernel::PAGED`. */ + private function pagedKernel(): TestKernel + { + return $this->compiled($this->config(new Options()), TestKernel::PAGED, [FullPageTools::class => FullPageTools::class, SecondPageOwnTool::class => SecondPageOwnTool::class]); + } + /** * A kernel whose container this class compiled for its configuration. * diff --git a/tests/Integration/Laravel/CustomerTestSuiteTest.php b/tests/Integration/Laravel/CustomerTestSuiteTest.php index 6fc6423..6642999 100644 --- a/tests/Integration/Laravel/CustomerTestSuiteTest.php +++ b/tests/Integration/Laravel/CustomerTestSuiteTest.php @@ -12,14 +12,19 @@ use AgentCat\LaravelMcp\Internal\Access\ServerAccess; use AgentCat\Tests\Support\Laravel\BridgeTestCase; use AgentCat\Tests\Support\LaravelMcp\ConformanceServer; +use AgentCat\Tests\Support\LaravelMcp\PagedOwnServer; +use AgentCat\Tests\Support\LaravelMcp\PagedServer; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; +use AgentCat\Tests\Support\LaravelMcp\Tools\OwnTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\Methods\ListTools; use Laravel\Mcp\Server\Testing\TestListResponse; use Laravel\Mcp\Server\Transport\FakeTransporter; +use PHPUnit\Framework\Attributes\Group; /** * A customer's Laravel test suite with AgentCat installed (plan E1 header conflict 18, D31, Review Focus 2): laravel/mcp's @@ -77,7 +82,7 @@ public function testAFakeRecordsTheHelpersCalls(): void $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. */ + /** A tracked listing ends with AgentCat's tool: `tools()` follows `nextCursor`, and it comes once, after the last page (D66). */ public function testWithAFakeTheListingEndsWithTheGetMoreToolsTool(): void { AgentCat::fake(); @@ -85,6 +90,28 @@ public function testWithAFakeTheListingEndsWithTheGetMoreToolsTool(): void $this->assertSame([...self::TOOLS, GetMoreTools::NAME], $this->names(ConformanceServer::tools())); } + /** D66 (B10 fixed): `tools()` follows `nextCursor` on one server, and only the last page has AgentCat's tool. */ + #[Group('ac-02-8')] + public function testWithAFakeAPagedListingEndsWithOneGetMoreTools(): void + { + AgentCat::fake(); + + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 15)), GetMoreTools::NAME], $this->names(PagedServer::tools())); + } + + /** D66 (B10 fixed): each `tool()` builds a new server, whose rebuild reads up to page 2, where `own` is. */ + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testWithAFakeAToolOnTheSecondPageKeepsItsOwnSessionId(): void + { + $fake = AgentCat::fake(); + + PagedOwnServer::tool(OwnTool::class, ['session_id' => 'theirs'])->assertSee('ok'); + + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $fake->assertTrackedCount(1); + } + /** @return list */ private function names(TestListResponse $tools): array { diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 3b54588..56cfa27 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -20,6 +20,8 @@ use AgentCat\Tests\Support\LaravelMcp\LaravelTestCase; use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; use AgentCat\Tests\Support\LaravelMcp\LineTransport; +use AgentCat\Tests\Support\LaravelMcp\ListingServer; +use AgentCat\Tests\Support\LaravelMcp\PagedOwnServer; use AgentCat\Tests\Support\LaravelMcp\RecordingCallServer; use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; use AgentCat\Tests\Support\LaravelMcp\RequestEchoServer; @@ -45,6 +47,8 @@ final class CallToolTest extends LaravelTestCase { private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + #[Group('ac-02-16')] #[Group('ac-02-47')] public function testTheToolGetsItsOwnArgumentsAndTheEventRecordsTheCall(): void @@ -117,6 +121,82 @@ public function __construct(Transport $transport) $this->assertCount(1, $fixture->events()); } + /** @return iterable */ + public static function eras(): iterable + { + yield '2026-07-28' => [static fn(LaravelHarness $harness): \stdClass => $harness->callTool('own', ['session_id' => 'theirs', 'context' => 'why'])]; + yield 'legacy' => [static fn(LaravelHarness $harness): \stdClass => $harness->request('tools/call', ['name' => 'own', 'arguments' => ['session_id' => 'theirs', 'context' => 'why']])]; + } + + /** + * D66 (B10 fixed): a new server per request, as under PHP-FPM, rebuilds from page 1 to the page that names the + * tool, so `own`, alone on page 2, is stripped exactly and keeps its own `session_id`. + * + * @param \Closure(LaravelHarness): \stdClass $call + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testAToolOnTheSecondPageKeepsItsOwnSessionId(\Closure $call): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedOwnServer::class, $fixture->tracker()); + + $answer = $call($harness); + + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $this->assertEquals([(object) ['type' => 'text', 'text' => 'ok']], JsonAssert::at($answer, 'result', 'content')); // no "unrecognized" block + $event = $fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('why', $event->userIntent); + $this->assertContains(self::REBUILT, $fixture->lines()); + } + + /** D66 (B10 fixed): on a long-running server each listed page merges into the registries, so no call rebuilds. */ + #[Group('ac-02-8')] + public function testListedPagesMergeOnALongRunningServer(): void + { + $fixture = new LaravelTrackingFixture(); + $stdio = new LaravelStdioHarness(PagedOwnServer::class, $fixture->tracker()); + $first = JsonAssert::at($stdio->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => new \stdClass()]), 'result'); + $cursor = JsonAssert::at($first, 'nextCursor'); + $this->assertIsString($cursor); + $second = JsonAssert::at($stdio->send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => ['cursor' => $cursor]]), 'result'); + + $stdio->send(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']]]); + $stdio->send(['jsonrpc' => '2.0', 'id' => 4, 'method' => 'tools/call', 'params' => ['name' => 'own', 'arguments' => ['session_id' => 'theirs']]]); + + $this->assertSame(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $this->names($first)); + $this->assertSame(['own', GetMoreTools::NAME], $this->names($second)); + $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], ToolCalls::of('echo'), 'agent tracking is off: agent_id is not ours'); + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); + $this->assertNotContains(self::REBUILT, $fixture->lines(), 'both tools were listed'); + } + + /** + * D66 (B10 fixed), with the rebuild's early stop (Plan F spec conflict 3): no page that the rebuild could read names + * `echo`, and page 2 fails. Page 1 is kept, one line says where the rebuild stopped, and the call is served with the + * heuristic strip. + */ + #[Group('ac-02-21')] + public function testASecondPageThatFailsLeavesTheCallServed(): void + { + $list = new RecordingMethod(answer: static fn(JsonRpcRequest $request): JsonRpcResponse => isset($request->params['cursor']) + ? throw new \RuntimeException('page down') + : JsonRpcResponse::result($request->id, ['tools' => [['name' => 't01', 'inputSchema' => ['type' => 'object', 'properties' => new \stdClass()]]], 'nextCursor' => 'p2'])); + $this->app?->instance(RecordingMethod::class, $list); + $fixture = new LaravelTrackingFixture(); + + $answer = (new LaravelHarness(ListingServer::class, $fixture->tracker()))->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start', 'context' => 'why']); + + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 1, 'text')); + $this->assertSame([['msg' => 'hi']], ToolCalls::of('echo')); + $this->assertSame([null, 'p2'], array_map(static fn(JsonRpcRequest $request): mixed => $request->params['cursor'] ?? null, $list->requests)); + $this->assertSame('minted', $fixture->event()->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame(['Warning: registry rebuild-on-demand stopped after page 1 - RuntimeException: page down'], $fixture->lines()); + } + #[Group('ac-02-24')] #[Group('ac-05-E5')] public function testAThrownToolExceptionIsRecordedAsItself(): void @@ -661,6 +741,15 @@ private function httpStream(?\Closure $track, string $tool): string return implode('', $sent->getArrayCopy()); } + /** @return list the names of a listing's tools */ + private function names(mixed $result): array + { + $tools = JsonAssert::at($result, 'tools'); + $this->assertIsList($tools); + + return array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools); + } + private function text(mixed $value): string { $this->assertIsString($value); diff --git a/tests/Integration/LaravelMcp/ListToolsTest.php b/tests/Integration/LaravelMcp/ListToolsTest.php index fece5c5..19b5045 100644 --- a/tests/Integration/LaravelMcp/ListToolsTest.php +++ b/tests/Integration/LaravelMcp/ListToolsTest.php @@ -54,8 +54,9 @@ public function testListedToolsGetTheInjectedParametersAndGetMoreToolsComesLast( $this->assertSame(GetMoreTools::descriptor()->description, JsonAssert::at($result, 'tools', 5, 'description')); } + /** D66 (B10 fixed): every page is injected, and only the last one, without a next cursor, gets get_more_tools. */ #[Group('ac-02-8')] - public function testEveryPageGetsGetMoreToolsAndKeepsItsCursor(): void + public function testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsItsCursor(): void { $fixture = new LaravelTrackingFixture(); $harness = new LaravelHarness(PagedServer::class, $fixture->tracker()); @@ -65,8 +66,7 @@ public function testEveryPageGetsGetMoreToolsAndKeepsItsCursor(): void $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(['echo', ...array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 14))], $this->names($first)); $this->assertSame($twin->nextCursor, $first->nextCursor); $this->assertSame(['t15', GetMoreTools::NAME], $this->names($second)); $this->assertObjectNotHasProperty('nextCursor', $second); @@ -133,7 +133,7 @@ public function testACustomerListHandlerIsWrapped(): void $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(['x'], $this->names($result)); // D66: a page with a next cursor has no get_more_tools $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); diff --git a/tests/Integration/McpSdk/CallToolTest.php b/tests/Integration/McpSdk/CallToolTest.php index 071c6b1..a3099b1 100644 --- a/tests/Integration/McpSdk/CallToolTest.php +++ b/tests/Integration/McpSdk/CallToolTest.php @@ -31,6 +31,8 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Tool; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -39,6 +41,8 @@ final class CallToolTest extends TestCase { private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + private TrackingFixture $fixture; #[\Override] @@ -171,6 +175,108 @@ public function testAFailedRebuildStripsHeuristicallyAndIsTriedAgain(): void $this->assertCount(2, $this->fixture->events()); } + /** @return iterable */ + public static function eras(): iterable + { + yield 'handshake' => [false]; + yield '2026-07-28' => [true]; + } + + /** + * D66 (B10 fixed): a server that never listed (every PHP-FPM request) rebuilds from page 1 to the page that names + * the tool, so the 51st tool, alone on page 2 of mcp/sdk's pages of 50, keeps its own required `session_id`. + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-20')] + public function testTheFiftyFirstToolKeepsItsOwnSessionId(bool $modern): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $arguments = ['session_id' => 'theirs', 'agent_id' => 'a1']; + + $answer = $modern ? $harness->callToolModern('own', $arguments) : $harness->callTool('own', $arguments); + + $this->assertObjectNotHasProperty('error', $answer); // without D66: -32602, "Missing required properties" + $this->assertSame([['session_id' => 'theirs', 'agent_id' => 'a1']], $own->calls); + $this->assertSame('foreign', $this->tag($this->fixture->event(), Constants::TAG_SESSION_SOURCE)); + $this->assertCount(1, array_filter($this->fixture->lines(), static fn(string $line): bool => $line === self::REBUILT)); + } + + /** D66 (B10 fixed): each listed page merges into the registries, so a tool of an earlier page is stripped exactly. */ + #[Group('ac-02-8')] + public function testListedPagesMergeIntoTheRegistries(): void + { + $builder = TrackingFixture::builder()->setPaginationLimit(1); + $echo = Tools::echo($builder); + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $cursor = JsonAssert::at($harness->listTools(), 'nextCursor'); + $this->assertIsString($cursor); + $harness->listTools($cursor); + + $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']); + $harness->callTool('own', ['session_id' => 'mine']); + + $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], $echo->calls, 'agent tracking is off: agent_id is not ours'); + $this->assertSame([['session_id' => 'mine']], $own->calls); + $this->assertNotContains(self::REBUILT, $this->fixture->lines()); + } + + /** + * D66 (B10 fixed), with the rebuild's early stop (Plan F spec conflict 3): a customer list handler that ignores the + * cursor gives the same next cursor again. The rebuild for a tool that no page names reads two pages and stops at + * that cursor, as at the last page; the call is served with the heuristic strip. + */ + #[Group('ac-02-20')] + #[Group('ac-04-30')] + public function testAListHandlerThatIgnoresTheCursorEndsTheRebuild(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $listed = new Tool(name: 'listed', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: 'Listed', annotations: null); + $list = new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$listed], 'again')); + $builder->addRequestHandler($list); + $harness = $this->fixture->harness($builder); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start', 'context' => 'why']); + + $this->assertSame(2, $list->calls); // page 1, then page "again", whose cursor was already followed + $this->assertSame([['msg' => 'hi']], $echo->calls); // no page names echo: the heuristic strip + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 1, 'text')); + $this->assertContains(self::REBUILT, $this->fixture->lines()); + $this->assertSame([], array_values(array_filter($this->fixture->lines(), static fn(string $line): bool => str_starts_with($line, 'Warning:')))); + } + + /** + * D66: the rebuild reads the cursor that the client gets. mcp/sdk sends no `nextCursor` for a falsy one, so a page + * whose next cursor is `'0'` is the last page for the rebuild too, as for the listing. + */ + #[Group('ac-02-20')] + public function testANextCursorTheLibraryDoesNotSendEndsTheRebuild(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $listed = new Tool(name: 'listed', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: 'Listed', annotations: null); + $list = new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$listed], '0')); + $builder->addRequestHandler($list); + $harness = $this->fixture->harness($builder); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame(1, $list->calls); + $this->assertSame([['msg' => 'hi']], $echo->calls); + $this->assertContains(self::REBUILT, $this->fixture->lines()); + } + /** @return iterable}> */ public static function sessionlessArguments(): iterable { diff --git a/tests/Integration/McpSdk/ListToolsTest.php b/tests/Integration/McpSdk/ListToolsTest.php index a9b28c0..2881bd5 100644 --- a/tests/Integration/McpSdk/ListToolsTest.php +++ b/tests/Integration/McpSdk/ListToolsTest.php @@ -114,8 +114,9 @@ public function testTheModernEraGetsTheSameInjectedListing(): void $this->assertSame('complete', $modern->resultType); } + /** D66 (B10 fixed): every page is injected, and only the last one, without a next cursor, gets get_more_tools. */ #[Group('ac-02-8')] - public function testEveryPageGetsGetMoreToolsAndKeepsTheLibrarysCursor(): void + public function testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsTheLibrarysCursor(): void { $build = function (bool $tracked): HttpHarness { $builder = $this->builder()->setPaginationLimit(1) @@ -134,7 +135,7 @@ public function testEveryPageGetsGetMoreToolsAndKeepsTheLibrarysCursor(): void $this->assertIsString($first->nextCursor); $second = $harness->listTools($first->nextCursor); - $this->assertSame(['a', GetMoreTools::NAME], $this->names($first)); + $this->assertSame(['a'], $this->names($first)); $this->assertSame($twin->nextCursor, $first->nextCursor); $this->assertSame(['b', GetMoreTools::NAME], $this->names($second)); $this->assertObjectNotHasProperty('nextCursor', $second); @@ -373,17 +374,23 @@ public function testACustomerListHandlerAddedAfterInstallGetsTheInjection(): voi $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); } - /** @return iterable */ + /** + * The cursor a customer handler answers, and the names AgentCat lists. D66: a page with a next cursor on the wire + * has no get_more_tools. mcp/sdk sends no `nextCursor` for a falsy one (`''`, `'0'`), so such a page is the last. + * + * @return iterable}> + */ public static function cursors(): iterable { - yield 'none' => [null]; - yield 'empty' => ['']; - yield 'zero' => ['0']; - yield 'opaque' => ['c']; + yield 'none' => [null, ['x', GetMoreTools::NAME]]; + yield 'empty' => ['', ['x', GetMoreTools::NAME]]; + yield 'zero' => ['0', ['x', GetMoreTools::NAME]]; + yield 'opaque' => ['c', ['x']]; } + /** @param list $names */ #[DataProvider('cursors')] - public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor): void + public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor, array $names): void { $build = function (bool $tracked) use ($cursor): HttpHarness { $builder = $this->builder(); @@ -398,7 +405,7 @@ public function testTheCursorIsSentWhenTheLibraryWouldSendIt(?string $cursor): v $result = $build(true)->listTools(); - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame($names, $this->names($result)); $this->assertSame($this->keys($twin), $this->keys($result)); $this->assertSame($twin->nextCursor ?? null, $result->nextCursor ?? null); } @@ -410,7 +417,7 @@ public function testMembersALaterLibraryAddsToTheListingAreKept(): void $result = $this->customerListing(true, $listing); - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['x'], $this->names($result)); // D66: a page with a next cursor has no get_more_tools $this->assertSame(['tools', 'nextCursor', '_meta'], $this->keys($result)); $this->assertSame($twin->nextCursor, $result->nextCursor); $this->assertEquals($twin->_meta, $result->_meta); diff --git a/tests/Support/FakeCallSeam.php b/tests/Support/FakeCallSeam.php index 87797a6..c046f65 100644 --- a/tests/Support/FakeCallSeam.php +++ b/tests/Support/FakeCallSeam.php @@ -30,6 +30,18 @@ final class FakeCallSeam implements CallSeam /** @var list|\Throwable|null the library's first `tools/list` page, or what listing it throws */ public array|\Throwable|null $listing = null; + /** The first page's next cursor. */ + public ?string $nextCursor = null; + + /** + * @var array|\Throwable|null, ?string}> the later pages by the cursor that asks for them: + * tools (as `$listing`) and next cursor + */ + public array $pages = []; + + /** @var list the cursor of each listing, in order */ + public array $cursors = []; + public ?\Throwable $replaceFailure = null; /** @@ -118,14 +130,16 @@ public function answer(\stdClass $tree): \stdClass } #[\Override] - public function listOriginal(): ?array + public function listOriginal(?string $cursor): ?array { ++$this->listCalls; - if ($this->listing instanceof \Throwable) { - throw $this->listing; + $this->cursors[] = $cursor; + [$tools, $next] = $cursor === null ? [$this->listing, $this->nextCursor] : ($this->pages[$cursor] ?? [null, null]); + if ($tools instanceof \Throwable) { + throw $tools; } - return $this->listing; + return $tools === null ? null : [$tools, $next]; } #[\Override] diff --git a/tests/Support/LaravelMcp/LaravelConformanceHarness.php b/tests/Support/LaravelMcp/LaravelConformanceHarness.php index babeeb3..c7e0e09 100644 --- a/tests/Support/LaravelMcp/LaravelConformanceHarness.php +++ b/tests/Support/LaravelMcp/LaravelConformanceHarness.php @@ -57,6 +57,14 @@ public static function fullPage(): self return new self($fixture, new LaravelStdioHarness(FullPageServer::class, $fixture->tracker())); } + /** `PagedOwnServer`: a full page, then `own` alone on page 2. */ + public static function paged(): self + { + $fixture = new LaravelTrackingFixture(); + + return new self($fixture, new LaravelStdioHarness(PagedOwnServer::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 diff --git a/tests/Support/LaravelMcp/PagedOwnServer.php b/tests/Support/LaravelMcp/PagedOwnServer.php new file mode 100644 index 0000000..0599a34 --- /dev/null +++ b/tests/Support/LaravelMcp/PagedOwnServer.php @@ -0,0 +1,29 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 14)), OwnTool::class]; + } +} diff --git a/tests/Support/McpSdk/McpSdkConformanceHarness.php b/tests/Support/McpSdk/McpSdkConformanceHarness.php index 8b43d98..a99891e 100644 --- a/tests/Support/McpSdk/McpSdkConformanceHarness.php +++ b/tests/Support/McpSdk/McpSdkConformanceHarness.php @@ -70,6 +70,22 @@ public static function fullPage(): self return $harness; } + /** `fullPage()`'s 15 tools, then `own` alone on page 2. */ + public static function paged(): 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)); + } + $tools['own'] = Tools::own($builder); + $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. diff --git a/tests/Support/Symfony/FullPage/SecondPageOwnTool.php b/tests/Support/Symfony/FullPage/SecondPageOwnTool.php new file mode 100644 index 0000000..7d7a225 --- /dev/null +++ b/tests/Support/Symfony/FullPage/SecondPageOwnTool.php @@ -0,0 +1,24 @@ + $session_id]); + + return 'ok'; + } +} diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index 7e2be6c..698d82e 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -57,6 +57,12 @@ final class TestKernel extends Kernel */ public const array FULL_PAGE = ['servers' => ['conformance' => ['registry' => [Tools\EchoTool::class, FullPage\FullPageTools::class], 'pagination_limit' => 15] + self::CONFORMANCE]]; + /** + * Two pages of 15: `FULL_PAGE`'s tools, then `own` (`FullPage\SecondPageOwnTool`) alone on page 2. The kernel needs + * both tool classes as services, in this order. + */ + public const array PAGED = ['servers' => ['conformance' => ['registry' => [Tools\EchoTool::class, FullPage\FullPageTools::class, FullPage\SecondPageOwnTool::class], 'pagination_limit' => 15] + self::CONFORMANCE]]; + /** * @param string $directory the directory of the cache and log directories * @param array $agentcat the agentcat configuration diff --git a/tests/Unit/Internal/Engine/CallInterceptorTest.php b/tests/Unit/Internal/Engine/CallInterceptorTest.php index 1364d11..aa2bb64 100644 --- a/tests/Unit/Internal/Engine/CallInterceptorTest.php +++ b/tests/Unit/Internal/Engine/CallInterceptorTest.php @@ -34,6 +34,8 @@ final class CallInterceptorTest extends TestCase { private const string ECHO_TOOL = '{"name":"echo","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["msg"]}}'; + private const string OWN_TOOL = '{"name":"own","inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"]}}'; + private const string STRUCTURED_TOOL = '{"name":"structured","inputSchema":{"type":"object","properties":{}},"outputSchema":{"type":"object","properties":{"echoed":{"type":"string"}}}}'; private const string STRUCTURED_RESULT = '{"content":[{"type":"text","text":"x"}],"structuredContent":{"echoed":"x"}}'; @@ -387,6 +389,24 @@ public function testTheRegistriesAreRebuiltOnceOnDemand(): void $this->assertSame([['stripped', ['session_id', 'context']], ['replace'], ['stripped', ['session_id', 'context']], ['replace']], $seam->calls); } + /** D66 (B10 fixed): the rebuild reads page 2 for a tool that page 1 does not name; before, it read page 1 only. */ + #[Group('ac-02-19')] + public function testAToolOnALaterPageIsStrippedExactly(): void + { + $seam = $this->seam('own', ['session_id' => 'theirs', 'agent_id' => 'a1']); + $seam->listing = [$this->tree(self::ECHO_TOOL)]; + $seam->nextCursor = 'p2'; + $seam->pages = ['p2' => [[$this->tree(self::OWN_TOOL)], null]]; + + $this->interceptor->intercept($this->server(), $seam); + + $this->assertSame([null, 'p2'], $seam->cursors); + $this->assertSame(['stripped', ['context']], $seam->calls[0] ?? null); // the heuristic: session_id, agent_id, context + $event = $this->onlyEvent(); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('', $event->sessionId); + } + #[Group('ac-02-21')] public function testAFailedRebuildFallsBackToTheHeuristicStrip(): void { diff --git a/tests/Unit/Internal/Engine/ToolListInjectorTest.php b/tests/Unit/Internal/Engine/ToolListInjectorTest.php index 1234bcc..1ad8573 100644 --- a/tests/Unit/Internal/Engine/ToolListInjectorTest.php +++ b/tests/Unit/Internal/Engine/ToolListInjectorTest.php @@ -338,6 +338,25 @@ public function testCustomerGetMoreToolsIsKeptAndShadowWarnedOncePerState(): voi $this->assertSame(2, $this->logCount(self::SHADOW_WARN)); } + /** D66 (B10 fixed): TypeScript appends get_more_tools to every page; only the page without a next cursor gets it. */ + #[Group('ac-02-8')] + #[Group('ac-04-24')] + public function testGetMoreToolsIsAppendedOnlyToTheLastPage(): void + { + $state = new InjectionState(); + $customer = $this->decode('{"name":"get_more_tools","inputSchema":{"type":"object","properties":{}}}'); + + $first = ToolListInjector::inject([$this->decode('{"name":"a"}')], new InjectionConfig(), $state, lastPage: false); + $last = ToolListInjector::inject([$this->decode('{"name":"b"}')], new InjectionConfig(), $state, lastPage: true); + $shadowing = ToolListInjector::inject([$customer], new InjectionConfig(), $state, lastPage: false); + + $this->assertSame(['a'], $this->names($first)); + $this->assertSame(['a' => ['session_id', 'context']], $first->registry); + $this->assertSame(['b', 'get_more_tools'], $this->names($last)); + $this->assertSame(['get_more_tools'], $this->names($shadowing)); // listed once, with no second copy + $this->assertSame([self::SHADOW_WARN], $this->logger->messages()); + } + #[Group('ac-04-48')] public function testContextPassSkipsCustomerGetMoreToolsEvenWithReportMissingOff(): void { @@ -877,6 +896,12 @@ private function inject(array $tools, InjectionConfig $config = new InjectionCon return ToolListInjector::inject($tools, $config, $state); } + /** @return list the names of the listed tools */ + private function names(InjectedList $result): array + { + return array_map(static fn(mixed $tool): mixed => $tool instanceof \stdClass ? ($tool->name ?? null) : null, $result->tools); + } + private function logCount(string $message): int { return count(array_filter($this->logger->messages(), static fn(string $logged): bool => $logged === $message)); diff --git a/tests/Unit/Internal/Engine/ToolListingTest.php b/tests/Unit/Internal/Engine/ToolListingTest.php index 1478df4..58d9c07 100644 --- a/tests/Unit/Internal/Engine/ToolListingTest.php +++ b/tests/Unit/Internal/Engine/ToolListingTest.php @@ -5,15 +5,22 @@ namespace AgentCat\Tests\Unit\Internal\Engine; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\ToolListing; use AgentCat\Internal\Engine\ToolListInjector; use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Handles\ArgumentStripper; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Tools\GetMoreTools; use AgentCat\Options; +use AgentCat\Tests\Support\CallContexts; +use AgentCat\Tests\Support\FakeCallSeam; +use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -21,8 +28,18 @@ final class ToolListingTest extends TestCase { private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + private const string STOPPED = 'Warning: registry rebuild-on-demand stopped after page '; + private const string ECHO_TOOL = '{"name":"echo","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["msg"]}}'; + private const string OWN_TOOL = '{"name":"own","inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"]}}'; + + /** The heuristic strip for a tool no listing has named. */ + private const array HEURISTIC = ['session_id', 'agent_id', 'context']; + + /** What a tool without an own parameter loses: AgentCat's two injected parameters (agent tracking is off). */ + private const array INJECTED = ['session_id', 'context']; + private MemoryLogger $logger; #[\Override] @@ -38,9 +55,9 @@ protected function tearDown(): void Log::reset(); } - private function server(): TrackedServer + private function server(Options $options = new Options()): TrackedServer { - return new TrackedServer('proj', new Options(), static fn(): array => [null, null], new Publisher(new EventQueue(transport: new RecordingTransport()))); + return new TrackedServer('proj', $options, static fn(): array => [null, null], new Publisher(new EventQueue(transport: new RecordingTransport()))); } private function tool(string $json): \stdClass @@ -68,13 +85,83 @@ private function names(?array $tools): array }, $tools); } + /** + * A library listing of these pages by the cursor that asks for them ('' for the first): tools and next cursor, null + * for an answer without a tool list, or what listing throws. Records each cursor asked for in `$asked`. + * + * @param array, ?string}|\Throwable|null> $pages + * @param \ArrayObject $asked + * @return \Closure(?string): (array{list, ?string}|null) + */ + private function pages(array $pages, \ArrayObject $asked = new \ArrayObject()): \Closure + { + return static function (?string $cursor) use ($pages, $asked): ?array { + $asked->append($cursor); + $page = $pages[$cursor ?? ''] ?? null; + if ($page instanceof \Throwable) { + throw $page; + } + + return $page; + }; + } + + /** + * An endless listing: page n holds tool `t` and the cursor of page n+1. Records each cursor asked for. + * + * @param \ArrayObject $asked + * @return \Closure(?string): array{list, string} + */ + private function endless(\ArrayObject $asked): \Closure + { + return static function (?string $cursor) use ($asked): array { + $asked->append($cursor); + $n = (int) ($cursor ?? '0'); + + return [[(object) ['name' => 't' . $n]], (string) ($n + 1)]; + }; + } + + /** + * laravel/mcp's pages of 15 for 16 tools: `t01` to `t15` on page 1, then `own` (its own `session_id`) on page 2. + * + * @return array, ?string}> + */ + private function sixteenTools(): array + { + return [ + '' => [array_map(fn(int $i): \stdClass => $this->tool(sprintf('{"name":"t%02d"}', $i)), range(1, 15)), 'p2'], + 'p2' => [[$this->tool(self::OWN_TOOL)], null], + ]; + } + + /** @param array $arguments */ + private function seam(string $name, array $arguments = []): FakeCallSeam + { + return new FakeCallSeam($name, CallContexts::arguments($arguments), CallContexts::sample($name, $arguments)); + } + + private function intercept(TrackedServer $server, FakeCallSeam $seam): \stdClass + { + return (new CallInterceptor(new Publisher(new EventQueue(transport: new RecordingTransport()))))->intercept($server, $seam); + } + + /** @return list */ + private function registered(TrackedServer $server): array + { + $registry = $server->registry; + $this->assertNotNull($registry); + + return array_keys($registry); + } + #[Group('ac-04-28')] #[Group('ac-02-11')] public function testEmptyListingIsServedAsIs(): void { $server = $this->server(); - $this->assertNull(ToolListing::apply($server, [])); + $this->assertNull(ToolListing::apply($server, [], lastPage: true)); $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages()); $this->assertNull($server->registry); } @@ -85,7 +172,7 @@ public function testAListingWithoutAToolListIsServedAsIs(): void { $server = $this->server(); - $this->assertNull(ToolListing::apply($server, null)); + $this->assertNull(ToolListing::apply($server, null, lastPage: true)); $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages()); $this->assertNull($server->registry); } @@ -94,30 +181,30 @@ public function testListingAddsGetMoreToolsAndRecordsRegistry(): void { $server = $this->server(); - $out = ToolListing::apply($server, [$this->tool(self::ECHO_TOOL)]); + $out = ToolListing::apply($server, [$this->tool(self::ECHO_TOOL)], lastPage: true); $this->assertSame(['echo', 'get_more_tools'], $this->names($out)); $this->assertSame(['echo' => ['session_id', 'context'], 'get_more_tools' => ['session_id']], $server->registry); } - public function testEachPageIsInjectedAndTheRegistryHoldsTheLastPage(): void + /** D66 (B10 fixed): TypeScript appends get_more_tools to every page and keeps only the last page's registries. */ + #[Group('ac-02-8')] + public function testOnlyTheLastPageGetsGetMoreToolsAndThePagesMerge(): void { $server = $this->server(); - $page1 = ToolListing::apply($server, [$this->tool('{"name":"a"}')]); - $page2 = ToolListing::apply($server, [$this->tool('{"name":"b"}')]); + $page1 = ToolListing::apply($server, [$this->tool('{"name":"a"}')], lastPage: false); + $page2 = ToolListing::apply($server, [$this->tool('{"name":"b"}')], lastPage: true); - $this->assertSame(['a', 'get_more_tools'], $this->names($page1)); + $this->assertSame(['a'], $this->names($page1)); $this->assertSame(['b', 'get_more_tools'], $this->names($page2)); - $registry = $server->registry; - $this->assertNotNull($registry); - $this->assertSame(['b', 'get_more_tools'], array_keys($registry)); + $this->assertSame(['a', 'b', 'get_more_tools'], $this->registered($server)); } public function testDegradedInjectionKeepsThePreviousRegistries(): void { $server = $this->server(); - ToolListing::apply($server, [$this->tool(self::ECHO_TOOL)]); + ToolListing::apply($server, [$this->tool(self::ECHO_TOOL)], lastPage: true); $before = $server->registry; $hostile = new class extends \stdClass { public function __isset(string $name): bool @@ -131,7 +218,7 @@ public function __get(string $name): mixed } }; - $this->assertNull(ToolListing::apply($server, [$hostile])); + $this->assertNull(ToolListing::apply($server, [$hostile], lastPage: true)); $this->assertSame( ['Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - boom'], $this->logger->messages(), @@ -140,19 +227,16 @@ public function __get(string $name): mixed $this->assertNotNull($before); } - public function testEnsureRegistriesSkipsTheListingWhenRegistriesExist(): void + /** D66: a registered tool needs no rebuild; before, any registry skipped it. */ + public function testEnsureRegistriesSkipsTheListingWhenTheToolIsRegistered(): void { $server = $this->server(); - ToolListing::apply($server, [$this->tool('{"name":"a"}')]); - $called = false; + ToolListing::apply($server, [$this->tool('{"name":"a"}')], lastPage: true); + $asked = new \ArrayObject(); - ToolListing::ensureRegistries($server, static function () use (&$called): array { - $called = true; + ToolListing::ensureRegistries($server, 'a', $this->pages(['' => [[], null]], $asked)); - return []; - }); - - $this->assertFalse($called); + $this->assertSame([], $asked->getArrayCopy()); } #[Group('ac-02-20')] @@ -160,7 +244,7 @@ public function testEnsureRegistriesRebuildsOnDemand(): void { $server = $this->server(); - ToolListing::ensureRegistries($server, fn(): array => [$this->tool(self::ECHO_TOOL)]); + ToolListing::ensureRegistries($server, 'echo', fn(?string $cursor): array => [[$this->tool(self::ECHO_TOOL)], null]); $this->assertSame(['echo' => ['session_id', 'context'], 'get_more_tools' => ['session_id']], $server->registry); $this->assertSame([self::REBUILT], $this->logger->messages()); @@ -170,7 +254,7 @@ public function testEnsureRegistriesRebuildsFromAnEmptyListing(): void { $server = $this->server(); - ToolListing::ensureRegistries($server, static fn(): array => []); + ToolListing::ensureRegistries($server, 'echo', static fn(?string $cursor): array => [[], null]); $this->assertSame(['get_more_tools' => ['session_id']], $server->registry); $this->assertSame([self::REBUILT], $this->logger->messages()); @@ -180,7 +264,7 @@ public function testEnsureRegistriesIgnoresAMissingListing(): void { $server = $this->server(); - ToolListing::ensureRegistries($server, static fn(): ?array => null); + ToolListing::ensureRegistries($server, 'echo', static fn(?string $cursor): ?array => null); $this->assertNull($server->registry); $this->assertSame([], $this->logger->messages()); @@ -201,7 +285,7 @@ public function __get(string $name): mixed } }; - ToolListing::ensureRegistries($server, static fn(): array => [$hostile]); + ToolListing::ensureRegistries($server, 'echo', static fn(?string $cursor): array => [[$hostile], null]); $this->assertNull($server->registry); $this->assertSame( @@ -215,14 +299,14 @@ public function testEnsureRegistriesLogsAFailureAndRetriesNextTime(): void { $server = $this->server(); $calls = 0; - $list = static function () use (&$calls): array { + $list = static function (?string $cursor) use (&$calls): array { ++$calls; throw new \RuntimeException('list down'); }; - ToolListing::ensureRegistries($server, $list); - ToolListing::ensureRegistries($server, $list); + ToolListing::ensureRegistries($server, 'echo', $list); + ToolListing::ensureRegistries($server, 'echo', $list); $this->assertSame(2, $calls); $this->assertNull($server->registry); @@ -231,4 +315,232 @@ public function testEnsureRegistriesLogsAFailureAndRetriesNextTime(): void $this->logger->messages(), ); } + + #[Group('ac-02-20')] + public function testTheRebuildReadsEveryPage(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, 'b', $this->pages(['' => [[$this->tool('{"name":"a"}')], 'p2'], 'p2' => [[$this->tool('{"name":"b"}')], null]], $asked)); + + $this->assertSame([null, 'p2'], $asked->getArrayCopy()); + $this->assertSame(['a' => self::INJECTED, 'b' => self::INJECTED, 'get_more_tools' => ['session_id']], $server->registry); + $this->assertTrue($server->everyPageRead); + $this->assertSame([self::REBUILT], $this->logger->messages()); + } + + #[Group('ac-02-20')] + public function testACursorAlreadyFollowedEndsTheRebuild(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, 'b', $this->pages(['' => [[$this->tool('{"name":"a"}')], 'c'], 'c' => [[$this->tool('{"name":"b"}')], 'c']], $asked)); + + $this->assertSame([null, 'c'], $asked->getArrayCopy()); + $this->assertSame(['a', 'b', 'get_more_tools'], $this->registered($server)); + $this->assertTrue($server->everyPageRead); + $this->assertSame([self::REBUILT], $this->logger->messages()); + } + + /** @return iterable */ + public static function unreadablePages(): iterable + { + yield 'it throws' => [new \RuntimeException('page down'), 'RuntimeException: page down']; + yield 'no tool list' => [null, 'page 2 has no tool list']; + } + + #[DataProvider('unreadablePages')] + #[Group('ac-02-21')] + public function testAPageThatCannotBeReadKeepsTheEarlierPagesAndIsTriedAgain(?\Throwable $page, string $reason): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + $list = $this->pages(['' => [[$this->tool('{"name":"a"}')], 'p2'], 'p2' => $page], $asked); + + ToolListing::ensureRegistries($server, 'b', $list); + + $this->assertSame(['a' => self::INJECTED], $server->registry); // no get_more_tools entry: the end was not read + $this->assertFalse($server->everyPageRead); + $this->assertSame([self::STOPPED . '1 - ' . $reason], $this->logger->messages()); + + ToolListing::ensureRegistries($server, 'a', $list); + $this->assertCount(2, $asked); + + ToolListing::ensureRegistries($server, 'b', $list); + $this->assertCount(4, $asked); + $this->assertSame(array_fill(0, 2, self::STOPPED . '1 - ' . $reason), $this->logger->messages()); + } + + #[Group('ac-02-22')] + public function testTheRebuildStopsAtThePageLimit(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, 'nope', $this->endless($asked)); + + $this->assertCount(ToolListing::REBUILD_MAX_PAGES, $asked); + $this->assertSame(array_map(static fn(int $n): string => 't' . $n, range(0, 99)), $this->registered($server)); + $this->assertSame([self::STOPPED . '100 - the limit is 100 pages'], $this->logger->messages()); + $this->assertFalse($server->everyPageRead); + } + + #[Group('ac-02-22')] + public function testTheRebuildStopsWhenItsBudgetIsUsedUp(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + $ticks = 0; + $clock = static function () use (&$ticks): float { + return 2.5 * $ticks++; // 0.0 at the start, 2.5 after page 1, exactly the budget after page 2 + }; + + ToolListing::ensureRegistries($server, 'nope', $this->endless($asked), $clock); + + $this->assertCount(2, $asked); + $this->assertSame(['t0', 't1'], $this->registered($server)); // no get_more_tools entry: the end was not read + $this->assertSame([self::STOPPED . '2 - the budget of 5 s is used up'], $this->logger->messages()); + $this->assertFalse($server->everyPageRead); + } + + /** D66: a client that listed page 1 only, then calls: only a tool that no listing named rebuilds, and only once. */ + #[Group('ac-02-20')] + public function testOnlyAToolNoListingNamedRebuilds(): void + { + $server = $this->server(); + ToolListing::apply($server, [$this->tool('{"name":"a"}')], lastPage: false); + $asked = new \ArrayObject(); + $list = $this->pages(['' => [[$this->tool('{"name":"a"}')], 'p2'], 'p2' => [[$this->tool('{"name":"b"}')], null]], $asked); + + ToolListing::ensureRegistries($server, 'a', $list); + $this->assertSame([], $asked->getArrayCopy()); + + ToolListing::ensureRegistries($server, 'b', $list); + $this->assertSame([null, 'p2'], $asked->getArrayCopy()); + $this->assertSame(['a', 'b', 'get_more_tools'], $this->registered($server)); + + ToolListing::ensureRegistries($server, 'nope', $list); + $this->assertSame([null, 'p2'], $asked->getArrayCopy(), 'every page was read'); + } + + /** @return iterable */ + public static function cursors(): iterable + { + yield 'null' => [null, null]; + yield 'empty' => ['', null]; + yield 'the integer 0' => [0, null]; + yield 'a list' => [['c'], null]; + yield 'the string 0' => ['0', '0']; + yield 'opaque' => ['c', 'c']; + } + + #[DataProvider('cursors')] + #[Group('ac-02-8')] + public function testTheNextCursorIsANonEmptyString(mixed $cursor, ?string $next): void + { + $this->assertSame($next, ToolListing::nextCursor($cursor)); + } + + /** Plan F spec conflict 3: the rebuild stops at the first page that names the called tool. */ + #[Group('ac-02-20')] + public function testARebuildForAPageOneToolReadsOnePage(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, 't01', $this->pages($this->sixteenTools(), $asked)); + + $this->assertSame([null], $asked->getArrayCopy()); + $this->assertSame(array_map(static fn(int $i): string => sprintf('t%02d', $i), range(1, 15)), $this->registered($server)); + $this->assertFalse($server->everyPageRead); + $this->assertSame([self::REBUILT], $this->logger->messages()); + } + + #[Group('ac-02-20')] + #[Group('ac-02-19')] + public function testARebuildForAPageTwoToolReadsTwoPages(): void + { + $server = $this->server(); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, 'own', $this->pages($this->sixteenTools(), $asked)); + + $this->assertSame([null, 'p2'], $asked->getArrayCopy()); + $this->assertTrue($server->injection->hasDeclaredSessionParam('own')); + $this->assertSame(['context'], ArgumentStripper::names('own', $server->registry)); // its session_id stays + $this->assertTrue($server->everyPageRead); + } + + /** + * Review Focus 3: a list handler that gives the same next cursor again. The call answers; the rebuild read two + * pages and logged one line, and only a tool that no page read named gets the heuristic strip. + */ + #[Group('ac-02-20')] + #[Group('ac-04-30')] + public function testARebuildStopsAtARepeatedCursor(): void + { + $server = $this->server(); + $unread = $this->seam('c'); + $unread->listing = [$this->tool('{"name":"a"}')]; + $unread->nextCursor = 'again'; + $unread->pages = ['again' => [[$this->tool('{"name":"b"}')], 'again']]; + $read = $this->seam('b'); + + $answer = $this->intercept($server, $unread); + $this->intercept($server, $read); + + $this->assertSame([null, 'again'], $unread->cursors); + $this->assertSame([self::REBUILT], $this->logger->messages()); + $this->assertSame('ok', JsonAssert::at($answer, 'content', 1, 'text')); + $this->assertSame(['stripped', self::HEURISTIC], $unread->calls[0] ?? null); + $this->assertSame(0, $read->listCalls); + $this->assertSame(['stripped', self::INJECTED], $read->calls[0] ?? null); + } + + /** + * Review Focus 3: a list handler that gives a new cursor every time. The call answers; the rebuild read 100 pages + * and logged one line, and only a tool that no page read named gets the heuristic strip. + */ + #[Group('ac-02-22')] + public function testARebuildStopsAfterOneHundredPages(): void + { + $server = $this->server(); + $unread = $this->seam('nope'); + $unread->listing = [$this->tool('{"name":"t0"}')]; + $unread->nextCursor = 'c1'; + foreach (range(1, 2 * ToolListing::REBUILD_MAX_PAGES) as $n) { + $unread->pages['c' . $n] = [[$this->tool(sprintf('{"name":"t%d"}', $n))], 'c' . ($n + 1)]; + } + $read = $this->seam('t99'); + + $answer = $this->intercept($server, $unread); + $this->intercept($server, $read); + + $this->assertSame(ToolListing::REBUILD_MAX_PAGES, $unread->listCalls); + $this->assertSame([self::STOPPED . '100 - the limit is 100 pages'], $this->logger->messages()); + $this->assertSame('ok', JsonAssert::at($answer, 'content', 1, 'text')); + $this->assertSame(['stripped', self::HEURISTIC], $unread->calls[0] ?? null); + $this->assertSame(0, $read->listCalls); + $this->assertSame(['stripped', self::INJECTED], $read->calls[0] ?? null); + } + + /** AgentCat answers get_more_tools itself, whatever page lists it; with report-missing off it is the customer's. */ + #[Group('ac-02-40')] + #[Group('ac-02-41')] + public function testGetMoreToolsNeedsNoRebuild(): void + { + $server = $this->server(); + $customer = $this->server(new Options(enableReportMissing: false)); + $asked = new \ArrayObject(); + $list = $this->pages(['' => [[$this->tool(self::ECHO_TOOL)], null]], $asked); + + ToolListing::ensureRegistries($server, GetMoreTools::NAME, $list); + $this->assertSame([], $asked->getArrayCopy()); + $this->assertNull($server->registry); + + ToolListing::ensureRegistries($customer, GetMoreTools::NAME, $list); + $this->assertSame([null], $asked->getArrayCopy()); + } } diff --git a/tests/Unit/Internal/Engine/TrackedServerTest.php b/tests/Unit/Internal/Engine/TrackedServerTest.php index c72f773..f6c8c79 100644 --- a/tests/Unit/Internal/Engine/TrackedServerTest.php +++ b/tests/Unit/Internal/Engine/TrackedServerTest.php @@ -47,16 +47,22 @@ public function testMirrorAllowedAndAdopt(): void $server = new TrackedServer('p', new Options(), static fn(): array => [null, null], $this->publisher()); $this->assertTrue($server->mirrorAllowed('a')); - $server->adopt(new InjectedList([], ['a' => []], ['b'])); + $server->adopt(new InjectedList([], ['a' => [], 'b' => []], ['b'])); $this->assertFalse($server->mirrorAllowed('a')); $this->assertTrue($server->mirrorAllowed('b')); - $this->assertSame(['a' => []], $server->registry); + $this->assertSame(['a' => [], 'b' => []], $server->registry); $this->assertSame(['b'], $server->outputRegistry); - $server->adopt(new InjectedList([], ['c' => ['x']], [])); - $this->assertSame(['c' => ['x']], $server->registry); - $this->assertSame([], $server->outputRegistry); + // D66 (B10 fixed): each page merges; TypeScript replaces both registries. A numeric name keeps its int key. + $server->adopt(new InjectedList([], ['c' => ['x'], '7' => ['context']], ['c'])); + $this->assertSame(['a' => [], 'b' => [], 'c' => ['x'], 7 => ['context']], $server->registry); + $this->assertSame(['b', 'c'], $server->outputRegistry); + + // A tool listed again replaces its entry, and leaves the output registry without an outputSchema. + $server->adopt(new InjectedList([], ['b' => ['session_id']], [])); + $this->assertSame(['a' => [], 'b' => ['session_id'], 'c' => ['x'], 7 => ['context']], $server->registry); + $this->assertSame(['c'], $server->outputRegistry); } public function testServerInfoIsReadOnEveryCall(): void diff --git a/tests/Unit/LaravelMcp/LaravelCallSeamTest.php b/tests/Unit/LaravelMcp/LaravelCallSeamTest.php index 03a5513..8c19601 100644 --- a/tests/Unit/LaravelMcp/LaravelCallSeamTest.php +++ b/tests/Unit/LaravelMcp/LaravelCallSeamTest.php @@ -139,20 +139,26 @@ public function testAnAnswerIsAResultForTheRequest(): void $this->assertSame([], $this->sent); } - public function testTheRebuildListsTheFirstPageWithTheListHandler(): void + /** D66 (B10 fixed): the rebuild lists the page at a cursor, and reads the page's next cursor. */ + public function testTheRebuildListsAPageAtACursorWithTheListHandler(): 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']]]); + return JsonRpcResponse::result($request->id, ['tools' => [['name' => 'echo']], 'nextCursor' => 'p2']); }); - $this->assertEquals([(object) ['name' => 'echo']], $seam->listOriginal()); - $this->assertCount(1, $listed); + $this->assertEquals([[(object) ['name' => 'echo']], 'p2'], $seam->listOriginal(null)); + $this->assertEquals([[(object) ['name' => 'echo']], 'p2'], $seam->listOriginal('p2')); + $this->assertCount(2, $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'); + $this->assertSame(['agentcat-rebuild', 'tools/list', ['cursor' => 'p2']], [$listed[1]->id, $listed[1]->method, $listed[1]->params]); + $last = $this->seam(['name' => 'echo'], list: static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::result($request->id, ['tools' => [], 'nextCursor' => ''])); + $this->assertSame([[], null], $last->listOriginal(null), 'an empty cursor ends the listing'); + $this->assertNull($this->seam(['name' => 'echo'])->listOriginal(null), 'no list handler'); + $this->assertNull($this->seam(['name' => 'echo'], list: static fn(JsonRpcRequest $request): array => [JsonRpcResponse::result($request->id, ['tools' => []])])->listOriginal(null), 'a streamed listing'); + $this->assertNull($this->seam(['name' => 'echo'], list: static fn(JsonRpcRequest $request): JsonRpcResponse => JsonRpcResponse::error($request->id, -32603, 'down'))->listOriginal(null), 'a JSON-RPC error'); } public function testAnOrdinaryResultDoesNotCompleteLater(): void From 9aacc269349a14288ce689f77066979d858ef2a3 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 11:38:48 +0000 Subject: [PATCH 03/68] fix: rebuild the registries for a get_more_tools call like for any other tool --- src/Internal/Engine/ToolListing.php | 17 ++++---- tests/Integration/LaravelMcp/CallToolTest.php | 42 +++++++++++++++++++ tests/Integration/McpSdk/CallToolTest.php | 32 ++++++++++++++ .../LaravelMcp/PagedGetMoreToolsServer.php | 29 +++++++++++++ .../LaravelMcp/Tools/CustomerGetMoreTools.php | 36 ++++++++++++++++ .../Internal/Engine/CallInterceptorTest.php | 25 +++++++++++ .../Unit/Internal/Engine/ToolListingTest.php | 34 +++++++++++---- 7 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 tests/Support/LaravelMcp/PagedGetMoreToolsServer.php create mode 100644 tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index ccedb76..e6215e4 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -6,7 +6,6 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\ErrorText; -use AgentCat\Internal\Tools\GetMoreTools; /** @internal */ final class ToolListing @@ -68,10 +67,12 @@ public static function nextCursor(mixed $cursor): ?string * TS ensureRegistries (D17: no cache), reading the pages from the first (D66, B10 fixed). It runs on a call whose * tool no listing of this server named, until one rebuild has read every page; under PHP-FPM that is every call, * as before. It stops at the first page that names the tool (Plan F spec conflict 3), so a tool of page 1 costs one - * page, as before; a tool that no page names reads every page. AgentCat answers get_more_tools itself, so a call - * for it needs no rebuild. A page that cannot be read ends the rebuild: the pages before it are kept, the other - * tools keep the heuristic strip, and the next call for one of them tries again. A cursor already followed ends - * the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next page. + * page, as before; a tool that no page names reads every page. A get_more_tools call rebuilds too: a customer's + * tool of that name is found on its page, so its own parameters stay its own; AgentCat's own is on no page, and the + * read to the last page gives it its exact entry. A page that cannot be read ends the rebuild: the pages before it + * are kept, the other tools keep the heuristic strip, and the next call for one of them tries again. A cursor + * already followed ends the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next + * page. * * @param \Closure(?string): (array{list, ?string}|null) $listPage the library's own page at a cursor (null * for the first): its tools and its next @@ -81,11 +82,7 @@ public static function nextCursor(mixed $cursor): ?string */ public static function ensureRegistries(TrackedServer $server, string $toolName, \Closure $listPage, ?\Closure $clock = null): void { - if ( - $server->everyPageRead - || ($server->options->enableReportMissing && $toolName === GetMoreTools::NAME) - || ($server->registry !== null && array_key_exists($toolName, $server->registry)) - ) { + if ($server->everyPageRead || ($server->registry !== null && array_key_exists($toolName, $server->registry))) { return; } try { diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 56cfa27..3ec3166 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -21,6 +21,7 @@ use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; use AgentCat\Tests\Support\LaravelMcp\LineTransport; use AgentCat\Tests\Support\LaravelMcp\ListingServer; +use AgentCat\Tests\Support\LaravelMcp\PagedGetMoreToolsServer; use AgentCat\Tests\Support\LaravelMcp\PagedOwnServer; use AgentCat\Tests\Support\LaravelMcp\RecordingCallServer; use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; @@ -49,6 +50,8 @@ final class CallToolTest extends LaravelTestCase private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; + private const string CANNED = '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.'; + #[Group('ac-02-16')] #[Group('ac-02-47')] public function testTheToolGetsItsOwnArgumentsAndTheEventRecordsTheCall(): void @@ -197,6 +200,45 @@ public function testASecondPageThatFailsLeavesTheCallServed(): void $this->assertSame(['Warning: registry rebuild-on-demand stopped after page 1 - RuntimeException: page down'], $fixture->lines()); } + /** + * Task 2 fix round 0: a get_more_tools call rebuilds like any other tool. A new server per request (PHP-FPM), no + * listing: the rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing + * shadows the customer's tool), and the `session_id` that tool declares stays the customer's: no session is read + * from it and no "unrecognized" block is added. + */ + #[Group('ac-02-19')] + #[Group('ac-02-14')] + public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedGetMoreToolsServer::class, $fixture->tracker()); + + $answer = $harness->callTool(GetMoreTools::NAME, ['session_id' => 'theirs', 'context' => 'need X']); + + $this->assertEquals([(object) ['type' => 'text', 'text' => self::CANNED]], JsonAssert::at($answer, 'result', 'content')); + $this->assertSame([], ToolCalls::of(GetMoreTools::NAME), 'AgentCat answers its calls'); + $event = $fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('need X', $event->userIntent); + $this->assertContains(self::REBUILT, $fixture->lines()); + } + + /** Task 2 fix round 0: with report-missing off, the customer's own get_more_tools on page 2 gets its `session_id`. */ + #[Group('ac-02-19')] + #[Group('ac-02-41')] + public function testWithReportMissingOffACustomerGetMoreToolsOnPageTwoGetsItsOwnSessionId(): void + { + $fixture = new LaravelTrackingFixture(); + $harness = new LaravelHarness(PagedGetMoreToolsServer::class, $fixture->tracker(new Options(enableReportMissing: false))); + + $answer = $harness->callTool(GetMoreTools::NAME, ['session_id' => 'theirs']); + + $this->assertSame('mine', JsonAssert::at($answer, 'result', 'content', 0, 'text')); + $this->assertSame([['session_id' => 'theirs']], ToolCalls::of(GetMoreTools::NAME)); + $this->assertSame('foreign', $fixture->event()->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + #[Group('ac-02-24')] #[Group('ac-05-E5')] public function testAThrownToolExceptionIsRecordedAsItself(): void diff --git a/tests/Integration/McpSdk/CallToolTest.php b/tests/Integration/McpSdk/CallToolTest.php index a3099b1..bb11780 100644 --- a/tests/Integration/McpSdk/CallToolTest.php +++ b/tests/Integration/McpSdk/CallToolTest.php @@ -17,6 +17,7 @@ use AgentCat\Tests\Support\McpSdk\HttpHarness; use AgentCat\Tests\Support\McpSdk\McpSdkVersion; use AgentCat\Tests\Support\McpSdk\NameService; +use AgentCat\Tests\Support\McpSdk\RecordingTool; use AgentCat\Tests\Support\McpSdk\Tools; use AgentCat\Tests\Support\McpSdk\TrackingFixture; use AgentCat\ToolCall; @@ -277,6 +278,37 @@ public function testANextCursorTheLibraryDoesNotSendEndsTheRebuild(): void $this->assertContains(self::REBUILT, $this->fixture->lines()); } + /** + * Task 2 fix round 0: a get_more_tools call rebuilds like any other tool. A server that never listed (PHP-FPM): the + * rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the + * customer's tool), and the `session_id` that tool declares stays the customer's: no session is read from it. + */ + #[DataProvider('eras')] + #[Group('ac-02-19')] + #[Group('ac-02-14')] + public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(bool $modern): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + $customer = new RecordingTool(); + $schema = ['type' => 'object', 'properties' => ['session_id' => ['type' => 'string']], 'required' => ['session_id']]; + $builder->add(new Tool(name: GetMoreTools::NAME, title: null, inputSchema: $schema, description: 'Mine', annotations: null), $customer); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $arguments = ['session_id' => 'theirs', 'context' => 'need X']; + + $answer = $modern ? $harness->callToolModern(GetMoreTools::NAME, $arguments) : $harness->callTool(GetMoreTools::NAME, $arguments); + + $this->assertSame([$this->cannedText()], $this->texts(HttpHarness::result($answer))); + $this->assertSame([], $customer->calls, 'AgentCat answers its calls'); + $event = $this->fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertContains(self::REBUILT, $this->fixture->lines()); + } + /** @return iterable}> */ public static function sessionlessArguments(): iterable { diff --git a/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php b/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php new file mode 100644 index 0000000..6cf09b7 --- /dev/null +++ b/tests/Support/LaravelMcp/PagedGetMoreToolsServer.php @@ -0,0 +1,29 @@ +tools = [EchoTool::class, ...array_map(static fn(int $i): Tool => new NamedTool(sprintf('t%02d', $i)), range(1, 14)), CustomerGetMoreTools::class]; + } +} diff --git a/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php b/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php new file mode 100644 index 0000000..8d298e3 --- /dev/null +++ b/tests/Support/LaravelMcp/Tools/CustomerGetMoreTools.php @@ -0,0 +1,36 @@ + */ + #[\Override] + public function schema(JsonSchema $schema): array + { + return ['session_id' => $schema->string()->required()]; + } + + public function handle(Request $request): Response + { + ToolCalls::record('get_more_tools', $request->all()); + + return Response::text('mine'); + } +} diff --git a/tests/Unit/Internal/Engine/CallInterceptorTest.php b/tests/Unit/Internal/Engine/CallInterceptorTest.php index aa2bb64..35c5831 100644 --- a/tests/Unit/Internal/Engine/CallInterceptorTest.php +++ b/tests/Unit/Internal/Engine/CallInterceptorTest.php @@ -13,6 +13,7 @@ use AgentCat\Internal\Engine\Observed; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Handles\ArgumentStripper; use AgentCat\Internal\Id\SessionId; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; @@ -601,6 +602,30 @@ public function testGetMoreToolsIsAnsweredWithoutTheCustomer(): void $this->assertContains('Missing tool reported (context length: 6)', $this->logger->messages()); } + /** + * A get_more_tools call rebuilds like any other tool (Task 2 fix round 0): AgentCat's own is on no page, so the + * read goes to the last page. Its entry then names the handles AgentCat injected, and they are AgentCat's. + */ + #[Group('ac-02-40')] + public function testAGetMoreToolsCallRebuildsAndItsHandlesAreAgentCats(): void + { + $server = $this->server(new Options(enableAgentTracking: true)); + $seam = $this->seam('get_more_tools', ['context' => 'need X', 'session_id' => $this->supplied(), 'agent_id' => 'a1']); + $seam->listing = [$this->tree(self::ECHO_TOOL)]; + $seam->nextCursor = 'p2'; + $seam->pages = ['p2' => [[$this->tree(self::STRUCTURED_TOOL)], null]]; + + $this->interceptor->intercept($server, $seam); + + $this->assertSame([null, 'p2'], $seam->cursors); + $this->assertSame(['session_id', 'agent_id'], ArgumentStripper::names('get_more_tools', $server->registry)); + $this->assertSame([['answer']], $seam->calls); // AgentCat answers; a supplied session gets no block + $event = $this->onlyEvent(); + $this->assertSame($this->supplied(), $event->sessionId); + $this->assertSame('supplied', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertSame('a1', $event->tags[Constants::TAG_AGENT_ID] ?? null); + } + #[Group('ac-02-41')] public function testWithReportMissingOffGetMoreToolsReachesTheLibrary(): void { diff --git a/tests/Unit/Internal/Engine/ToolListingTest.php b/tests/Unit/Internal/Engine/ToolListingTest.php index 58d9c07..fae811b 100644 --- a/tests/Unit/Internal/Engine/ToolListingTest.php +++ b/tests/Unit/Internal/Engine/ToolListingTest.php @@ -526,21 +526,37 @@ public function testARebuildStopsAfterOneHundredPages(): void $this->assertSame(['stripped', self::INJECTED], $read->calls[0] ?? null); } - /** AgentCat answers get_more_tools itself, whatever page lists it; with report-missing off it is the customer's. */ + /** + * A get_more_tools call rebuilds like any other tool (coordinator ruling, Task 2 fix round 0). AgentCat's own is on + * no page, so the read goes to the last page, which appends it: its entry holds the parameters AgentCat injected. + */ #[Group('ac-02-40')] - #[Group('ac-02-41')] - public function testGetMoreToolsNeedsNoRebuild(): void + public function testAGetMoreToolsCallReadsToTheLastPageForAgentCatsOwn(): void + { + $server = $this->server(new Options(enableAgentTracking: true)); + $asked = new \ArrayObject(); + + ToolListing::ensureRegistries($server, GetMoreTools::NAME, $this->pages(['' => [[$this->tool(self::ECHO_TOOL)], 'p2'], 'p2' => [[$this->tool('{"name":"b"}')], null]], $asked)); + + $this->assertSame([null, 'p2'], $asked->getArrayCopy()); + $this->assertSame(['session_id', 'agent_id'], ArgumentStripper::names(GetMoreTools::NAME, $server->registry)); + $this->assertFalse($server->injection->hasDeclaredSessionParam(GetMoreTools::NAME)); + $this->assertTrue($server->everyPageRead); + } + + /** A customer's get_more_tools is found on its page, like any tool: its own `session_id` stays its own. */ + #[Group('ac-02-19')] + #[Group('ac-04-24')] + public function testACustomerGetMoreToolsIsFoundOnItsPage(): void { $server = $this->server(); - $customer = $this->server(new Options(enableReportMissing: false)); $asked = new \ArrayObject(); - $list = $this->pages(['' => [[$this->tool(self::ECHO_TOOL)], null]], $asked); + $customer = $this->tool('{"name":"get_more_tools","inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"]}}'); - ToolListing::ensureRegistries($server, GetMoreTools::NAME, $list); - $this->assertSame([], $asked->getArrayCopy()); - $this->assertNull($server->registry); + ToolListing::ensureRegistries($server, GetMoreTools::NAME, $this->pages(['' => [[$customer], 'p2'], 'p2' => [[$this->tool('{"name":"b"}')], null]], $asked)); - ToolListing::ensureRegistries($customer, GetMoreTools::NAME, $list); $this->assertSame([null], $asked->getArrayCopy()); + $this->assertTrue($server->injection->hasDeclaredSessionParam(GetMoreTools::NAME)); + $this->assertSame([], ArgumentStripper::names(GetMoreTools::NAME, $server->registry)); } } From b200372db1e5e71f5fbeecd936332a773e2b1d87 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 11:51:19 +0000 Subject: [PATCH 04/68] fix: keep one output-registry entry for a nameless tool listed again --- src/Internal/Engine/ToolListing.php | 13 ++++++------- src/Internal/Engine/TrackedServer.php | 8 +++++--- tests/Unit/Internal/Engine/ToolListingTest.php | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index e6215e4..7d875c1 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -66,13 +66,12 @@ public static function nextCursor(mixed $cursor): ?string /** * TS ensureRegistries (D17: no cache), reading the pages from the first (D66, B10 fixed). It runs on a call whose * tool no listing of this server named, until one rebuild has read every page; under PHP-FPM that is every call, - * as before. It stops at the first page that names the tool (Plan F spec conflict 3), so a tool of page 1 costs one - * page, as before; a tool that no page names reads every page. A get_more_tools call rebuilds too: a customer's - * tool of that name is found on its page, so its own parameters stay its own; AgentCat's own is on no page, and the - * read to the last page gives it its exact entry. A page that cannot be read ends the rebuild: the pages before it - * are kept, the other tools keep the heuristic strip, and the next call for one of them tries again. A cursor - * already followed ends the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next - * page. + * as before. It stops at the first page that names the tool (D66), so a tool of page 1 costs one page, as before; + * a tool that no page names reads every page. A get_more_tools call rebuilds too: a customer's tool of that name is + * found on its page, so its own parameters stay its own; AgentCat's own is on no page, and the read to the last + * page gives it its exact entry. A page that cannot be read ends the rebuild: the pages before it are kept, the + * other tools keep the heuristic strip, and the next call for one of them tries again. A cursor already followed + * ends the listing. Synchronous PHP cannot stop a page that hangs: the budget only stops the next page. * * @param \Closure(?string): (array{list, ?string}|null) $listPage the library's own page at a cursor (null * for the first): its tools and its next diff --git a/src/Internal/Engine/TrackedServer.php b/src/Internal/Engine/TrackedServer.php index 4ad5d0b..0d9befd 100644 --- a/src/Internal/Engine/TrackedServer.php +++ b/src/Internal/Engine/TrackedServer.php @@ -75,13 +75,15 @@ public function mirrorAllowed(string $toolName): bool /** * Merges one page's registries into the server's (D66, B10 fixed; TypeScript replaces them): the page's entries * replace those of the same tools, and the output registry keeps the tools of the other pages. `array_replace()` - * keeps the int keys that PHP makes of numeric-string names, and `array_diff()` compares them as strings. Every - * tool of a page's output registry has an entry in its registry, so the merged output registry has no duplicate. + * keeps the int keys that PHP makes of numeric-string names, and `array_diff()` compares them as strings. A tool + * without a name is in the output registry as `unknown` and can have no registry entry, so `array_unique()` keeps + * one copy of it when the page is listed again. */ public function adopt(InjectedList $list): void { $this->registry = array_replace($this->registry ?? [], $list->registry); - $this->outputRegistry = [...array_diff($this->outputRegistry ?? [], array_keys($list->registry)), ...$list->outputRegistry]; + $kept = array_diff($this->outputRegistry ?? [], array_keys($list->registry)); + $this->outputRegistry = [...array_unique([...$kept, ...$list->outputRegistry])]; } /** @return array{0: ?string, 1: ?string} */ diff --git a/tests/Unit/Internal/Engine/ToolListingTest.php b/tests/Unit/Internal/Engine/ToolListingTest.php index fae811b..ac1180f 100644 --- a/tests/Unit/Internal/Engine/ToolListingTest.php +++ b/tests/Unit/Internal/Engine/ToolListingTest.php @@ -227,6 +227,24 @@ public function __get(string $name): mixed $this->assertNotNull($before); } + /** + * D66: the pages merge into the output registry. A tool without a name is recorded there as `unknown`, but with + * its own `session_id` and context injection off it has no registry entry, so a merge without de-duplication + * would add one more copy at each listing of a long-running process. + */ + public function testListingANamelessToolAgainKeepsOneOutputRegistryEntry(): void + { + $server = $this->server(new Options(enableToolCallContext: false)); + $nameless = '{"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}}},"outputSchema":{"type":"object","properties":{}}}'; + + for ($listing = 1; $listing <= 5; ++$listing) { + ToolListing::apply($server, [$this->tool($nameless), $this->tool('{"name":"a"}')], lastPage: true); + } + + $this->assertSame(['unknown'], $server->outputRegistry); + $this->assertSame(['a', 'get_more_tools'], $this->registered($server)); + } + /** D66: a registered tool needs no rebuild; before, any registry skipped it. */ public function testEnsureRegistriesSkipsTheListingWhenTheToolIsRegistered(): void { From 6c3910867269201ac321e2472a25e3298f9d4c14 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 11:56:58 +0000 Subject: [PATCH 05/68] test: assert the exact rebuild lines and cover the page-2 mirror and a page listed again --- tests/Integration/LaravelMcp/CallToolTest.php | 23 +++--- tests/Integration/McpSdk/CallToolTest.php | 79 ++++++++++++++++--- tests/Support/ListingLines.php | 32 ++++++++ .../McpSdk/McpSdkConformanceHarness.php | 41 +++++----- .../Internal/Engine/CallInterceptorTest.php | 6 +- .../Unit/Internal/Engine/ToolListingTest.php | 8 +- 6 files changed, 139 insertions(+), 50 deletions(-) create mode 100644 tests/Support/ListingLines.php diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 3ec3166..1f4b10b 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -29,6 +29,7 @@ use AgentCat\Tests\Support\LaravelMcp\StreamingServer; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\ToolCalls; use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; @@ -153,7 +154,7 @@ public function testAToolOnTheSecondPageKeepsItsOwnSessionId(\Closure $call): vo $this->assertSame('', $event->sessionId); $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); $this->assertSame('why', $event->userIntent); - $this->assertContains(self::REBUILT, $fixture->lines()); + $this->assertSame([ListingLines::ownSessionId('own'), self::REBUILT], $fixture->lines()); } /** D66 (B10 fixed): on a long-running server each listed page merges into the registries, so no call rebuilds. */ @@ -174,13 +175,13 @@ public function testListedPagesMergeOnALongRunningServer(): void $this->assertSame(['own', GetMoreTools::NAME], $this->names($second)); $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], ToolCalls::of('echo'), 'agent tracking is off: agent_id is not ours'); $this->assertSame([['session_id' => 'theirs']], ToolCalls::of('own')); - $this->assertNotContains(self::REBUILT, $fixture->lines(), 'both tools were listed'); + $this->assertSame([ListingLines::ownSessionId('own')], $fixture->lines(), 'both tools were listed: no rebuild'); } /** - * D66 (B10 fixed), with the rebuild's early stop (Plan F spec conflict 3): no page that the rebuild could read names - * `echo`, and page 2 fails. Page 1 is kept, one line says where the rebuild stopped, and the call is served with the - * heuristic strip. + * D66 (B10 fixed): the rebuild stops at the first page that names the tool, or at a page it cannot read. No page + * that the rebuild could read names `echo`, and page 2 fails. Page 1 is kept, one line says where the rebuild + * stopped, and the call is served with the heuristic strip. */ #[Group('ac-02-21')] public function testASecondPageThatFailsLeavesTheCallServed(): void @@ -201,10 +202,10 @@ public function testASecondPageThatFailsLeavesTheCallServed(): void } /** - * Task 2 fix round 0: a get_more_tools call rebuilds like any other tool. A new server per request (PHP-FPM), no - * listing: the rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing - * shadows the customer's tool), and the `session_id` that tool declares stays the customer's: no session is read - * from it and no "unrecognized" block is added. + * D66: a get_more_tools call rebuilds like any other tool. A new server per request (PHP-FPM), no listing: the + * rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the + * customer's tool), and the `session_id` that tool declares stays the customer's: no session is read from it and + * no "unrecognized" block is added. */ #[Group('ac-02-19')] #[Group('ac-02-14')] @@ -221,10 +222,10 @@ public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(): void $this->assertSame('', $event->sessionId); $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); $this->assertSame('need X', $event->userIntent); - $this->assertContains(self::REBUILT, $fixture->lines()); + $this->assertSame(ListingLines::shadowedCall(6), $fixture->lines()); } - /** Task 2 fix round 0: with report-missing off, the customer's own get_more_tools on page 2 gets its `session_id`. */ + /** D66: with report-missing off, the customer's own get_more_tools on page 2 is found and gets its `session_id`. */ #[Group('ac-02-19')] #[Group('ac-02-41')] public function testWithReportMissingOffACustomerGetMoreToolsOnPageTwoGetsItsOwnSessionId(): void diff --git a/tests/Integration/McpSdk/CallToolTest.php b/tests/Integration/McpSdk/CallToolTest.php index bb11780..0061ffb 100644 --- a/tests/Integration/McpSdk/CallToolTest.php +++ b/tests/Integration/McpSdk/CallToolTest.php @@ -12,6 +12,7 @@ use AgentCat\Options; use AgentCat\Tests\Support\Golden; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\McpSdk\CustomerHandler; use AgentCat\Tests\Support\McpSdk\GreetingTool; use AgentCat\Tests\Support\McpSdk\HttpHarness; @@ -206,7 +207,7 @@ public function testTheFiftyFirstToolKeepsItsOwnSessionId(bool $modern): void $this->assertObjectNotHasProperty('error', $answer); // without D66: -32602, "Missing required properties" $this->assertSame([['session_id' => 'theirs', 'agent_id' => 'a1']], $own->calls); $this->assertSame('foreign', $this->tag($this->fixture->event(), Constants::TAG_SESSION_SOURCE)); - $this->assertCount(1, array_filter($this->fixture->lines(), static fn(string $line): bool => $line === self::REBUILT)); + $this->assertSame([ListingLines::ownSessionId('own'), self::REBUILT], $this->fixture->lines()); } /** D66 (B10 fixed): each listed page merges into the registries, so a tool of an earlier page is stripped exactly. */ @@ -227,13 +228,13 @@ public function testListedPagesMergeIntoTheRegistries(): void $this->assertSame([['msg' => 'hi', 'agent_id' => 'a1']], $echo->calls, 'agent tracking is off: agent_id is not ours'); $this->assertSame([['session_id' => 'mine']], $own->calls); - $this->assertNotContains(self::REBUILT, $this->fixture->lines()); + $this->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'both tools were listed: no rebuild'); } /** - * D66 (B10 fixed), with the rebuild's early stop (Plan F spec conflict 3): a customer list handler that ignores the - * cursor gives the same next cursor again. The rebuild for a tool that no page names reads two pages and stops at - * that cursor, as at the last page; the call is served with the heuristic strip. + * D66 (B10 fixed): the rebuild stops at the first page that names the tool. A customer list handler that ignores + * the cursor gives the same next cursor again. The rebuild for a tool that no page names reads two pages and stops + * at that cursor, as at the last page; the call is served with the heuristic strip. */ #[Group('ac-02-20')] #[Group('ac-04-30')] @@ -252,8 +253,7 @@ public function testAListHandlerThatIgnoresTheCursorEndsTheRebuild(): void $this->assertSame(2, $list->calls); // page 1, then page "again", whose cursor was already followed $this->assertSame([['msg' => 'hi']], $echo->calls); // no page names echo: the heuristic strip $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 1, 'text')); - $this->assertContains(self::REBUILT, $this->fixture->lines()); - $this->assertSame([], array_values(array_filter($this->fixture->lines(), static fn(string $line): bool => str_starts_with($line, 'Warning:')))); + $this->assertSame([self::REBUILT], $this->fixture->lines()); } /** @@ -275,13 +275,13 @@ public function testANextCursorTheLibraryDoesNotSendEndsTheRebuild(): void $this->assertSame(1, $list->calls); $this->assertSame([['msg' => 'hi']], $echo->calls); - $this->assertContains(self::REBUILT, $this->fixture->lines()); + $this->assertSame([self::REBUILT], $this->fixture->lines()); } /** - * Task 2 fix round 0: a get_more_tools call rebuilds like any other tool. A server that never listed (PHP-FPM): the - * rebuild finds the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the - * customer's tool), and the `session_id` that tool declares stays the customer's: no session is read from it. + * D66: a get_more_tools call rebuilds like any other tool. A server that never listed (PHP-FPM): the rebuild finds + * the customer's own get_more_tools on page 2. AgentCat answers the call (report-missing shadows the customer's + * tool), and the `session_id` that tool declares stays the customer's: no session is read from it. */ #[DataProvider('eras')] #[Group('ac-02-19')] @@ -306,7 +306,62 @@ public function testAShadowedCustomerGetMoreToolsKeepsItsOwnSessionId(bool $mode $event = $this->fixture->event(); $this->assertSame('', $event->sessionId); $this->assertSame('foreign', $this->tag($event, Constants::TAG_SESSION_SOURCE)); - $this->assertContains(self::REBUILT, $this->fixture->lines()); + $this->assertSame(ListingLines::shadowedCall(6), $this->fixture->lines()); + } + + /** + * D66 (B10 fixed): a server that never listed rebuilds up to the page that names the tool, so a page-2 tool with an + * `outputSchema` is in the output registry and gets the `mcp_session` mirror. Page 1 alone would leave it out. + */ + #[Group('ac-02-36')] + #[Group('ac-02-20')] + public function testAPageTwoToolWithAnOutputSchemaGetsTheSessionMirror(): void + { + $builder = TrackingFixture::builder(); + foreach (range(1, 50) as $i) { + Tools::named($builder, sprintf('t%02d', $i)); + } + Tools::structured($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $structured = JsonAssert::at($result, 'structuredContent'); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys($structured)); + $this->assertSame('issued', JsonAssert::at($structured, Constants::MCP_SESSION_KEY, 'status')); + $this->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** + * D66 (B10 fixed): on a long-running server a page listed again replaces only its own entries. After page 1, page 2 + * and page 1 again, the tools of page 2 keep their entry, their own `session_id` and their mirror, with no rebuild. + */ + #[Group('ac-02-8')] + #[Group('ac-02-19')] + public function testListingAnEarlierPageAgainKeepsTheLaterPagesEntries(): void + { + $builder = TrackingFixture::builder()->setPaginationLimit(2); + Tools::echo($builder); + Tools::named($builder, 't01'); + $own = Tools::own($builder); + Tools::structured($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $cursor = JsonAssert::at($harness->listTools(), 'nextCursor'); + $this->assertIsString($cursor); + $second = JsonAssert::at($harness->listTools($cursor), 'tools'); + $this->assertIsList($second); + $this->assertSame(['own', 'structured', GetMoreTools::NAME], array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $second)); + $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'mine', 'agent_id' => 'a1']); + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $this->assertSame([['session_id' => 'mine', 'agent_id' => 'a1']], $own->calls, 'agent tracking is off: agent_id is not ours'); + $this->assertSame('foreign', $this->tag($this->fixture->event(0, 2), Constants::TAG_SESSION_SOURCE)); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys(JsonAssert::at($result, 'structuredContent'))); + $this->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'no rebuild'); } /** @return iterable}> */ diff --git a/tests/Support/ListingLines.php b/tests/Support/ListingLines.php new file mode 100644 index 0000000..80788bc --- /dev/null +++ b/tests/Support/ListingLines.php @@ -0,0 +1,32 @@ + + */ + public static function shadowedCall(int $contextLength): array + { + return [self::SHADOWED, self::ownSessionId('get_more_tools'), self::REBUILT, sprintf(self::MISSING_TOOL_REPORTED, $contextLength)]; + } +} diff --git a/tests/Support/McpSdk/McpSdkConformanceHarness.php b/tests/Support/McpSdk/McpSdkConformanceHarness.php index a99891e..bc7a0f8 100644 --- a/tests/Support/McpSdk/McpSdkConformanceHarness.php +++ b/tests/Support/McpSdk/McpSdkConformanceHarness.php @@ -58,32 +58,13 @@ public static function untracked(): self /** `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; + return self::pagesOf15(withOwn: false); } /** `fullPage()`'s 15 tools, then `own` alone on page 2. */ public static function paged(): 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)); - } - $tools['own'] = Tools::own($builder); - $harness->tools[] = $tools; - $harness->server = $harness->fixture->harness($harness->tracked($builder)); - - return $harness; + return self::pagesOf15(withOwn: true); } /** @@ -155,6 +136,24 @@ private static function builder(): Builder return Server::builder()->setServerInfo('conformance-server', '2.0.0'); } + /** `echo` and `t01` to `t14` with a page size of 15, then `own` on page 2 when `$withOwn`, tracked with defaults. */ + private static function pagesOf15(bool $withOwn): 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)); + } + if ($withOwn) { + $tools['own'] = Tools::own($builder); + } + $harness->tools[] = $tools; + $harness->server = $harness->fixture->harness($harness->tracked($builder)); + + return $harness; + } + /** Adds the five conformance tools, which `received()` reads. */ private function conformanceTools(Builder $builder): Builder { diff --git a/tests/Unit/Internal/Engine/CallInterceptorTest.php b/tests/Unit/Internal/Engine/CallInterceptorTest.php index 35c5831..e540502 100644 --- a/tests/Unit/Internal/Engine/CallInterceptorTest.php +++ b/tests/Unit/Internal/Engine/CallInterceptorTest.php @@ -402,7 +402,7 @@ public function testAToolOnALaterPageIsStrippedExactly(): void $this->interceptor->intercept($this->server(), $seam); $this->assertSame([null, 'p2'], $seam->cursors); - $this->assertSame(['stripped', ['context']], $seam->calls[0] ?? null); // the heuristic: session_id, agent_id, context + $this->assertSame(['stripped', ['context']], $seam->calls[0] ?? null); // before D66: the heuristic session_id, agent_id, context $event = $this->onlyEvent(); $this->assertSame('foreign', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); $this->assertSame('', $event->sessionId); @@ -603,8 +603,8 @@ public function testGetMoreToolsIsAnsweredWithoutTheCustomer(): void } /** - * A get_more_tools call rebuilds like any other tool (Task 2 fix round 0): AgentCat's own is on no page, so the - * read goes to the last page. Its entry then names the handles AgentCat injected, and they are AgentCat's. + * D66: a get_more_tools call rebuilds like any other tool. AgentCat's own is on no page, so the read goes to the + * last page. Its entry then names the handles AgentCat injected, and they are AgentCat's. */ #[Group('ac-02-40')] public function testAGetMoreToolsCallRebuildsAndItsHandlesAreAgentCats(): void diff --git a/tests/Unit/Internal/Engine/ToolListingTest.php b/tests/Unit/Internal/Engine/ToolListingTest.php index ac1180f..b71d9ea 100644 --- a/tests/Unit/Internal/Engine/ToolListingTest.php +++ b/tests/Unit/Internal/Engine/ToolListingTest.php @@ -18,6 +18,7 @@ use AgentCat\Tests\Support\CallContexts; use AgentCat\Tests\Support\FakeCallSeam; use AgentCat\Tests\Support\JsonAssert; +use AgentCat\Tests\Support\ListingLines; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; use PHPUnit\Framework\Attributes\DataProvider; @@ -461,7 +462,7 @@ public function testTheNextCursorIsANonEmptyString(mixed $cursor, ?string $next) $this->assertSame($next, ToolListing::nextCursor($cursor)); } - /** Plan F spec conflict 3: the rebuild stops at the first page that names the called tool. */ + /** D66: the rebuild stops at the first page that names the called tool, so a page-1 tool costs one page. */ #[Group('ac-02-20')] public function testARebuildForAPageOneToolReadsOnePage(): void { @@ -489,6 +490,7 @@ public function testARebuildForAPageTwoToolReadsTwoPages(): void $this->assertTrue($server->injection->hasDeclaredSessionParam('own')); $this->assertSame(['context'], ArgumentStripper::names('own', $server->registry)); // its session_id stays $this->assertTrue($server->everyPageRead); + $this->assertSame([ListingLines::ownSessionId('own'), self::REBUILT], $this->logger->messages()); } /** @@ -545,8 +547,8 @@ public function testARebuildStopsAfterOneHundredPages(): void } /** - * A get_more_tools call rebuilds like any other tool (coordinator ruling, Task 2 fix round 0). AgentCat's own is on - * no page, so the read goes to the last page, which appends it: its entry holds the parameters AgentCat injected. + * D66: a get_more_tools call rebuilds like any other tool. AgentCat's own is on no page, so the read goes to the + * last page, which appends it: its entry holds the parameters AgentCat injected. */ #[Group('ac-02-40')] public function testAGetMoreToolsCallReadsToTheLastPageForAgentCatsOwn(): void From 456696bca957963428a897d1b0668332d69de627 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:02:30 +0000 Subject: [PATCH 06/68] test: pin that the merged output registry stays a list --- tests/Unit/Internal/Engine/ToolListingTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Internal/Engine/ToolListingTest.php b/tests/Unit/Internal/Engine/ToolListingTest.php index b71d9ea..09a53af 100644 --- a/tests/Unit/Internal/Engine/ToolListingTest.php +++ b/tests/Unit/Internal/Engine/ToolListingTest.php @@ -239,10 +239,10 @@ public function testListingANamelessToolAgainKeepsOneOutputRegistryEntry(): void $nameless = '{"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}}},"outputSchema":{"type":"object","properties":{}}}'; for ($listing = 1; $listing <= 5; ++$listing) { - ToolListing::apply($server, [$this->tool($nameless), $this->tool('{"name":"a"}')], lastPage: true); + ToolListing::apply($server, [$this->tool($nameless), $this->tool('{"name":"a","outputSchema":{"type":"object","properties":{}}}')], lastPage: true); } - $this->assertSame(['unknown'], $server->outputRegistry); + $this->assertSame(['unknown', 'a'], $server->outputRegistry); // a list: the copy removed before `a` leaves no gap $this->assertSame(['a', 'get_more_tools'], $this->registered($server)); } From 39d06586118a3aa0ffb380c16bc0ff694c715e74 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:07:53 +0000 Subject: [PATCH 07/68] fix: give the six remaining bundle keys an info text --- src/Symfony/AgentCatBundle.php | 14 ++++++++++---- tests/Integration/Symfony/BundleTest.php | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index 0db02a2..b2d20a5 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -33,6 +33,12 @@ final class AgentCatBundle extends AbstractBundle private const array BOOLS = ['enable_report_missing' => true, 'enable_tracing' => true, 'enable_tool_call_context' => true]; + private const array BOOL_INFO = [ + 'enable_report_missing' => 'Adds the get_more_tools tool, so agents can report tools they miss. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', + 'enable_tracing' => 'Publishes one event for every tool call. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', + 'enable_tool_call_context' => 'Adds a context parameter to your tools, so agents explain each call. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', + ]; + /** The configuration's root key is `agentcat` (D55), not the `agent_cat` that Symfony derives from the class name. */ public function __construct() { @@ -51,10 +57,10 @@ public function configure(DefinitionConfigurator $definition): void $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp, written here, not in an environment variable. Unset: their own file, agentcat.log in the log directory.')->end(); $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL, the TypeScript SDK\'s name, is read too.')->end(); foreach (self::BOOLS as $key => $default) { - $children->scalarNode($key)->defaultValue($default)->end(); + $children->scalarNode($key)->defaultValue($default)->info(self::BOOL_INFO[$key])->end(); } - $children->scalarNode('custom_context_description')->defaultNull()->end(); - $children->scalarNode('enable_agent_tracking')->defaultFalse()->end(); + $children->scalarNode('custom_context_description')->defaultNull()->info('Replaces the default description of the context parameter. Unset: the default description.')->end(); + $children->scalarNode('enable_agent_tracking')->defaultFalse()->info('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.')->end(); foreach (self::HOOKS as $hook) { $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, written here, not in an environment variable. The service is resolved when the hook runs.')->end(); } @@ -62,7 +68,7 @@ public function configure(DefinitionConfigurator $definition): void $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS, the TypeScript SDK\'s name, is read too.')->end(); $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end(); $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end(); - $children->scalarNode('disable_signal_handlers')->defaultFalse()->end(); + $children->scalarNode('disable_signal_handlers')->defaultFalse()->info('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.')->end(); } /** @param array $config */ diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 026c1b5..1ab170a 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -110,6 +110,27 @@ public function testBootingAndACommandTrackNothing(): void $this->assertSame($handlers, $this->signalHandlers()); } + /** Every key of the `agentcat` configuration explains itself in `config:dump-reference`. */ + #[MaximumDuration(1000)] + public function testEveryKeyOfTheReferenceHasAnInfoText(): void + { + $console = new Application($this->kernel(agentcat: self::ON)); + $console->setAutoExit(false); + $output = new BufferedOutput(); + + $exit = $console->run(new ArrayInput(['command' => 'config:dump-reference', 'name' => 'agentcat']), $output); + $lines = explode("\n", $output->fetch()); + + $this->assertSame(0, $exit); + $bare = []; + foreach ($lines as $index => $line) { + if (1 === preg_match('/^ ([a-z_]+):/', $line, $key) && !str_starts_with($lines[$index - 1] ?? '', ' #')) { + $bare[] = $key[1]; + } + } + $this->assertSame([], $bare); + } + /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ #[MaximumDuration(1000)] public function testTheServerIsTrackedOncePerContainer(): void From 9eed466b84a7912a33db493712ecb044ffb5250e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:08:10 +0000 Subject: [PATCH 08/68] fix: treat an empty exclude string as unset with one log line --- src/Internal/Support/BridgeKeys.php | 9 +++++++-- tests/Unit/Internal/Support/BridgeKeysTest.php | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Internal/Support/BridgeKeys.php b/src/Internal/Support/BridgeKeys.php index 19b5403..be68802 100644 --- a/src/Internal/Support/BridgeKeys.php +++ b/src/Internal/Support/BridgeKeys.php @@ -40,8 +40,8 @@ public function string(string $key): ?string } /** - * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one; a null entry is - * unset. + * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one, but the empty string + * names nothing: the default, with one line. A null entry is unset. * * @param list $default the value when the key is unset, and when it is of a wrong type * @@ -50,6 +50,11 @@ public function string(string $key): ?string public function strings(string $key, array $default): array { $value = $this->config[$key] ?? $default; + if ($value === '') { + ($this->warn)(sprintf(self::WRONG_TYPE, $key, 'array or non-empty string', 'empty string')); // names no server + + return $default; + } if (is_string($value)) { return [$value]; } diff --git a/tests/Unit/Internal/Support/BridgeKeysTest.php b/tests/Unit/Internal/Support/BridgeKeysTest.php index e0da5ff..4d92514 100644 --- a/tests/Unit/Internal/Support/BridgeKeysTest.php +++ b/tests/Unit/Internal/Support/BridgeKeysTest.php @@ -47,6 +47,12 @@ public function testAStringIsAListOfOne(): void $this->assertSame([], $this->lines->getArrayCopy()); } + public function testAnEmptyStringNamesNoServerSoItIsTheDefaultWithOneLine(): void + { + $this->assertSame(self::DEFAULT, $this->keys(['exclude' => ''])->strings('exclude', self::DEFAULT)); + $this->assertSame(['WARN: AgentCat option "agentcat.exclude" must be array or non-empty string, got empty string; the default is used'], $this->lines->getArrayCopy()); + } + public function testAListOfAnotherTypeIsTheDefaultWithOneLine(): void { $this->assertSame(self::DEFAULT, $this->keys(['exclude' => 1.5])->strings('exclude', self::DEFAULT)); From 6d53ad2904c68f3ce1715c162ccca8ea22754a2f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:08:23 +0000 Subject: [PATCH 09/68] fix: say when agentcat:install creates .env --- src/Laravel/Console/InstallCommand.php | 6 +++++- tests/Integration/Laravel/InstallCommandTest.php | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Laravel/Console/InstallCommand.php b/src/Laravel/Console/InstallCommand.php index 2f27a96..f3ff2f2 100644 --- a/src/Laravel/Console/InstallCommand.php +++ b/src/Laravel/Console/InstallCommand.php @@ -9,7 +9,7 @@ use Illuminate\Foundation\Application; /** - * `php artisan agentcat:install`: writes `AGENTCAT_PROJECT_ID` into `.env` and an empty key into `.env.example`, and + * `php artisan agentcat:install`: writes `AGENTCAT_PROJECT_ID` into `.env` (creating the file, with that key only, when it is missing) and an empty key into `.env.example`, and * publishes `config/agentcat.php` with `--config`. It sends nothing over the network. * * @internal @@ -39,6 +39,7 @@ public function handle(Application $app): int return self::FAILURE; } $env = $app->environmentFilePath(); + $created = !file_exists($env); try { EnvFile::set($env, 'AGENTCAT_PROJECT_ID', $project); EnvFile::ensure($env . '.example', 'AGENTCAT_PROJECT_ID'); @@ -47,6 +48,9 @@ public function handle(Application $app): int return self::FAILURE; } + if ($created) { + $this->line(sprintf('Created %s with AGENTCAT_PROJECT_ID only.', $env)); + } if ($this->option('config') === true) { $this->call('vendor:publish', ['--tag' => 'agentcat-config']); } diff --git a/tests/Integration/Laravel/InstallCommandTest.php b/tests/Integration/Laravel/InstallCommandTest.php index 4e5d71b..51d323f 100644 --- a/tests/Integration/Laravel/InstallCommandTest.php +++ b/tests/Integration/Laravel/InstallCommandTest.php @@ -48,6 +48,7 @@ public function testItWritesTheProjectIdAndNothingElse(): void { $this->install('agentcat:install', ['project' => self::PROJECT]) ->expectsOutputToContain('AgentCat is set up: AGENTCAT_PROJECT_ID is in ' . $this->dir . '/.env.') + ->doesntExpectOutputToContain('Created') ->assertExitCode(0); $this->assertSame("APP_NAME=Demo\nAGENTCAT_PROJECT_ID=" . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); @@ -73,7 +74,9 @@ public function testItCreatesAMissingEnvAndLeavesAMissingExampleAlone(): void unlink($this->dir . '/.env'); unlink($this->dir . '/.env.example'); - $this->install('agentcat:install', ['project' => self::PROJECT])->assertExitCode(0); + $this->install('agentcat:install', ['project' => self::PROJECT]) + ->expectsOutputToContain('Created ' . $this->dir . '/.env with AGENTCAT_PROJECT_ID only.') + ->assertExitCode(0); $this->assertSame('AGENTCAT_PROJECT_ID=' . self::PROJECT . "\n", file_get_contents($this->dir . '/.env')); $this->assertFileDoesNotExist($this->dir . '/.env.example'); From a16d8f7b16e89e36d704f8e82b98c6d9b48556ec Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:08:59 +0000 Subject: [PATCH 10/68] fix: tell publishCustomEvent callers what to pass instead of a StatelessProtocol --- src/Client.php | 5 +++++ src/Internal/Engine/Libraries.php | 6 ++++++ src/McpSdk/McpSdkLibrary.php | 9 +++++++++ tests/Integration/PublishCustomEventTest.php | 14 ++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/Client.php b/src/Client.php index 82d23ae..f8cbaf7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -226,6 +226,11 @@ public function publishCustomEvent(object|string $serverOrSessionId, ?string $pr return; } + $advice = Libraries::untrackedAdvice($serverOrSessionId); + if ($advice !== null) { + throw new \InvalidArgumentException('Server is not tracked: ' . $advice); + } + throw new \InvalidArgumentException('Server is not tracked. Please call agentcat.track() first or provide a session ID string.'); } if ($sessionId === '') { diff --git a/src/Internal/Engine/Libraries.php b/src/Internal/Engine/Libraries.php index 94daed0..42eee5b 100644 --- a/src/Internal/Engine/Libraries.php +++ b/src/Internal/Engine/Libraries.php @@ -48,6 +48,12 @@ public static function supportMatrix(): string return implode(' ', array_map(static fn(Library $library): string => $library->supportMatrix(), self::all())); } + /** What to pass instead of an object that no library can map to a tracked server; null when there is no advice. */ + public static function untrackedAdvice(object $server): ?string + { + return McpSdkLibrary::untrackedAdvice($server); + } + public static function trackedServer(object $server): ?TrackedServer { foreach (self::all() as $library) { diff --git a/src/McpSdk/McpSdkLibrary.php b/src/McpSdk/McpSdkLibrary.php index 4c1a687..92e0b85 100644 --- a/src/McpSdk/McpSdkLibrary.php +++ b/src/McpSdk/McpSdkLibrary.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; use Mcp\Server\Builder; +use Mcp\Server\Stateless\StatelessProtocol; use Psr\Log\LoggerInterface; /** @@ -18,6 +19,14 @@ */ final readonly class McpSdkLibrary implements Library { + /** What to pass instead, when the object is what `Builder::buildStateless()` returns; null for any other object. */ + public static function untrackedAdvice(object $server): ?string + { + return $server instanceof StatelessProtocol + ? 'pass the Mcp\Server\Builder (or the Mcp\Server built from it) to AgentCat::publishCustomEvent(); Builder::buildStateless() returns a StatelessProtocol, which AgentCat cannot map to its Builder' + : null; + } + #[\Override] public function supports(object $server): bool { diff --git a/tests/Integration/PublishCustomEventTest.php b/tests/Integration/PublishCustomEventTest.php index 84d6d46..2b9497a 100644 --- a/tests/Integration/PublishCustomEventTest.php +++ b/tests/Integration/PublishCustomEventTest.php @@ -41,6 +41,8 @@ final class PublishCustomEventTest extends TestCase private const string NOT_TRACKED = 'Server is not tracked. Please call agentcat.track() first or provide a session ID string.'; + private const string STATELESS = 'Server is not tracked: pass the Mcp\Server\Builder (or the Mcp\Server built from it) to AgentCat::publishCustomEvent(); Builder::buildStateless() returns a StatelessProtocol, which AgentCat cannot map to its Builder'; + private const string NO_SESSION = 'publishCustomEvent: no sessionId provided; event will be published without a session. Pass eventData.sessionId to attribute it to a task.'; private const string PUBLISHED_WITHOUT_SESSION = "Published custom event without a session with type 'agentcat:custom'"; @@ -117,6 +119,18 @@ public function testAFirstArgumentThatIsNeitherObjectNorStringIsATypeError(): vo } /** @return iterable */ + /** What `Builder::buildStateless()` returns cannot be mapped to its Builder, so the message says what to pass. */ + public function testAStatelessProtocolIsRejectedWithTheObjectToPass(): void + { + $protocol = TrackingFixture::builder()->buildStateless(); + + $error = Thrown::by(\InvalidArgumentException::class, fn() => $this->client->publishCustomEvent($protocol, self::PROJECT, new CustomEvent(sessionId: 'ses_1'))); + + $this->assertSame(self::STATELESS, $error->getMessage()); + $this->assertNothingQueued(); + $this->assertSame([], $this->logger->messages()); + } + public static function untracked(): iterable { yield 'a Builder' => [TrackingFixture::builder(...)]; From c73cc79acf9e7e302ff862d327baaf8819794a8c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:09:26 +0000 Subject: [PATCH 11/68] fix: let AGENTCAT_ENABLED decide when agentcat.enabled holds an unknown word --- src/Internal/Support/EnabledGate.php | 9 +++++---- src/Internal/Support/TestMode.php | 6 ++++++ .../Unit/Internal/Support/EnabledGateTest.php | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Internal/Support/EnabledGate.php b/src/Internal/Support/EnabledGate.php index 395d087..6f1e9ab 100644 --- a/src/Internal/Support/EnabledGate.php +++ b/src/Internal/Support/EnabledGate.php @@ -16,9 +16,10 @@ final class EnabledGate { /** * The D44 words decide (`TestMode::word()`). Unset or blank: `TestMode::sdkEnabled()`, which reads - * `AGENTCAT_ENABLED` of the process and is off in test environments (D31, D44). Any other value logs one line and - * the D31 default is used: off in test environments, on elsewhere. It does not read `AGENTCAT_ENABLED` again, which - * holds the same value when `env()` filled the configuration, and would log a second line for it. + * `AGENTCAT_ENABLED` of the process and is off in test environments (D31, D44). Any other value logs one line, and + * `AGENTCAT_ENABLED` of the process decides when it holds a D44 word, as it does for the shared client. Else the D31 + * default is used: off in test environments, on elsewhere. The variable is read without a line of its own, because + * it holds the same unknown word when `env()` filled the configuration. * * @return \Closure(): bool */ @@ -33,6 +34,6 @@ public static function of(mixed $configured): \Closure } 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(); + return static fn(): bool => TestMode::environmentWord() ?? !TestMode::inTestEnvironment(); } } diff --git a/src/Internal/Support/TestMode.php b/src/Internal/Support/TestMode.php index baeb798..85f384c 100644 --- a/src/Internal/Support/TestMode.php +++ b/src/Internal/Support/TestMode.php @@ -71,6 +71,12 @@ public static function sdkEnabled(?\Closure $classLoaded = null): bool return self::$configured ?? self::word(Env::get('AGENTCAT_ENABLED') ?? '') ?? self::byDefault(self::normalized('AGENTCAT_ENABLED'), $classLoaded); } + /** The D44 word that `AGENTCAT_ENABLED` holds, without a log line; null when it is unset, blank or unknown. */ + public static function environmentWord(): ?bool + { + return self::word(Env::get('AGENTCAT_ENABLED') ?? ''); + } + /** * 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. diff --git a/tests/Unit/Internal/Support/EnabledGateTest.php b/tests/Unit/Internal/Support/EnabledGateTest.php index e3542d7..7387e7d 100644 --- a/tests/Unit/Internal/Support/EnabledGateTest.php +++ b/tests/Unit/Internal/Support/EnabledGateTest.php @@ -87,6 +87,24 @@ public function testAnUnknownWordThatIsAlsoTheEnvironmentLogsOneLine(): void $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()); } + public function testAnUnknownWordFallsBackToTheEnvironmentOfTheProcess(): void + { + putenv('AGENTCAT_ENABLED=false'); + $off = EnabledGate::of('maybe'); + putenv('AGENTCAT_ENABLED=true'); + $on = EnabledGate::of('maybe'); + putenv('AGENTCAT_ENABLED'); + $unset = EnabledGate::of('maybe'); + + putenv('AGENTCAT_ENABLED=false'); + $this->assertFalse($off()); + putenv('AGENTCAT_ENABLED=true'); + $this->assertTrue($on()); // forced on, also in a test environment (D44) + putenv('AGENTCAT_ENABLED'); + $this->assertFalse($unset()); // PHPUnit is loaded: the default (D31) + $this->assertCount(3, $this->logger->messages()); // one line for each unknown word, none for the variable + } + /** @return iterable */ public static function unknownValues(): iterable { From c82469a8d6d0dafd425f4c78b8440e42ed26d8a1 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:10:27 +0000 Subject: [PATCH 12/68] fix: name the written key and the fix in the exporter entry and header messages --- src/Internal/Telemetry/ExporterConfigs.php | 4 ++-- tests/Unit/Internal/Telemetry/ExporterConfigsTest.php | 8 ++++---- tests/Unit/OptionsTest.php | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Internal/Telemetry/ExporterConfigs.php b/src/Internal/Telemetry/ExporterConfigs.php index 1b5a57e..393fcca 100644 --- a/src/Internal/Telemetry/ExporterConfigs.php +++ b/src/Internal/Telemetry/ExporterConfigs.php @@ -45,7 +45,7 @@ public static function fromArray(array $entries): array $config = match (true) { $entry instanceof OtlpExporter, $entry instanceof DatadogExporter, $entry instanceof SentryExporter, $entry instanceof PostHogExporter => $entry, is_array($entry) => self::one($name, $entry), - default => throw new \InvalidArgumentException('exporter configuration must be an array, got ' . get_debug_type($entry)), + default => throw new \InvalidArgumentException(sprintf('exporter "exporters.%s" must be an array, got %s; write an array or omit the entry', $name, get_debug_type($entry))), }; } catch (\InvalidArgumentException $e) { Log::write(sprintf('Failed to initialize exporter %s: %s', $name, ErrorText::of($e))); @@ -118,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.' . $header, 'string', $text); + $headers[(string) $header] = is_string($text) ? $text : throw new \InvalidArgumentException(sprintf('exporter option "headers.%s" must be a string, got %s; write a string or omit the entry', $header, get_debug_type($text))); } return $headers; diff --git a/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php index 00cc2a2..ececb1e 100644 --- a/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php +++ b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php @@ -114,8 +114,8 @@ public function testANonArrayEntryIsLogged(): void { $this->assertSame([], ExporterConfigs::fromArray([3 => 'otlp', 'x' => new \stdClass()])); $this->assertSame([ - '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', + 'Failed to initialize exporter 3: InvalidArgumentException: exporter "exporters.3" must be an array, got string; write an array or omit the entry', + 'Failed to initialize exporter x: InvalidArgumentException: exporter "exporters.x" must be an array, got stdClass; write an array or omit the entry', ], $this->logger->messages()); } @@ -158,12 +158,12 @@ public function testAHeaderThatIsNotAStringIsNamed(): void 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()); + $this->assertSame(['Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers.Authorization" must be a string, got null; write a string or omit the entry'], $logger->messages()); } public function testTheFirstHeaderThatIsNotAStringIsNamed(): void { $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()); + $this->assertSame(['Failed to initialize exporter a: InvalidArgumentException: exporter option "headers.z" must be a string, got int; write a string or omit the entry'], $this->logger->messages()); } } diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index 0e6e7e1..3bd5390 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -254,9 +254,9 @@ 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.x" must be string, got int', + 'Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers.x" must be a string, got int; write a string or omit the entry', 'WARN: Unknown AgentCat option "exporters.ph.hots" ignored', - 'Failed to initialize exporter str: InvalidArgumentException: exporter configuration must be an array, got string', + 'Failed to initialize exporter str: InvalidArgumentException: exporter "exporters.str" must be an array, got string; write an array or omit the entry', ], $this->logger->messages()); } From 0fd1b6a652ff89d6d0a324b6d46da1c7fc05b7d3 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:23:21 +0000 Subject: [PATCH 13/68] test: keep the provider docblock with its data provider --- tests/Integration/PublishCustomEventTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/PublishCustomEventTest.php b/tests/Integration/PublishCustomEventTest.php index 2b9497a..c7f3043 100644 --- a/tests/Integration/PublishCustomEventTest.php +++ b/tests/Integration/PublishCustomEventTest.php @@ -118,7 +118,6 @@ public function testAFirstArgumentThatIsNeitherObjectNorStringIsATypeError(): vo $this->client->publishCustomEvent($bad, 'p'); // @phpstan-ignore argument.type (proves PHP's own type check) } - /** @return iterable */ /** What `Builder::buildStateless()` returns cannot be mapped to its Builder, so the message says what to pass. */ public function testAStatelessProtocolIsRejectedWithTheObjectToPass(): void { @@ -131,6 +130,7 @@ public function testAStatelessProtocolIsRejectedWithTheObjectToPass(): void $this->assertSame([], $this->logger->messages()); } + /** @return iterable */ public static function untracked(): iterable { yield 'a Builder' => [TrackingFixture::builder(...)]; From 793d9a1834a551bcb5a236d6e4c0c299fbcb4a54 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:23:21 +0000 Subject: [PATCH 14/68] fix: treat a blank exclude string like an empty one --- src/Internal/Support/BridgeKeys.php | 4 ++-- tests/Unit/Internal/Support/BridgeKeysTest.php | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Internal/Support/BridgeKeys.php b/src/Internal/Support/BridgeKeys.php index be68802..13fd874 100644 --- a/src/Internal/Support/BridgeKeys.php +++ b/src/Internal/Support/BridgeKeys.php @@ -40,7 +40,7 @@ public function string(string $key): ?string } /** - * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one, but the empty string + * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one, but a blank string * names nothing: the default, with one line. A null entry is unset. * * @param list $default the value when the key is unset, and when it is of a wrong type @@ -50,7 +50,7 @@ public function string(string $key): ?string public function strings(string $key, array $default): array { $value = $this->config[$key] ?? $default; - if ($value === '') { + if (is_string($value) && trim($value) === '') { ($this->warn)(sprintf(self::WRONG_TYPE, $key, 'array or non-empty string', 'empty string')); // names no server return $default; diff --git a/tests/Unit/Internal/Support/BridgeKeysTest.php b/tests/Unit/Internal/Support/BridgeKeysTest.php index 4d92514..cfb4f99 100644 --- a/tests/Unit/Internal/Support/BridgeKeysTest.php +++ b/tests/Unit/Internal/Support/BridgeKeysTest.php @@ -53,6 +53,12 @@ public function testAnEmptyStringNamesNoServerSoItIsTheDefaultWithOneLine(): voi $this->assertSame(['WARN: AgentCat option "agentcat.exclude" must be array or non-empty string, got empty string; the default is used'], $this->lines->getArrayCopy()); } + public function testABlankStringIsTreatedLikeTheEmptyOne(): void + { + $this->assertSame(self::DEFAULT, $this->keys(['exclude' => " \t\n"])->strings('exclude', self::DEFAULT)); + $this->assertSame(['WARN: AgentCat option "agentcat.exclude" must be array or non-empty string, got empty string; the default is used'], $this->lines->getArrayCopy()); + } + public function testAListOfAnotherTypeIsTheDefaultWithOneLine(): void { $this->assertSame(self::DEFAULT, $this->keys(['exclude' => 1.5])->strings('exclude', self::DEFAULT)); From e3f9e4ef4ef8c2f6f857b825f222307a70718ef0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:23:21 +0000 Subject: [PATCH 15/68] refactor: build the header message through invalid() --- src/Internal/Telemetry/ExporterConfigs.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Internal/Telemetry/ExporterConfigs.php b/src/Internal/Telemetry/ExporterConfigs.php index 393fcca..9cbc1ee 100644 --- a/src/Internal/Telemetry/ExporterConfigs.php +++ b/src/Internal/Telemetry/ExporterConfigs.php @@ -118,14 +118,14 @@ 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 new \InvalidArgumentException(sprintf('exporter option "headers.%s" must be a string, got %s; write a string or omit the entry', $header, get_debug_type($text))); + $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers.' . $header, 'a string', $text, 'write a string or omit the entry'); } return $headers; } - private static function invalid(string $key, string $expected, mixed $value): \InvalidArgumentException + private static function invalid(string $key, string $expected, mixed $value, string $fix = ''): \InvalidArgumentException { - return new \InvalidArgumentException(sprintf('exporter option "%s" must be %s, got %s', $key, $expected, get_debug_type($value))); + return new \InvalidArgumentException(sprintf('exporter option "%s" must be %s, got %s%s', $key, $expected, get_debug_type($value), $fix === '' ? '' : '; ' . $fix)); } } From 0c124549d95e251eeb3c00a0d0f7a46a9c702a77 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:23:21 +0000 Subject: [PATCH 16/68] refactor: state the accepted bool words once in the bundle --- src/Symfony/AgentCatBundle.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index b2d20a5..044f49b 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -33,10 +33,13 @@ final class AgentCatBundle extends AbstractBundle private const array BOOLS = ['enable_report_missing' => true, 'enable_tracing' => true, 'enable_tool_call_context' => true]; + /** The sentence of every bool key's info() text. */ + private const string BOOL_WORDS = 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'; + private const array BOOL_INFO = [ - 'enable_report_missing' => 'Adds the get_more_tools tool, so agents can report tools they miss. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', - 'enable_tracing' => 'Publishes one event for every tool call. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', - 'enable_tool_call_context' => 'Adds a context parameter to your tools, so agents explain each call. Default: true. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.', + 'enable_report_missing' => 'Adds the get_more_tools tool, so agents can report tools they miss. Default: true. ' . self::BOOL_WORDS, + 'enable_tracing' => 'Publishes one event for every tool call. Default: true. ' . self::BOOL_WORDS, + 'enable_tool_call_context' => 'Adds a context parameter to your tools, so agents explain each call. Default: true. ' . self::BOOL_WORDS, ]; /** The configuration's root key is `agentcat` (D55), not the `agent_cat` that Symfony derives from the class name. */ @@ -60,7 +63,7 @@ public function configure(DefinitionConfigurator $definition): void $children->scalarNode($key)->defaultValue($default)->info(self::BOOL_INFO[$key])->end(); } $children->scalarNode('custom_context_description')->defaultNull()->info('Replaces the default description of the context parameter. Unset: the default description.')->end(); - $children->scalarNode('enable_agent_tracking')->defaultFalse()->info('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.')->end(); + $children->scalarNode('enable_agent_tracking')->defaultFalse()->info('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. ' . self::BOOL_WORDS)->end(); foreach (self::HOOKS as $hook) { $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, written here, not in an environment variable. The service is resolved when the hook runs.')->end(); } @@ -68,7 +71,7 @@ public function configure(DefinitionConfigurator $definition): void $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS, the TypeScript SDK\'s name, is read too.')->end(); $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end(); $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end(); - $children->scalarNode('disable_signal_handlers')->defaultFalse()->info('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. Takes true, false, 1, 0, yes, no, on, off or an %env()% value.')->end(); + $children->scalarNode('disable_signal_handlers')->defaultFalse()->info('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. ' . self::BOOL_WORDS)->end(); } /** @param array $config */ From 74a864b6493eef418efb9875b19596cd13151f4e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:33:59 +0000 Subject: [PATCH 17/68] test: assert every bool key states the accepted words --- tests/Integration/Symfony/BundleTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 1ab170a..3abf903 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -129,6 +129,8 @@ public function testEveryKeyOfTheReferenceHasAnInfoText(): void } } $this->assertSame([], $bare); + $this->assertSame(5, substr_count(implode(" +", $lines), 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'), 'every bool key states the accepted words'); } /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ From 926d77524880222b666031e8974a03c621af3b73 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 12:40:14 +0000 Subject: [PATCH 18/68] test: assert the full info text of the two remaining bool keys --- tests/Integration/Symfony/BundleTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 3abf903..26df6c7 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -129,8 +129,10 @@ public function testEveryKeyOfTheReferenceHasAnInfoText(): void } } $this->assertSame([], $bare); - $this->assertSame(5, substr_count(implode(" -", $lines), 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'), 'every bool key states the accepted words'); + $dump = implode("\n", $lines); + $this->assertSame(5, substr_count($dump, 'Takes true, false, 1, 0, yes, no, on, off or an %env()% value.'), 'every bool key states the accepted words'); + $this->assertStringContainsString('Adds an agent_id parameter to your tools, to tell agents apart. Default: false. Takes true, false', $dump); + $this->assertStringContainsString('Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers. Default: false. Takes true, false', $dump); } /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ From 320e64fbc819a9cf20c9e446c3c08173b612805f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 13:35:05 +0000 Subject: [PATCH 19/68] fix: keep serving on hosts that disable process, signal, curl multi and clock functions --- src/Internal/Delivery/FinishRequest.php | 26 ++- src/Internal/Delivery/RequestEndWait.php | 2 +- src/Internal/Delivery/ShutdownFlusher.php | 31 ++- src/Internal/Delivery/SignalDrain.php | 31 ++- src/Internal/Diagnostics/Diagnostics.php | 6 +- src/Internal/Engine/ToolListing.php | 5 +- src/Internal/Id/Ksuid.php | 3 +- src/Internal/Logging/FileLogger.php | 3 +- src/Internal/Support/Clock.php | 30 +++ src/Internal/Support/Env.php | 22 +- src/Internal/Telemetry/ExportJson.php | 11 +- src/Internal/Transport/CurlHttpChannel.php | 4 +- src/Internal/Transport/CurlSupport.php | 30 ++- src/Internal/Transport/CurlTransport.php | 11 +- src/Testing/Internal/InstantTransport.php | 13 +- tests/Fixtures/hardened-host.php | 21 ++ .../Integration/Delivery/MissingCurlTest.php | 2 +- .../Delivery/SignalDrainDeliveryTest.php | 27 ++- tests/Integration/Fixtures/signal-drain.php | 8 +- tests/Integration/HardenedHostTest.php | 197 ++++++++++++++++++ .../Transport/CurlTransportTest.php | 5 +- .../Internal/Delivery/FinishRequestTest.php | 26 +++ .../Internal/Delivery/ShutdownFlusherTest.php | 32 +++ .../Internal/Delivery/SignalDrainTest.php | 34 ++- tests/Unit/Internal/Support/ClockTest.php | 52 +++++ tests/Unit/Internal/Support/EnvTest.php | 36 ++++ .../Internal/Telemetry/ExportJsonTest.php | 36 ++++ .../Transport/CurlHttpChannelUnitTest.php | 5 +- .../Internal/Transport/CurlSupportTest.php | 54 +++++ 29 files changed, 702 insertions(+), 61 deletions(-) create mode 100644 src/Internal/Support/Clock.php create mode 100644 tests/Fixtures/hardened-host.php create mode 100644 tests/Integration/HardenedHostTest.php create mode 100644 tests/Unit/Internal/Support/ClockTest.php diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index e861bfb..7d59358 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -4,6 +4,8 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Support\Env; + /** * The SAPI function that sends the HTTP response to the client before the script ends, so that delivery at * shutdown does not delay the response. @@ -20,7 +22,7 @@ final class FinishRequest * * @param array $server `$_SERVER` * @param \Closure(string): bool $functionExists - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function functionFor(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): ?string { @@ -39,11 +41,11 @@ public static function functionFor(string $sapi, array $server, \Closure $functi * 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()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = null): bool { - return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false; + return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? Env::lookup(...))('FRANKENPHP_WORKER') !== false; } /** @@ -64,7 +66,7 @@ private static function isUnit(array $server): bool * stdio server) or FrankenPHP worker mode. * * @param array $server `$_SERVER` - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function outlivesRequest(string $sapi, array $server, ?\Closure $getenv = null): bool { @@ -77,7 +79,7 @@ public static function outlivesRequest(string $sapi, array $server, ?\Closure $g * * @param array $server `$_SERVER` * @param \Closure(string): bool $functionExists - * @param (\Closure(string): (string|false))|null $getenv default `getenv()` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function responseEndsWithTheScript(string $sapi, array $server, \Closure $functionExists, ?\Closure $getenv = null): bool { @@ -92,18 +94,24 @@ public static function detect(): ?\Closure /** * Calls `$function` after `ignore_user_abort(true)`, which keeps the script alive when the client - * disconnects after its response. + * disconnects after its response. A host can disable `ignore_user_abort()` (`disable_functions`): the call then + * runs alone. + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `ignore_user_abort()` exists; default `function_exists()` * * @return (\Closure(): void)|null null when `$function` is not callable */ - public static function closureFor(?string $function): ?\Closure + public static function closureFor(?string $function, ?\Closure $functionExists = null): ?\Closure { if ($function === null || !is_callable($function)) { return null; } + $ignoreAbort = ($functionExists ?? function_exists(...))('ignore_user_abort'); - return static function () use ($function): void { - ignore_user_abort(true); + return static function () use ($function, $ignoreAbort): void { + if ($ignoreAbort) { + ignore_user_abort(true); + } $function(); }; } diff --git a/src/Internal/Delivery/RequestEndWait.php b/src/Internal/Delivery/RequestEndWait.php index 37ca6ab..5a86f2f 100644 --- a/src/Internal/Delivery/RequestEndWait.php +++ b/src/Internal/Delivery/RequestEndWait.php @@ -31,7 +31,7 @@ final class RequestEndWait * @param bool $console true for a console command (Laravel `runningInConsole()`; * Octane workers report false); Symfony's kernel.terminate passes false * @param array|null $server `$_SERVER`; null reads it - * @param (\Closure(string): (string|false))|null $getenv default `getenv(...)` + * @param (\Closure(string): (string|false))|null $getenv default `Env::lookup()` */ public static function seconds(bool $console, string $sapi = PHP_SAPI, ?array $server = null, ?\Closure $getenv = null): float { diff --git a/src/Internal/Delivery/ShutdownFlusher.php b/src/Internal/Delivery/ShutdownFlusher.php index 1f0afc7..5553654 100644 --- a/src/Internal/Delivery/ShutdownFlusher.php +++ b/src/Internal/Delivery/ShutdownFlusher.php @@ -4,6 +4,8 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Logging\Log; + /** * Delivers the global queue when the request or script ends. * @@ -12,14 +14,18 @@ * `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. + * shuts the queue down within its drain budget. Where a host disables `register_shutdown_function()` + * (`disable_functions`), nothing is registered and one line says so: the events still waiting when the script ends are + * lost. * * @internal */ final class ShutdownFlusher { - /** @var \Closure(\Closure(): void): void */ - private readonly \Closure $register; + private const string NO_SHUTDOWN = 'WARN: AgentCat cannot send waiting events when the script ends: register_shutdown_function is disabled'; + + /** @var (\Closure(\Closure(): void): void)|null null where a host disabled `register_shutdown_function()` */ + private readonly ?\Closure $register; private bool $registered = false; @@ -28,22 +34,33 @@ final class ShutdownFlusher * @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 + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `register_shutdown_function()` exists; default `function_exists()` */ public function __construct( private readonly \Closure $queue, ?\Closure $register = null, private readonly ?\Closure $finishRequest = null, private readonly ?RequestEnd $requestEnd = null, + ?\Closure $functionExists = null, ) { - $this->register = $register ?? register_shutdown_function(...); + $this->register = $register ?? (($functionExists ?? function_exists(...))('register_shutdown_function') ? register_shutdown_function(...) : null); } - /** Registers the shutdown function once. A registrar error reaches the caller (the queue logs it). */ + /** + * Registers the shutdown function once; without `register_shutdown_function()`, logs NO_SHUTDOWN once instead. A + * registrar error reaches the caller (the queue logs it). + */ public function ensureRegistered(): void { if ($this->registered) { return; } + if (!$this->register instanceof \Closure) { + $this->registered = true; + Log::write(self::NO_SHUTDOWN); + + return; + } ($this->register)($this->onShutdown(...)); $this->registered = true; } @@ -52,7 +69,9 @@ public function ensureRegistered(): void public function onShutdown(): void { try { - ($this->register)($this->onFinalShutdown(...)); + ($this->register ?? static function (\Closure $pass): void { + $pass(); // no registrar: the final pass runs now + })($this->onFinalShutdown(...)); } catch (\Throwable) { $this->onFinalShutdown(); // cannot move to the end of the list: flush now } diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php index 038d45b..0a27b82 100644 --- a/src/Internal/Delivery/SignalDrain.php +++ b/src/Internal/Delivery/SignalDrain.php @@ -4,12 +4,15 @@ namespace AgentCat\Internal\Delivery; +use AgentCat\Internal\Logging\Log; + /** * Delivers the global queue when a long-running CLI server (an MCP stdio server) gets SIGTERM or SIGINT. Without * 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. It installs nothing when ext-swoole + * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. When a host disables a pcntl function + * the drain needs (`disable_functions`), it logs one line instead (`DISABLED`). 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: @@ -26,6 +29,12 @@ */ final class SignalDrain { + /** The line of a host whose ext-pcntl lacks a function the drain needs; `%s` lists them. */ + private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s)'; + + /** The pcntl functions the drain calls. */ + private const array FUNCTIONS = ['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals']; + /** @var \Closure(int): void */ private readonly \Closure $raise; @@ -43,7 +52,8 @@ final class SignalDrain * @param \Closure(): EventQueue $queue the queue to deliver, looked up at the signal * @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 + * process, or `exit(128 + $signal)` without `posix_kill()` or + * `posix_getpid()` * @param (\Closure(string): bool)|null $functionExists default `function_exists(...)` * @param (\Closure(string): bool)|null $extensionLoaded default `extension_loaded(...)` */ @@ -70,7 +80,10 @@ public function disable(): bool return true; } - /** Installs the handlers once. Does nothing outside the CLI SAPI, under Swoole or OpenSwoole, or without ext-pcntl. */ + /** + * Installs the handlers once. Does nothing outside the CLI SAPI, under Swoole or OpenSwoole, or without ext-pcntl; + * with ext-pcntl but without a function of FUNCTIONS, it logs `DISABLED` once. + */ public function install(): void { if ($this->installed || $this->disabled || $this->sapi !== 'cli') { @@ -81,10 +94,14 @@ public function install(): void 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; + $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); + if ($missing !== []) { + $this->disabled = true; // checked once + if (($this->extensionLoaded)('pcntl')) { + Log::write(sprintf(self::DISABLED, implode(', ', $missing))); // the extension is there: a host disabled them } + + return; } $this->installed = true; $installedAny = false; @@ -129,7 +146,7 @@ public function onSignal(int $signal, mixed $previous, mixed $siginfo = null): v private function raiseWithDefaultDisposition(int $signal): void { pcntl_signal($signal, \SIG_DFL); - if (($this->functionExists)('posix_kill')) { + if (($this->functionExists)('posix_kill') && ($this->functionExists)('posix_getpid')) { posix_kill(posix_getpid(), $signal); // the default disposition of SIGTERM and SIGINT ends the process here return; diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index 8c5313e..b66c74d 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -10,6 +10,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\BridgeConfig; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\Env; use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Transport\HttpRequest; @@ -106,8 +107,9 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue $queue, DiagnosticsAttributes::resource($projectId), DiagnosticsAttributes::record(), - $clock ?? static fn(): float => hrtime(true) / 1e9, - $wallMillis ?? static fn(): int => (int) (microtime(true) * 1000), // whole milliseconds, like Date.now() + $clock ?? Clock::monotonic(), + // Whole milliseconds, like Date.now(), from DateTimeImmutable: a host can disable microtime(). + $wallMillis ?? static fn(): int => (int) (new \DateTimeImmutable())->format('Uv'), FinishRequest::outlivesRequest($sapi ?? PHP_SAPI, $_SERVER), ); self::$active = $diagnostics; diff --git a/src/Internal/Engine/ToolListing.php b/src/Internal/Engine/ToolListing.php index 7d875c1..03b51d8 100644 --- a/src/Internal/Engine/ToolListing.php +++ b/src/Internal/Engine/ToolListing.php @@ -5,6 +5,7 @@ namespace AgentCat\Internal\Engine; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\ErrorText; /** @internal */ @@ -77,7 +78,7 @@ public static function nextCursor(mixed $cursor): ?string * for the first): its tools and its next * cursor, or null when the answer has no * tool list; may throw - * @param (\Closure(): float)|null $clock seconds on a monotonic clock; null reads `hrtime()` + * @param (\Closure(): float)|null $clock seconds on a monotonic clock; null reads `Clock::monotonic()` */ public static function ensureRegistries(TrackedServer $server, string $toolName, \Closure $listPage, ?\Closure $clock = null): void { @@ -85,7 +86,7 @@ public static function ensureRegistries(TrackedServer $server, string $toolName, return; } try { - $read = self::readPages($toolName, $listPage, $clock ?? static fn(): float => hrtime(true) / 1e9); + $read = self::readPages($toolName, $listPage, $clock ?? Clock::monotonic()); if ($read === null) { return; } diff --git a/src/Internal/Id/Ksuid.php b/src/Internal/Id/Ksuid.php index 897c96e..9d6e75b 100644 --- a/src/Internal/Id/Ksuid.php +++ b/src/Internal/Id/Ksuid.php @@ -35,7 +35,8 @@ public static function fromParts(int $timestampMs, string $payload): string public static function random(?int $timestampMs = null): string { - return self::fromParts($timestampMs ?? (int) (microtime(true) * 1000), random_bytes(self::PAYLOAD_LENGTH)); + // The clock of DateTimeImmutable, not microtime(): a host can disable a function (`disable_functions`). + return self::fromParts($timestampMs ?? (int) (new \DateTimeImmutable())->format('Uv'), random_bytes(self::PAYLOAD_LENGTH)); } /** diff --git a/src/Internal/Logging/FileLogger.php b/src/Internal/Logging/FileLogger.php index 2c0f776..da7986d 100644 --- a/src/Internal/Logging/FileLogger.php +++ b/src/Internal/Logging/FileLogger.php @@ -4,6 +4,7 @@ namespace AgentCat\Internal\Logging; +use AgentCat\Internal\Support\Env; use Psr\Log\AbstractLogger; /** @internal */ @@ -17,7 +18,7 @@ final class FileLogger extends AbstractLogger public function __construct(?string $path = null, ?\Closure $homeLookup = null) { if ($path === null) { - $home = ($homeLookup ?? static fn(): ?string => self::resolveHome(getenv('HOME'), self::posixHome(...)))(); + $home = ($homeLookup ?? static fn(): ?string => self::resolveHome(Env::lookup('HOME'), self::posixHome(...)))(); $path = $home === null ? null : $home . '/agentcat.log'; } $this->path = $path; diff --git a/src/Internal/Support/Clock.php b/src/Internal/Support/Clock.php new file mode 100644 index 0000000..a707440 --- /dev/null +++ b/src/Internal/Support/Clock.php @@ -0,0 +1,30 @@ + hrtime(true) / 1e9; + } + + return static fn(): float => microtime(true); + } +} diff --git a/src/Internal/Support/Env.php b/src/Internal/Support/Env.php index 68e46a2..708d6cb 100644 --- a/src/Internal/Support/Env.php +++ b/src/Internal/Support/Env.php @@ -5,7 +5,8 @@ namespace AgentCat\Internal\Support; /** - * Environment variable lookup that works under CLI, PHP-FPM and `variables_order` without `E`. + * Environment variable lookup that works under CLI, PHP-FPM, `variables_order` without `E`, and on a host that + * disables `getenv()`. * * @internal */ @@ -14,7 +15,7 @@ final class Env /** The first non-empty string among `getenv()`, `$_ENV` and `$_SERVER` (JS `||` skips empty strings). */ public static function get(string $name): ?string { - foreach ([getenv($name), $_ENV[$name] ?? null, $_SERVER[$name] ?? null] as $value) { + foreach ([self::lookup($name), $_ENV[$name] ?? null, $_SERVER[$name] ?? null] as $value) { if (is_string($value) && $value !== '') { return $value; } @@ -22,4 +23,21 @@ public static function get(string $name): ?string return null; } + + /** + * `getenv($name)`: the value, or false when the variable is not set. A host can disable `getenv()` + * (`disable_functions`); the value then comes from `$_ENV`, else `$_SERVER`, where the CLI and PHP-FPM copy the + * process environment. + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `getenv()` exists; default `function_exists()` + */ + public static function lookup(string $name, ?\Closure $functionExists = null): string|false + { + if (($functionExists ?? function_exists(...))('getenv')) { + return getenv($name); + } + $value = $_ENV[$name] ?? $_SERVER[$name] ?? null; + + return is_string($value) ? $value : false; + } } diff --git a/src/Internal/Telemetry/ExportJson.php b/src/Internal/Telemetry/ExportJson.php index 2b81a83..7951ff3 100644 --- a/src/Internal/Telemetry/ExportJson.php +++ b/src/Internal/Telemetry/ExportJson.php @@ -8,7 +8,8 @@ /** * `JSON.stringify` for exporter and diagnostics bodies: shortest round-trip floats whatever php.ini says (06 AC-64), - * no escaped slashes or Unicode, U+FFFD for invalid UTF-8. + * no escaped slashes or Unicode, U+FFFD for invalid UTF-8. On a host that disables `ini_set()` or `ini_get()` + * (`disable_functions`), floats follow php.ini's `serialize_precision` (PHP's default, -1, is the shortest round trip). * * @internal */ @@ -17,11 +18,17 @@ final class ExportJson private const int FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_LINE_TERMINATORS | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR; /** + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether `ini_set()` and `ini_get()` exist; default `function_exists()` + * * @throws \JsonException * @throws \UnexpectedValueException on a cycle or too deep nesting */ - public static function encode(mixed $value): string + public static function encode(mixed $value, ?\Closure $functionExists = null): string { + $exists = $functionExists ?? function_exists(...); + if (!$exists('ini_set') || !$exists('ini_get')) { + return json_encode(WireValue::from($value), self::FLAGS); + } $precision = ini_get('serialize_precision'); ini_set('serialize_precision', '-1'); diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index c18af66..02e321b 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -35,8 +35,6 @@ final class CurlHttpChannel implements HttpChannel /** Upper bound of one wait, so a waiting caller re-checks its state at least once a second. */ private const float MAX_WAIT_SECONDS = 1.0; - private const string NO_CURL = 'WARN: AgentCat cannot send telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; - private const string FULL = 'WARN: AgentCat telemetry export queue full, dropped the oldest %d request(s)'; /** The reason phrase of a status line; the last status line of a response wins. */ @@ -86,7 +84,7 @@ public function __construct( ) { $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); - $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->curl = new CurlSupport($functionExists); $this->multi = new CurlMulti($multiStatus); $this->inCallback = new CallbackFlag(); } diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php index d0da7c9..c6374f4 100644 --- a/src/Internal/Transport/CurlSupport.php +++ b/src/Internal/Transport/CurlSupport.php @@ -9,8 +9,8 @@ /** * What `CurlTransport` and `CurlHttpChannel` share about ext-curl: the check that every curl function they call - * exists (a PHP without ext-curl, or with some of its functions in `disable_functions`), made once and logged once - * (D32), the User-Agent, the timeouts and the options of a `POST`, the bound of one wait, and the texts of a failed + * exists (a PHP without ext-curl, or with some of its functions in `disable_functions`), made once by each and logged + * once per process (D32), the User-Agent, the timeouts and the options of a `POST`, the bound of one wait, and the texts of a failed * transfer and of a failed multi handle. `CurlMulti` runs the transfers. * * @internal @@ -20,6 +20,9 @@ final class CurlSupport /** The longest curl timeout: 2^31 - 1 ms, what a 32-bit C `long` (curl's option type on Windows) carries. */ public const float MAX_TIMEOUT_SECONDS = 2_147_483.647; + /** The line of a PHP that lacks a function of FUNCTIONS; `%s` lists them. Both users need every one of them. */ + private const string MISSING = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; + /** Every curl function `CurlTransport` and `CurlHttpChannel` call. */ private const array FUNCTIONS = [ 'curl_init', 'curl_setopt_array', 'curl_error', 'curl_strerror', 'curl_getinfo', 'curl_multi_init', @@ -30,32 +33,39 @@ final class CurlSupport /** Whether every function in FUNCTIONS exists; null until `usable()` checked it. */ private ?bool $usable = null; + /** Whether a check of this process logged MISSING: the event transport and the side channel share one line. */ + private static bool $reported = false; + /** @var \Closure(string): bool */ private readonly \Closure $functionExists; - /** - * @param string $missingLine the line logged when a function is missing; `%s` is the comma-separated list - * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. - */ - public function __construct(private readonly string $missingLine, ?\Closure $functionExists = null) + /** @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. */ + public function __construct(?\Closure $functionExists = null) { $this->functionExists = $functionExists ?? function_exists(...); } - /** Whether every curl function exists. The first call checks them; a false answer logs one line. */ + /** Whether every curl function exists. The first call checks them; the first false answer of the process logs MISSING. */ public function usable(): bool { if ($this->usable === null) { $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); $this->usable = $missing === []; - if (!$this->usable) { - Log::write(sprintf($this->missingLine, implode(', ', $missing))); + if (!$this->usable && !self::$reported) { + self::$reported = true; + Log::write(sprintf(self::MISSING, implode(', ', $missing))); } } return $this->usable; } + /** Tests only: the next check that finds a missing function logs MISSING again. */ + public static function reset(): void + { + self::$reported = false; + } + /** True once `usable()` answered true. Checks nothing. */ public function confirmed(): bool { diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index df767db..ee6cd6d 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -5,13 +5,14 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\ErrorText; /** * One `POST` per event over ext-curl: `curl_multi` transfers, at most five at once, with TypeScript's retry * policy scheduled by time (`pump()` never sleeps) and connect/total timeouts. Nothing reaches STDOUT/STDERR. * The multi handle starts with the first delivery. When a curl function it calls is missing or disabled, every - * delivery is dropped and one line is logged (D32). + * delivery is dropped, and one line per process says so (`CurlSupport`, D32). * * @internal */ @@ -28,8 +29,6 @@ final class CurlTransport implements Transport /** Upper bound of one wait, so a waiting caller re-checks its state at least once a second. */ private const float MAX_WAIT_SECONDS = 1.0; - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled (%s)'; - /** The ext-curl check, made at the first delivery. */ private readonly CurlSupport $curl; @@ -50,7 +49,7 @@ final class CurlTransport implements Transport private array $scheduled = []; /** - * @param (\Closure(): float)|null $clock monotonic seconds (tests); must not throw. Default `hrtime(true) / 1e9`. + * @param (\Closure(): float)|null $clock monotonic seconds (tests); must not throw. Default `Clock::monotonic()`. * @param (\Closure(string, int): int)|null $multiStatus tests only: receives the function name and the CURLM code of each * `curl_multi_add_handle()` and `curl_multi_exec()` call, returns the code to act on * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. @@ -67,8 +66,8 @@ public function __construct( ) { $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); - $this->clock = $clock ?? static fn(): float => hrtime(true) / 1e9; - $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->clock = $clock ?? Clock::monotonic(); + $this->curl = new CurlSupport($functionExists); $this->multi = new CurlMulti($multiStatus); } diff --git a/src/Testing/Internal/InstantTransport.php b/src/Testing/Internal/InstantTransport.php index ac146f8..83d8df0 100644 --- a/src/Testing/Internal/InstantTransport.php +++ b/src/Testing/Internal/InstantTransport.php @@ -4,6 +4,7 @@ namespace AgentCat\Testing\Internal; +use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\Transport; @@ -12,8 +13,16 @@ * * @internal */ -final class InstantTransport implements Transport +final readonly class InstantTransport implements Transport { + /** @var \Closure(): float */ + private \Closure $clock; + + public function __construct() + { + $this->clock = Clock::monotonic(); + } + #[\Override] public function start(Delivery $delivery): void { @@ -51,6 +60,6 @@ public function inFlight(): int #[\Override] public function now(): float { - return hrtime(true) / 1e9; + return ($this->clock)(); } } diff --git a/tests/Fixtures/hardened-host.php b/tests/Fixtures/hardened-host.php new file mode 100644 index 0000000..9a13383 --- /dev/null +++ b/tests/Fixtures/hardened-host.php @@ -0,0 +1,21 @@ + 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']]; +$builder = Server::builder() + ->setServerInfo('hardened-host', '1.0.0') + ->addTool(static fn(string $msg): string => 'echo: ' . $msg, 'echo', description: 'Echo a message', inputSchema: $schema); + +AgentCat::track($builder, 'proj_' . str_repeat('h', 27)); + +exit($builder->build()->run(new StdioTransport())); diff --git a/tests/Integration/Delivery/MissingCurlTest.php b/tests/Integration/Delivery/MissingCurlTest.php index a647522..ca761a2 100644 --- a/tests/Integration/Delivery/MissingCurlTest.php +++ b/tests/Integration/Delivery/MissingCurlTest.php @@ -14,7 +14,7 @@ /** D32: a PHP without ext-curl's functions keeps the facade silent and harmless; events are dropped with one line. */ final class MissingCurlTest extends TestCase { - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled ('; + private const string NO_CURL = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; /** HOME of the script (its agentcat.log) and its result file. */ private string $dir = ''; diff --git a/tests/Integration/Delivery/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index d896a21..3327620 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -115,6 +115,28 @@ public function testWithTheHandlersDisabledSigtermEndsTheProcessAtOnceAndNothing $this->assertSame([], $this->resourceNames($base)); } + /** A host can disable `posix_getpid()`: the drain then ends the process with the shell's status for SIGTERM. */ + #[MaximumDuration(10000)] + public function testWithoutPosixGetpidTheDrainEndsTheProcessWithTheExitStatusOfTheSignal(): void + { + $base = $this->collector()->scenario(); + $process = $this->fixture($base, 'outside', '', ['-d', 'disable_functions=posix_getpid']); + + $process->start(); + $ready = microtime(true) + self::SIGNAL_DEADLINE; + while ($process->isRunning() && $process->getOutput() === '' && microtime(true) < $ready) { + usleep(10_000); + } + $this->assertSame("ready\n", $process->getOutput(), $process->getErrorOutput()); + $process->signal(15); + $process->wait(); + + $this->assertFalse($process->hasBeenSignaled()); + $this->assertSame(128 + 15, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame(['signal-drain'], $this->resourceNames($base)); + } + #[MaximumDuration(10000)] public function testWithTheHandlersDisabledANormalExitStillDeliversThroughTheShutdownFunction(): void { @@ -222,10 +244,11 @@ public function testASigtermDuringTheShutdownFlushKeepsItsDeadline(): void fclose($hole); } - private function fixture(string $base, string $mode, string $marker = ''): Process + /** @param list $php options of the PHP binary, before the script */ + private function fixture(string $base, string $mode, string $marker = '', array $php = []): Process { $process = new Process( - [PHP_BINARY, __DIR__ . '/../Fixtures/signal-drain.php', $base, $mode, $marker], + [PHP_BINARY, ...$php, __DIR__ . '/../Fixtures/signal-drain.php', $base, $mode, $marker], null, ['HOME' => $this->dir], ); diff --git a/tests/Integration/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index fa30d9d..d59bf04 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -23,6 +23,8 @@ // sends itself SIGTERM. Nothing delivers; SIGTERM ends the process at once. // disabled-shutdown: like disabled, but it prints "ending" and ends normally: the shutdown function delivers the event. // shutdown: no signal of its own: it prints "ending" and ends, so that the shutdown function delivers the event. +// outside: it prints "ready" and waits for the SIGTERM of the test, so that it also runs where a host disabled +// posix_getpid(). $baseUrl = $argv[1] ?? ''; $mode = $argv[2] ?? ''; $markerFile = $argv[3] ?? ''; @@ -50,7 +52,11 @@ if ($mode === 'chain-exit' || $mode === 'twice') { echo "signaled\n"; } -posix_kill(posix_getpid(), SIGTERM); +if ($mode === 'outside') { + echo "ready\n"; +} else { + posix_kill(posix_getpid(), SIGTERM); +} if ($mode === 'chain') { usleep(200_000); echo "done\n"; diff --git a/tests/Integration/HardenedHostTest.php b/tests/Integration/HardenedHostTest.php new file mode 100644 index 0000000..4b1f645 --- /dev/null +++ b/tests/Integration/HardenedHostTest.php @@ -0,0 +1,197 @@ +stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->home = getenv('HOME') . '/hardened-host-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->home, 0o700)); + } + + #[\Override] + protected function tearDown(): void + { + $this->client?->process()->stop(0); + $this->client = null; + FileTree::remove($this->home); + } + + /** @return iterable */ + public static function lists(): iterable + { + yield 'shared hosting' => ['exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,pcntl_fork,posix_kill,posix_mkfifo,set_time_limit,ignore_user_abort,putenv,getmypid,ini_set,parse_ini_file,show_source,highlight_file']; + yield 'no signals' => ['pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_async_signals,posix_getpid,posix_kill']; + yield 'no curl multi' => ['curl_multi_init,curl_multi_exec,curl_multi_add_handle,curl_multi_select']; + yield 'no clocks' => ['hrtime,getenv']; + yield 'shared hosting without curl multi or signals' => ['curl_multi_exec,pcntl_signal,set_time_limit,ignore_user_abort,putenv,getmypid']; + } + + #[DataProvider('lists')] + #[Group('hardened-host')] + #[MaximumDuration(10000)] + public function testTheServerAnswersAndEndsWithOneLinePerMissingFunction(string $functions): void + { + $client = $this->start($functions); + $initialize = $this->resultOf($client, 1, 'initialize', [ + 'protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'hardened-client', 'version' => '1.0.0'], + ]); + $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($initialize, 'protocolVersion')); + $client->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + $call = $this->resultOf($client, 2, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hardened']]); + $this->assertFalse(JsonAssert::at($call, 'isError')); + $this->assertContains('echo: hardened', $this->texts($call)); + + $client->closeInput(); + $code = $client->waitForExit(self::EOF_EXIT_SECONDS); + $stderr = $client->process()->getErrorOutput(); + $this->assertSame(0, $code, 'exit code at stdin EOF; stderr: ' . $stderr); + $this->assertStringNotContainsString('Fatal error', $stderr); + $this->assertStringNotContainsString('Uncaught', $stderr); + foreach ($client->stdoutLines() as $line) { + $this->assertSame('2.0', JsonAssert::at(Json::decode($line), 'jsonrpc'), 'stdout carries JSON-RPC only: ' . $line); + } + + $posts = $this->collector()->requests($this->diagnostics); + if (str_contains($functions, 'curl_')) { + $this->assertSame([], $posts, 'without curl multi nothing is sent'); + } else { + $this->assertNotSame([], $posts, 'diagnostics reach the collector'); + $this->assertStringContainsString('AgentCat setup complete', MockCollector::body($posts[0])); + } + + $entries = $this->logEntries(); + $this->assertNotSame([], preg_grep('/AgentCat setup complete/', $entries), 'the log works: ' . implode('', $entries)); + $this->assertSame([], preg_grep('/Call to undefined function/', $entries), 'AgentCat calls no disabled function'); + foreach (explode(',', $functions) as $function) { + $naming = preg_grep('/\b' . preg_quote($function, '/') . '\b/', $entries); + $this->assertIsArray($naming); + if (in_array($function, self::REPORTED, true)) { + $this->assertCount(1, $naming, $function . ' is named in one line: ' . implode('', $entries)); + } else { + $this->assertLessThanOrEqual(1, count($naming), $function . ' is named in one line at most: ' . implode('', $entries)); + } + } + } + + /** + * Starts the fixture server with `$functions` disabled. The event API is a closed local port; diagnostics are on, as + * they are by default outside tests, and go to this test's collector scenario. + */ + private function start(string $functions): StdioMcpClient + { + $this->diagnostics = $this->collector()->scenario(); + $this->client = StdioMcpClient::command( + [PHP_BINARY, '-d', 'disable_functions=' . $functions, '-d', 'display_errors=stderr', '-d', 'log_errors=0', __DIR__ . '/../Fixtures/hardened-host.php'], + [ + 'HOME' => $this->home, + 'AGENTCAT_ENABLED' => 'true', + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->diagnostics, + 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', + 'DISABLE_DIAGNOSTICS' => 'false', + ], + ); + + return $this->client; + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } + + /** + * Sends one request and returns the `result` of its answer. + * + * @param array $params + */ + private function resultOf(StdioMcpClient $client, int $id, string $method, array $params): \stdClass + { + $client->send(['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]); + + return HttpHarness::result($client->receive($id)); + } + + /** @return list the `text` of each content block */ + private function texts(\stdClass $result): array + { + $content = JsonAssert::at($result, 'content'); + $this->assertIsArray($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), array_values($content)); + } + + /** @return list the entries of the server's agentcat.log, each starting at its timestamp */ + private function logEntries(): array + { + $path = $this->home . '/agentcat.log'; + $log = is_file($path) ? (string) file_get_contents($path) : ''; + $entries = preg_split('/^(?=\[\d{4}-\d\d\-\d\dT)/m', $log); + $this->assertIsArray($entries); + + return array_values(array_filter($entries, static fn(string $entry): bool => $entry !== '')); + } +} diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index affe71e..1ba7ad7 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Transport\ApiBaseUrl; +use AgentCat\Internal\Transport\CurlSupport; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\RetryPolicy; @@ -31,7 +32,7 @@ final class CurlTransportTest extends TestCase private const string GAVE_UP = 'Failed to deliver AgentCat event after retries: '; - private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled ('; + private const string NO_CURL = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; /** Seconds; every blocking call in these tests has a deadline. */ private const float DEADLINE = 5.0; @@ -58,12 +59,14 @@ protected function setUp(): void { $this->logger = new MemoryLogger(); Log::setLogger($this->logger); + CurlSupport::reset(); } #[\Override] protected function tearDown(): void { Log::reset(); + CurlSupport::reset(); } #[Group('ac-03-1')] diff --git a/tests/Unit/Internal/Delivery/FinishRequestTest.php b/tests/Unit/Internal/Delivery/FinishRequestTest.php index c1a38a8..73b8763 100644 --- a/tests/Unit/Internal/Delivery/FinishRequestTest.php +++ b/tests/Unit/Internal/Delivery/FinishRequestTest.php @@ -187,6 +187,32 @@ public function testClosureForIgnoresUserAbortThenCallsTheFunction(): void } } + /** A host can disable ignore_user_abort(): the finish call then runs alone, and the setting stays as it was. */ + public function testWithoutIgnoreUserAbortTheClosureOnlyCallsTheFunction(): void + { + $previous = ignore_user_abort(); + $asked = new \ArrayObject(); + $exists = static function (string $function) use ($asked): bool { + $asked[] = $function; + + return $function !== 'ignore_user_abort'; + }; + + try { + ignore_user_abort(false); + $finish = FinishRequest::closureFor(self::class . '::probe', $exists); + $this->assertInstanceOf(\Closure::class, $finish); + + $finish(); + $finish(); + + $this->assertSame([0, 0], self::$probeCalls); + $this->assertSame(['ignore_user_abort'], $asked->getArrayCopy(), 'checked once, when the closure is made'); + } finally { + ignore_user_abort((bool) $previous); + } + } + public function testClosureForNeedsACallableFunction(): void { $this->assertNull(FinishRequest::closureFor(null)); diff --git a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php index a1f3f53..448256c 100644 --- a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php +++ b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php @@ -49,6 +49,38 @@ protected function tearDown(): void Log::reset(); } + /** A host can disable register_shutdown_function(): nothing is registered, and one line says so. */ + public function testWithoutRegisterShutdownFunctionOneLineIsLoggedAndNothingRegisters(): void + { + $asked = new \ArrayObject(); + $flusher = new ShutdownFlusher(fn(): EventQueue => $this->queue, functionExists: static function (string $function) use ($asked): bool { + $asked[] = $function; + + return false; + }); + + $flusher->ensureRegistered(); + $flusher->ensureRegistered(); + $this->queue->add(new QueuedEvent($this->event())); + $flusher->onShutdown(); + + $this->assertSame(['register_shutdown_function'], $asked->getArrayCopy()); + $this->assertSame(['WARN: AgentCat cannot send waiting events when the script ends: register_shutdown_function is disabled'], $this->logger->messages()); + $this->assertCount(1, $this->transport->deliveries, 'a pass that runs anyway flushes at once'); + } + + public function testTheDefaultRegistrarNeedsNoCheckWhenOneIsGiven(): void + { + $asked = new \ArrayObject(); + new ShutdownFlusher(fn(): EventQueue => $this->queue, $this->recorder(), functionExists: static function (string $function) use ($asked): bool { + $asked[] = $function; + + return true; + }); + + $this->assertSame([], $asked->getArrayCopy()); + } + #[Group('ac-03-12')] public function testRegistersTheShutdownFunctionOnce(): void { diff --git a/tests/Unit/Internal/Delivery/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index e95ab49..3fc2357 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -28,6 +28,8 @@ final class SignalDrainTest extends TestCase private const int SIG_DFL = 0; + private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s)'; + private MemoryLogger $logger; private RecordingTransport $transport; @@ -144,11 +146,41 @@ public function testWithoutAPcntlFunctionNothingIsInstalled(string $missing): vo { $this->resetHandlers(); - $this->drain(static fn(string $function): bool => $function !== $missing)->install(); + $drain = $this->drain(static fn(string $function): bool => $function !== $missing); + $drain->install(); + $drain->install(); $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGTERM)); $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGINT)); $this->assertFalse(pcntl_async_signals()); + $this->assertSame([sprintf(self::DISABLED, $missing)], $this->logger->messages(), 'ext-pcntl is loaded: a host disabled the function'); + } + + public function testTheLineNamesEveryDisabledPcntlFunction(): void + { + $drain = new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), static fn(string $function): bool => $function === 'pcntl_signal_get_handler', 'cli', static fn(string $extension): bool => $extension === 'pcntl'); + + $drain->install(); + + $this->assertSame([sprintf(self::DISABLED, 'pcntl_signal, pcntl_async_signals')], $this->logger->messages()); + $this->assertTrue($drain->disable(), 'nothing was installed'); + } + + /** A PHP built without ext-pcntl has no drain, as documented: that is no hardened host, so nothing is logged. */ + public function testWithoutExtPcntlNothingIsLogged(): void + { + $asked = new \ArrayObject(); + $drain = new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), static function (string $function) use ($asked): bool { + $asked[] = $function; + + return false; + }, 'cli', static fn(string $extension): bool => false); + + $drain->install(); + $drain->install(); + + $this->assertSame([], $this->logger->messages()); + $this->assertSame(['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals'], $asked->getArrayCopy(), 'checked once'); } /** @return iterable */ diff --git a/tests/Unit/Internal/Support/ClockTest.php b/tests/Unit/Internal/Support/ClockTest.php new file mode 100644 index 0000000..1507082 --- /dev/null +++ b/tests/Unit/Internal/Support/ClockTest.php @@ -0,0 +1,52 @@ +assertGreaterThanOrEqual($before, $first); + $this->assertGreaterThanOrEqual($first, $second); + $this->assertLessThanOrEqual($after, $second); + $this->assertSame(['hrtime'], $asked->getArrayCopy(), 'checked once, when the clock is made'); + } + + public function testTheDefaultClockReadsHrtime(): void + { + $before = hrtime(true) / 1e9; + $now = Clock::monotonic()(); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual(hrtime(true) / 1e9, $now); + } + + /** A host can disable hrtime(): the clock then reads the wall clock. */ + public function testWithoutHrtimeTheClockReadsMicrotime(): void + { + $clock = Clock::monotonic(static fn(string $function): bool => $function !== 'hrtime'); + + $before = microtime(true); + $now = $clock(); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual(microtime(true), $now); + } +} diff --git a/tests/Unit/Internal/Support/EnvTest.php b/tests/Unit/Internal/Support/EnvTest.php index 5e938f2..e475a67 100644 --- a/tests/Unit/Internal/Support/EnvTest.php +++ b/tests/Unit/Internal/Support/EnvTest.php @@ -94,4 +94,40 @@ public function testNonStringValuesAreIgnored(): void $this->assertNull(Env::get(self::NAME)); } + + public function testLookupIsGetenv(): void + { + $asked = new \ArrayObject(); + $exists = static function (string $function) use ($asked): bool { + $asked[] = $function; + + return true; + }; + $_ENV[self::NAME] = 'from-env'; + $_SERVER[self::NAME] = 'from-server'; + + $this->assertFalse(Env::lookup(self::NAME, $exists), 'unset for getenv(): $_ENV and $_SERVER are not read'); + putenv(self::NAME . '='); + $this->assertSame('', Env::lookup(self::NAME, $exists), 'set but empty'); + putenv(self::NAME . '=from-getenv'); + $this->assertSame('from-getenv', Env::lookup(self::NAME)); + $this->assertSame(['getenv', 'getenv'], $asked->getArrayCopy()); + } + + /** A host can disable getenv(): the CLI and PHP-FPM copy the environment into $_SERVER. */ + public function testWithoutGetenvLookupReadsEnvThenServer(): void + { + $noGetenv = static fn(string $function): bool => $function !== 'getenv'; + putenv(self::NAME . '=from-getenv'); + + $this->assertFalse(Env::lookup(self::NAME, $noGetenv)); + $_SERVER[self::NAME] = 'from-server'; + $this->assertSame('from-server', Env::lookup(self::NAME, $noGetenv)); + $_ENV[self::NAME] = ''; + $this->assertSame('', Env::lookup(self::NAME, $noGetenv), 'set but empty in $_ENV'); + $_ENV[self::NAME] = 'from-env'; + $this->assertSame('from-env', Env::lookup(self::NAME, $noGetenv)); + $_ENV[self::NAME] = 42; + $this->assertFalse(Env::lookup(self::NAME, $noGetenv), 'not a string'); + } } diff --git a/tests/Unit/Internal/Telemetry/ExportJsonTest.php b/tests/Unit/Internal/Telemetry/ExportJsonTest.php index 1660765..3d6a906 100644 --- a/tests/Unit/Internal/Telemetry/ExportJsonTest.php +++ b/tests/Unit/Internal/Telemetry/ExportJsonTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Telemetry\ExportJson; use AgentCat\Tests\Support\Thrown; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -25,6 +26,41 @@ public function testFloatsAreExactWhateverSerializePrecisionSays(): void } } + /** @return iterable */ + public static function iniFunctions(): iterable + { + yield 'ini_set' => ['ini_set']; + yield 'ini_get' => ['ini_get']; + } + + /** A host can disable ini_set() or ini_get(): floats then follow php.ini's serialize_precision, and nothing fails. */ + #[DataProvider('iniFunctions')] + public function testWithoutAnIniFunctionFloatsFollowSerializePrecision(string $missing): void + { + $before = ini_get('serialize_precision'); + ini_set('serialize_precision', '17'); + + try { + $this->assertSame('{"l":0.14999999999999999}', ExportJson::encode(['l' => 150 / 1000], static fn(string $function): bool => $function !== $missing)); + $this->assertSame('17', ini_get('serialize_precision')); + } finally { + ini_set('serialize_precision', (string) $before); + } + } + + public function testBothIniFunctionsAreChecked(): void + { + $asked = new \ArrayObject(); + + ExportJson::encode(1.5, static function (string $function) use ($asked): bool { + $asked[] = $function; + + return true; + }); + + $this->assertSame(['ini_set', 'ini_get'], $asked->getArrayCopy()); + } + public function testSerializePrecisionIsRestoredAfterAFailure(): void { $before = ini_get('serialize_precision'); diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index a94e895..c053294 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Transport\CurlHttpChannel; +use AgentCat\Internal\Transport\CurlSupport; use AgentCat\Internal\Transport\HttpRequest; use AgentCat\Internal\Transport\HttpResponse; use AgentCat\Internal\Transport\HttpTransfer; @@ -20,7 +21,7 @@ final class CurlHttpChannelUnitTest extends TestCase /** A5: at most one line per pass, with the count of that pass. */ private const string FULL = 'WARN: AgentCat telemetry export queue full, dropped the oldest %d request(s)'; - private const string NO_CURL = 'WARN: AgentCat cannot send telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; + private const string NO_CURL = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; private MemoryLogger $logger; @@ -29,12 +30,14 @@ protected function setUp(): void { $this->logger = new MemoryLogger(); Log::setLogger($this->logger); + CurlSupport::reset(); } #[\Override] protected function tearDown(): void { Log::reset(); + CurlSupport::reset(); } public function testAFullQueueDropsTheOldestRequestsWithOneLinePerPass(): void diff --git a/tests/Unit/Internal/Transport/CurlSupportTest.php b/tests/Unit/Internal/Transport/CurlSupportTest.php index e25c8ad..27ea370 100644 --- a/tests/Unit/Internal/Transport/CurlSupportTest.php +++ b/tests/Unit/Internal/Transport/CurlSupportTest.php @@ -4,15 +4,69 @@ namespace AgentCat\Tests\Unit\Internal\Transport; +use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Transport\CurlSupport; use AgentCat\Internal\Transport\CurlTransport; +use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Thrown; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class CurlSupportTest extends TestCase { + private const string MISSING = 'WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; + + private MemoryLogger $logger; + + #[\Override] + protected function setUp(): void + { + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + CurlSupport::reset(); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + CurlSupport::reset(); + } + + /** The event transport and the side channel each check; one line per process names what is missing. */ + public function testOneLinePerProcessNamesTheMissingFunctions(): void + { + $missing = static fn(string $function): bool => !in_array($function, ['curl_multi_exec', 'curl_multi_select'], true); + $transport = new CurlSupport($missing); + $channel = new CurlSupport($missing); + + $this->assertFalse($transport->usable()); + $this->assertFalse($transport->usable()); + $this->assertFalse($channel->usable()); + $this->assertFalse($channel->confirmed()); + + $this->assertSame([sprintf(self::MISSING, 'curl_multi_exec, curl_multi_select')], $this->logger->messages()); + } + + public function testAfterAResetTheNextMissingFunctionIsLoggedAgain(): void + { + $this->assertFalse((new CurlSupport(static fn(string $function): bool => $function !== 'curl_init'))->usable()); + CurlSupport::reset(); + $this->assertFalse((new CurlSupport(static fn(string $function): bool => $function !== 'curl_error'))->usable()); + + $this->assertSame([sprintf(self::MISSING, 'curl_init'), sprintf(self::MISSING, 'curl_error')], $this->logger->messages()); + } + + public function testEveryFunctionThereLogsNothing(): void + { + $support = new CurlSupport(); + + $this->assertTrue($support->usable()); + $this->assertTrue($support->confirmed()); + $this->assertSame([], $this->logger->messages()); + } + /** @return iterable */ public static function waits(): iterable { From 9a1993d7389aa75f0993b882632c3c21b89d76da Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:04:16 +0000 Subject: [PATCH 20/68] fix: log how to get the signal drain where it cannot install --- src/Internal/Delivery/SignalDrain.php | 30 ++++++-- tests/Integration/HardenedHostTest.php | 30 +++++--- .../Internal/Delivery/SignalDrainTest.php | 70 +++++++++++++++++-- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php index 0a27b82..2943380 100644 --- a/src/Internal/Delivery/SignalDrain.php +++ b/src/Internal/Delivery/SignalDrain.php @@ -11,9 +11,10 @@ * 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. When a host disables a pcntl function - * the drain needs (`disable_functions`), it logs one line instead (`DISABLED`). It installs nothing when ext-swoole - * or ext-openswoole is loaded. A signal that the application ignores in PHP (`pcntl_signal(…, SIG_IGN)`) stays + * under the CLI SAPI with ext-pcntl, and keeps the handler each signal had before. Where it would install but cannot, + * it logs one line instead, with the fix: a host disabled a pcntl function the drain needs (`disable_functions`), or + * PHP lacks ext-pcntl (not on Windows, which has none). 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 @@ -30,7 +31,10 @@ final class SignalDrain { /** The line of a host whose ext-pcntl lacks a function the drain needs; `%s` lists them. */ - private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s)'; + private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s); remove them from disable_functions'; + + /** The line of a PHP without ext-pcntl. */ + private const string NO_PCNTL = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl is not loaded; install ext-pcntl'; /** The pcntl functions the drain calls. */ private const array FUNCTIONS = ['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals']; @@ -44,6 +48,9 @@ final class SignalDrain /** @var \Closure(string): bool */ private readonly \Closure $extensionLoaded; + /** @var \Closure(int): never */ + private readonly \Closure $exit; + private bool $installed = false; private bool $disabled = false; @@ -56,6 +63,8 @@ final class SignalDrain * `posix_getpid()` * @param (\Closure(string): bool)|null $functionExists default `function_exists(...)` * @param (\Closure(string): bool)|null $extensionLoaded default `extension_loaded(...)` + * @param (\Closure(int): never)|null $exit tests only: ends the process with a status; default `exit()` + * @param string $osFamily `PHP_OS_FAMILY` */ public function __construct( private readonly \Closure $queue, @@ -63,10 +72,15 @@ public function __construct( ?\Closure $functionExists = null, private readonly string $sapi = PHP_SAPI, ?\Closure $extensionLoaded = null, + ?\Closure $exit = null, + private readonly string $osFamily = PHP_OS_FAMILY, ) { $this->raise = $raise ?? $this->raiseWithDefaultDisposition(...); $this->functionExists = $functionExists ?? function_exists(...); $this->extensionLoaded = $extensionLoaded ?? extension_loaded(...); + $this->exit = $exit ?? static function (int $status): never { + exit($status); + }; } /** Never installs after this. False when the handlers are installed already (then nothing changes). */ @@ -81,8 +95,8 @@ public function disable(): bool } /** - * Installs the handlers once. Does nothing outside the CLI SAPI, under Swoole or OpenSwoole, or without ext-pcntl; - * with ext-pcntl but without a function of FUNCTIONS, it logs `DISABLED` once. + * Installs the handlers once. Does nothing outside the CLI SAPI or under Swoole or OpenSwoole. Without a function of + * FUNCTIONS it logs one line once: `DISABLED` when ext-pcntl is loaded, else `NO_PCNTL` (none on Windows). */ public function install(): void { @@ -99,6 +113,8 @@ public function install(): void $this->disabled = true; // checked once if (($this->extensionLoaded)('pcntl')) { Log::write(sprintf(self::DISABLED, implode(', ', $missing))); // the extension is there: a host disabled them + } elseif ($this->osFamily !== 'Windows') { + Log::write(self::NO_PCNTL); } return; @@ -151,6 +167,6 @@ private function raiseWithDefaultDisposition(int $signal): void return; } - exit(128 + $signal); // the shell's exit status for "ended by this signal" + ($this->exit)(128 + $signal); // the shell's exit status for "ended by this signal" } } diff --git a/tests/Integration/HardenedHostTest.php b/tests/Integration/HardenedHostTest.php index 4b1f645..8efff84 100644 --- a/tests/Integration/HardenedHostTest.php +++ b/tests/Integration/HardenedHostTest.php @@ -19,7 +19,8 @@ * Hosts that disable process, signal, curl multi and clock functions (`disable_functions`: on PHP 8 a disabled * function does not exist). A tracked stdio server answers as it would without AgentCat and ends cleanly at stdin EOF; * AgentCat logs at most one line for each function it misses, a function whose absence turns a feature off is named in - * exactly one line, and diagnostics, on as outside tests, still reach their collector while ext-curl is whole. + * exactly one line (a pcntl function only where ext-pcntl is loaded), and diagnostics, on as outside tests, still reach + * their collector while ext-curl is whole. */ final class HardenedHostTest extends TestCase { @@ -31,11 +32,13 @@ final class HardenedHostTest extends TestCase */ private const float EOF_EXIT_SECONDS = 6.5; - /** The functions whose absence turns a feature off: the signal drain, or event delivery. */ - private const array REPORTED = [ - 'pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals', - 'curl_multi_init', 'curl_multi_exec', 'curl_multi_add_handle', 'curl_multi_select', - ]; + /** The pcntl functions of the signal drain: a host that disables one gets one line naming it, with ext-pcntl. */ + private const array DRAIN = ['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals']; + + /** The curl functions of event delivery: one line names each missing one. */ + private const array DELIVERY = ['curl_multi_init', 'curl_multi_exec', 'curl_multi_add_handle', 'curl_multi_select']; + + private const string SIGNAL_LINE = 'AgentCat cannot send waiting events at SIGTERM or SIGINT'; private static ?MockCollector $collector = null; @@ -106,8 +109,7 @@ public function testTheServerAnswersAndEndsWithOneLinePerMissingFunction(string $code = $client->waitForExit(self::EOF_EXIT_SECONDS); $stderr = $client->process()->getErrorOutput(); $this->assertSame(0, $code, 'exit code at stdin EOF; stderr: ' . $stderr); - $this->assertStringNotContainsString('Fatal error', $stderr); - $this->assertStringNotContainsString('Uncaught', $stderr); + $this->assertSame('', $stderr, 'no error, warning or deprecation'); foreach ($client->stdoutLines() as $line) { $this->assertSame('2.0', JsonAssert::at(Json::decode($line), 'jsonrpc'), 'stdout carries JSON-RPC only: ' . $line); } @@ -123,11 +125,19 @@ public function testTheServerAnswersAndEndsWithOneLinePerMissingFunction(string $entries = $this->logEntries(); $this->assertNotSame([], preg_grep('/AgentCat setup complete/', $entries), 'the log works: ' . implode('', $entries)); $this->assertSame([], preg_grep('/Call to undefined function/', $entries), 'AgentCat calls no disabled function'); - foreach (explode(',', $functions) as $function) { + // Without ext-pcntl no pcntl function exists to name: one line says to install the extension instead. + $pcntl = extension_loaded('pcntl'); + $listed = explode(',', $functions); + $signalLines = preg_grep('/' . self::SIGNAL_LINE . '/', $entries); + $this->assertIsArray($signalLines); + $this->assertCount($pcntl ? (array_intersect(self::DRAIN, $listed) === [] ? 0 : 1) : 1, $signalLines, 'the signal drain line: ' . implode('', $entries)); + foreach ($listed as $function) { $naming = preg_grep('/\b' . preg_quote($function, '/') . '\b/', $entries); $this->assertIsArray($naming); - if (in_array($function, self::REPORTED, true)) { + if (in_array($function, self::DELIVERY, true) || (in_array($function, self::DRAIN, true) && $pcntl)) { $this->assertCount(1, $naming, $function . ' is named in one line: ' . implode('', $entries)); + } elseif (in_array($function, self::DRAIN, true)) { + $this->assertCount(0, $naming, $function . ' does not exist without ext-pcntl: ' . implode('', $entries)); } else { $this->assertLessThanOrEqual(1, count($naming), $function . ' is named in one line at most: ' . implode('', $entries)); } diff --git a/tests/Unit/Internal/Delivery/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index 3fc2357..6364a37 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -11,6 +11,7 @@ use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; +use AgentCat\Tests\Support\Thrown; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -28,7 +29,11 @@ final class SignalDrainTest extends TestCase private const int SIG_DFL = 0; - private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s)'; + private const string DISABLED = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl functions are disabled (%s); remove them from disable_functions'; + + private const string NO_PCNTL = 'WARN: AgentCat cannot send waiting events at SIGTERM or SIGINT: ext-pcntl is not loaded; install ext-pcntl'; + + private const int SIGWINCH = 28; private MemoryLogger $logger; @@ -166,21 +171,76 @@ public function testTheLineNamesEveryDisabledPcntlFunction(): void $this->assertTrue($drain->disable(), 'nothing was installed'); } - /** A PHP built without ext-pcntl has no drain, as documented: that is no hardened host, so nothing is logged. */ - public function testWithoutExtPcntlNothingIsLogged(): void + /** A CLI without ext-pcntl (the official Docker images) has no drain: one line says how to get it. */ + public function testWithoutExtPcntlOneLineSaysToInstallIt(): void { $asked = new \ArrayObject(); $drain = new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), static function (string $function) use ($asked): bool { $asked[] = $function; return false; - }, 'cli', static fn(string $extension): bool => false); + }, 'cli', static fn(string $extension): bool => false, osFamily: 'Linux'); $drain->install(); $drain->install(); - $this->assertSame([], $this->logger->messages()); + $this->assertSame([self::NO_PCNTL], $this->logger->messages()); $this->assertSame(['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals'], $asked->getArrayCopy(), 'checked once'); + $this->assertTrue($drain->disable(), 'nothing was installed'); + } + + /** Windows has no ext-pcntl to install: no line. */ + public function testOnWindowsWithoutExtPcntlNothingIsLogged(): void + { + $drain = new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), static fn(string $function): bool => false, 'cli', static fn(string $extension): bool => false, osFamily: 'Windows'); + + $drain->install(); + + $this->assertSame([], $this->logger->messages()); + } + + /** Where the drain never installs (another SAPI, a coroutine server, the switch), no line names the missing extension. */ + public function testWhereTheDrainNeverInstallsNothingIsLogged(): void + { + $none = static fn(string $function): bool => false; + (new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), $none, 'fpm-fcgi', static fn(string $extension): bool => false, osFamily: 'Linux'))->install(); + (new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), $none, 'cli', static fn(string $extension): bool => $extension === 'swoole', osFamily: 'Linux'))->install(); + $disabled = new SignalDrain(fn(): EventQueue => $this->queue, $this->raise(...), $none, 'cli', static fn(string $extension): bool => false, osFamily: 'Linux'); + $disabled->disable(); + $disabled->install(); + + $this->assertSame([], $this->logger->messages()); + } + + /** @return iterable */ + public static function posixFunctions(): iterable + { + yield 'posix_getpid' => ['posix_getpid']; + yield 'posix_kill' => ['posix_kill']; + } + + /** + * Without one of the two posix functions the default raise exits with the shell's status for the signal. SIGWINCH, + * whose default disposition is "ignore", shows it: a raise by `posix_kill()` would leave this process running. + */ + #[DataProvider('posixFunctions')] + #[RequiresPhpExtension('pcntl')] + public function testWithoutAPosixFunctionTheRaiseExitsWithTheSignalsStatus(string $missing): void + { + $exits = new \ArrayObject(); + $drain = new SignalDrain(fn(): EventQueue => $this->queue, null, static fn(string $function): bool => $function !== $missing, exit: static function (int $status) use ($exits): never { + $exits[] = $status; + + throw new \LogicException('exit'); + }); + $this->queue->add($this->queued('before-the-exit')); + pcntl_signal(\SIGWINCH, static function (): void {}); + + Thrown::by(\LogicException::class, static fn() => $drain->onSignal(\SIGWINCH, \SIG_DFL)); + + $this->assertSame([128 + self::SIGWINCH], $exits->getArrayCopy()); + $this->assertCount(1, $this->transport->deliveries, 'the queue drained before the exit'); + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGWINCH)); } /** @return iterable */ From f14121da13a55d07fa8e8a3c574f531530d77e19 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:04:16 +0000 Subject: [PATCH 21/68] fix: log a failure that repeats on every delivery pass once, with its count --- src/Internal/Delivery/EventQueue.php | 5 +- src/Internal/Logging/RepeatedLines.php | 66 +++++++++ src/Internal/Transport/CurlTransport.php | 9 +- .../Delivery/RepeatedFailureTest.php | 131 ++++++++++++++++++ .../Internal/Logging/RepeatedLinesTest.php | 88 ++++++++++++ 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/Internal/Logging/RepeatedLines.php create mode 100644 tests/Integration/Delivery/RepeatedFailureTest.php create mode 100644 tests/Unit/Internal/Logging/RepeatedLinesTest.php diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 5db4ea7..0e53ac1 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -6,6 +6,7 @@ use AgentCat\Event; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\RepeatedLines; use AgentCat\Internal\Pipeline\EventProcessor; use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Support\ErrorText; @@ -365,6 +366,7 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg return false; } $this->busy = true; + RepeatedLines::begin(); // a failure of every pass is logged once try { $until = $deadline(); @@ -390,6 +392,7 @@ private function deliverUntil(\Closure $deadline, bool $tick = true, bool $backg return false; } finally { $this->busy = false; + RepeatedLines::end(); } } @@ -430,7 +433,7 @@ private function pumpOnce(bool $tick = true): void } $this->transport->pump(); } catch (\Throwable $e) { - Log::write(self::GAVE_UP . ErrorText::of($e)); + RepeatedLines::write(self::GAVE_UP . ErrorText::of($e)); } if ($tick) { foreach ($this->companions as $companion) { diff --git a/src/Internal/Logging/RepeatedLines.php b/src/Internal/Logging/RepeatedLines.php new file mode 100644 index 0000000..94f363c --- /dev/null +++ b/src/Internal/Logging/RepeatedLines.php @@ -0,0 +1,66 @@ + each line logged in the running delivery, with its repeats */ + private static array $repeats = []; + + /** A delivery starts. */ + public static function begin(): void + { + ++self::$depth; + } + + /** Logs `$line`, except a line the running delivery logged already: that one is counted. */ + public static function write(string $line): void + { + if (self::$depth > 0 && array_key_exists($line, self::$repeats)) { + ++self::$repeats[$line]; + + return; + } + if (self::$depth > 0) { + self::$repeats[$line] = 0; + } + Log::write($line); + } + + /** A delivery ends; the outermost one logs one line for each line that repeated, with the count. */ + public static function end(): void + { + if (self::$depth === 0 || --self::$depth > 0) { + return; + } + $repeats = self::$repeats; + self::$repeats = []; + foreach ($repeats as $line => $count) { + if ($count > 0) { + Log::write(sprintf(self::REPEATED, $line, $count)); + } + } + } + + /** Tests only. */ + public static function reset(): void + { + self::$depth = 0; + self::$repeats = []; + } +} diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index ee6cd6d..7c31365 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -5,6 +5,7 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\RepeatedLines; use AgentCat\Internal\Support\Clock; use AgentCat\Internal\Support\ErrorText; @@ -145,6 +146,8 @@ public function waitForActivity(float $deadline): void #[\Override] public function drain(float $deadline): bool { + RepeatedLines::begin(); // a failure of every pass is logged once + try { while (true) { $this->pump(); @@ -160,6 +163,8 @@ public function drain(float $deadline): bool Log::write(self::GAVE_UP . ErrorText::of($error)); return false; + } finally { + RepeatedLines::end(); } } @@ -193,7 +198,7 @@ public function curlOptions(Delivery $delivery): array /** * Runs transport work: PHP warnings are suppressed (a framework handler may turn them into exceptions) and - * any throwable is logged, never thrown (D32). + * any throwable is logged, never thrown (D32); inside a delivery, a repeated one once (`RepeatedLines`). * * @param \Closure(): void $work */ @@ -204,7 +209,7 @@ private function guarded(\Closure $work): void try { $work(); } catch (\Throwable $error) { - Log::write(self::GAVE_UP . ErrorText::of($error)); + RepeatedLines::write(self::GAVE_UP . ErrorText::of($error)); } finally { restore_error_handler(); } diff --git a/tests/Integration/Delivery/RepeatedFailureTest.php b/tests/Integration/Delivery/RepeatedFailureTest.php new file mode 100644 index 0000000..454338a --- /dev/null +++ b/tests/Integration/Delivery/RepeatedFailureTest.php @@ -0,0 +1,131 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + RepeatedLines::reset(); + } + + #[\Override] + protected function tearDown(): void + { + RepeatedLines::reset(); + Log::reset(); + } + + #[MaximumDuration(2000)] + public function testATransportThatThrowsOnEveryPassLogsTwoLinesPerDrain(): void + { + $transport = new class implements Transport { + public int $pumps = 0; + + #[\Override] + public function start(Delivery $delivery): void {} + + #[\Override] + public function pump(): void + { + ++$this->pumps; + + throw new \RuntimeException('boom'); + } + + #[\Override] + public function waitForActivity(float $deadline): void {} + + #[\Override] + public function drain(float $deadline): bool + { + return false; + } + + #[\Override] + public function send(Delivery $delivery, float $deadline = INF): bool + { + return false; + } + + #[\Override] + public function inFlight(): int + { + return 1; + } + + #[\Override] + public function now(): float + { + return hrtime(true) / 1e9; + } + }; + $queue = new EventQueue(transport: $transport); + $queue->add($this->queued()); + + $this->assertFalse($queue->flush(self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, $transport->pumps, 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: boom', + sprintf('%sRuntimeException: boom (%d more times in this delivery)', self::GAVE_UP, $transport->pumps - 1), + ], $this->logger->messages()); + } + + /** The real transport: a curl call that throws on every pass (here the test seam of curl_multi_exec()). */ + #[MaximumDuration(2000)] + public function testCurlTransportWorkThatThrowsOnEveryPassLogsTwoLinesPerDrain(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + + throw new \RuntimeException('exec boom'); + } + + return $code; + }); + $queue = new EventQueue(transport: $transport); + $queue->configure(MockCollector::closedPortUrl()); + $queue->add($this->queued()); + + $this->assertFalse($queue->flush(self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 1), + ], $this->logger->messages()); + } + + private function queued(): QueuedEvent + { + return new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'repeated')); + } +} diff --git a/tests/Unit/Internal/Logging/RepeatedLinesTest.php b/tests/Unit/Internal/Logging/RepeatedLinesTest.php new file mode 100644 index 0000000..b171e12 --- /dev/null +++ b/tests/Unit/Internal/Logging/RepeatedLinesTest.php @@ -0,0 +1,88 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + RepeatedLines::reset(); + } + + #[\Override] + protected function tearDown(): void + { + RepeatedLines::reset(); + Log::reset(); + } + + public function testOutsideADeliveryEveryLineIsLogged(): void + { + RepeatedLines::write('Failed: boom'); + RepeatedLines::write('Failed: boom'); + + $this->assertSame(['Failed: boom', 'Failed: boom'], $this->logger->messages()); + } + + public function testInsideADeliveryARepeatedLineIsLoggedOnceAndCountedAtTheEnd(): void + { + RepeatedLines::begin(); + RepeatedLines::write('Failed: boom'); + RepeatedLines::write('Failed: other'); + RepeatedLines::write('Failed: boom'); + RepeatedLines::write('Failed: boom'); + $this->assertSame(['Failed: boom', 'Failed: other'], $this->logger->messages()); + + RepeatedLines::end(); + + $this->assertSame(['Failed: boom', 'Failed: other', 'Failed: boom (2 more times in this delivery)'], $this->logger->messages()); + } + + public function testANestedDeliveryReportsAtTheOutermostEnd(): void + { + RepeatedLines::begin(); + RepeatedLines::begin(); + RepeatedLines::write('Failed: boom'); + RepeatedLines::write('Failed: boom'); + RepeatedLines::end(); + $this->assertSame(['Failed: boom'], $this->logger->messages()); + RepeatedLines::write('Failed: boom'); + + RepeatedLines::end(); + + $this->assertSame(['Failed: boom', 'Failed: boom (2 more times in this delivery)'], $this->logger->messages()); + } + + public function testTheNextDeliveryLogsTheLineAgain(): void + { + RepeatedLines::begin(); + RepeatedLines::write('Failed: boom'); + RepeatedLines::end(); + RepeatedLines::begin(); + RepeatedLines::write('Failed: boom'); + RepeatedLines::end(); + + $this->assertSame(['Failed: boom', 'Failed: boom'], $this->logger->messages(), 'no repeat, no count line'); + } + + public function testAnEndWithoutABeginChangesNothing(): void + { + RepeatedLines::end(); + RepeatedLines::write('Failed: boom'); + RepeatedLines::write('Failed: boom'); + + $this->assertSame(['Failed: boom', 'Failed: boom'], $this->logger->messages()); + } +} From c650afa9d3e642d03d72ae8e0e083bc7e5024a8d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:04:16 +0000 Subject: [PATCH 22/68] test: disable posix_kill as well as posix_getpid on the signal path --- .../Delivery/SignalDrainDeliveryTest.php | 15 ++++++++++++--- tests/Integration/Fixtures/signal-drain.php | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Delivery/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index 3327620..63610fc 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -8,6 +8,7 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\JsonAssert; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; @@ -115,12 +116,20 @@ public function testWithTheHandlersDisabledSigtermEndsTheProcessAtOnceAndNothing $this->assertSame([], $this->resourceNames($base)); } - /** A host can disable `posix_getpid()`: the drain then ends the process with the shell's status for SIGTERM. */ + /** @return iterable */ + public static function posixFunctions(): iterable + { + yield 'posix_getpid' => ['posix_getpid']; + yield 'posix_kill' => ['posix_kill']; + } + + /** A host can disable `posix_getpid()` or `posix_kill()`: the drain then ends the process with the shell's status for SIGTERM. */ + #[DataProvider('posixFunctions')] #[MaximumDuration(10000)] - public function testWithoutPosixGetpidTheDrainEndsTheProcessWithTheExitStatusOfTheSignal(): void + public function testWithoutAPosixFunctionTheDrainEndsTheProcessWithTheExitStatusOfTheSignal(string $disabled): void { $base = $this->collector()->scenario(); - $process = $this->fixture($base, 'outside', '', ['-d', 'disable_functions=posix_getpid']); + $process = $this->fixture($base, 'outside', '', ['-d', 'disable_functions=' . $disabled]); $process->start(); $ready = microtime(true) + self::SIGNAL_DEADLINE; diff --git a/tests/Integration/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index d59bf04..a4dfd37 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -24,7 +24,7 @@ // disabled-shutdown: like disabled, but it prints "ending" and ends normally: the shutdown function delivers the event. // shutdown: no signal of its own: it prints "ending" and ends, so that the shutdown function delivers the event. // outside: it prints "ready" and waits for the SIGTERM of the test, so that it also runs where a host disabled -// posix_getpid(). +// posix_getpid() or posix_kill(). $baseUrl = $argv[1] ?? ''; $mode = $argv[2] ?? ''; $markerFile = $argv[3] ?? ''; From 22f55b65ced8d703cce84c5e51022964f174a19a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:04:16 +0000 Subject: [PATCH 23/68] test: serve an MCP session through a PHP-FPM pool that disables shared-hosting functions --- tests/Runtime/FpmTest.php | 68 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/tests/Runtime/FpmTest.php b/tests/Runtime/FpmTest.php index 53b6da0..d39200e 100644 --- a/tests/Runtime/FpmTest.php +++ b/tests/Runtime/FpmTest.php @@ -35,6 +35,14 @@ final class FpmTest extends RuntimeTestCase private const string HTTP_SERVER = '/tests/E2E/Fixtures/http-server.php'; + /** A shared host's `disable_functions`: delivery (curl multi), the signal drain, time limits, the environment, the pid. */ + private const string HARDENED = 'curl_multi_exec,pcntl_signal,set_time_limit,ignore_user_abort,putenv,getmypid'; + + private const string HANDSHAKE_PROTOCOL_VERSION = '2025-06-18'; + + /** The container of the last `fpm()`. */ + private ?DockerRuntime $runtime = null; + #[Group('ac-03-1')] #[Group('ac-02-48')] #[MaximumDuration(30000)] @@ -180,12 +188,65 @@ public function testSymfonyAnswersBeforeTheShutdownDelivery(): void $this->assertGreaterThan($answer['endedAt'] + 1.0, $this->time($delivery, 'end'), 'the collector answered the delivery after the client had its answer'); } + /** + * A pool whose `disable_functions` is a shared host's: one MCP session (`initialize`, `notifications/initialized`, + * `tools/call`) answers as without AgentCat, no PHP error text reaches an answer or the workers' output, and AgentCat + * calls no disabled function. Each request of the pool's one worker logs each disabled function in one line at most + * (PHP-FPM keeps no state between requests): the curl line, once per request that queued an event, and nothing else. + */ + #[Group('hardened-host')] + #[MaximumDuration(30000)] + public function testAHardenedPoolAnswersAndLogsEachMissingFunctionOncePerRequest(): void + { + $scenario = self::$collector->scenario(); + $fpm = $this->fpm($scenario, workers: 1, disableFunctions: self::HARDENED); + $script = self::repo() . self::HTTP_SERVER; + $headers = ['Accept' => 'application/json, text/event-stream', 'X-E2E-Collector' => $scenario]; + + $initialize = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => HttpHarness::initializeParams('fpm-client', '1.0.0', self::HANDSHAKE_PROTOCOL_VERSION)], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $initialize['status'], $initialize['body']); + $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($this->answerResult($initialize), 'protocolVersion')); + $session = $initialize['headers']['mcp-session-id'] ?? ''; + $this->assertNotSame('', $session, 'initialize issued an Mcp-Session-Id'); + $headers = [...$headers, 'Mcp-Session-Id' => $session, 'MCP-Protocol-Version' => self::HANDSHAKE_PROTOCOL_VERSION]; + $initialized = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(HttpHarness::INITIALIZED, JSON_THROW_ON_ERROR)); + HttpHarness::assertInitialized($initialized['status'], $initialized['body']); + $call = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'hardened']]], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $call['status'], $call['body']); + $result = $this->answerResult($call); + $this->assertFalse(JsonAssert::at($result, 'isError')); + $content = JsonAssert::at($result, 'content'); + $this->assertIsArray($content); + $this->assertContains('echo: hardened', array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content)); + $last = $fpm->request($script, 'POST', '/mcp', $headers, json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'ping'], JSON_THROW_ON_ERROR)); + $this->assertSame(200, $last['status'], 'the one worker serves the ping only after the call\'s script, its shutdown included, ended: ' . $last['body']); + + foreach ([$initialize, $initialized, $call, $last] as $answer) { + $this->assertDoesNotMatchRegularExpression('/Warning|Notice|Deprecated|Fatal error/', $answer['body']); + } + $this->assertInstanceOf(DockerRuntime::class, $this->runtime); + $this->assertNoPhpErrors($this->runtime); + $log = $this->outFile('agentcat.log'); + $this->assertStringNotContainsString('Call to undefined function', $log); + $parts = preg_split('/^(?=\[[^\]]+\] \[[^\]]+\] AgentCat setup started)/m', $log); + $this->assertIsArray($parts); + $requests = array_values(array_filter($parts, static fn(string $part): bool => trim($part) !== '')); + $this->assertCount(4, $requests, 'each request tracks the server once: ' . $log); + foreach ($requests as $request) { + foreach (explode(',', self::HARDENED) as $function) { + $this->assertLessThanOrEqual(1, preg_match_all('/\b' . preg_quote($function, '/') . '\b/', $request), $function . ' in one line at most per request: ' . $request); + } + } + $this->assertSame(1, preg_match_all('/WARN: AgentCat cannot send events, telemetry exports or diagnostics: ext-curl functions are missing or disabled \(curl_multi_exec\)/', $requests[2]), 'the call\'s request logs that its event cannot be sent: ' . $requests[2]); + $this->assertSame([], self::$collector->requests($scenario), 'without curl_multi_exec nothing is sent'); + } + /** * PHP-FPM on 127.0.0.1 with `$workers` workers (two by default: one can drain while the other answers), which keep * the container's environment (`RuntimeTestCase::env()` with `$collector` as the AgentCat API) and get `HOME=/out`, - * as FPM gives its workers `/var/www` otherwise. + * as FPM gives its workers `/var/www` otherwise. `$disableFunctions` becomes the pool's `disable_functions`. */ - private function fpm(?string $collector = null, int $workers = 2): FastCgiClient + private function fpm(?string $collector = null, int $workers = 2, ?string $disableFunctions = null): FastCgiClient { $out = $this->out(); $port = $this->freePort(); @@ -199,9 +260,10 @@ private function fpm(?string $collector = null, int $workers = 2): FastCgiClient 'pm.max_children = ' . $workers, 'catch_workers_output = yes', 'decorate_workers_output = no', + ...($disableFunctions === null ? [] : ['php_admin_value[disable_functions] = ' . $disableFunctions]), '', ])); - DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); + $this->runtime = DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); return new FastCgiClient('127.0.0.1', $port); } From 63d105ad28edc5ef3df1ad312296e68e280197a6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:09:35 +0000 Subject: [PATCH 24/68] fix: count a repeated failure of the transport's own send and drain as one delivery --- src/Internal/Logging/RepeatedLines.php | 10 ++-- src/Internal/Transport/CurlTransport.php | 10 +++- .../Delivery/RepeatedFailureTest.php | 56 +++++++++++++++++++ .../Internal/Logging/RepeatedLinesTest.php | 6 +- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/Internal/Logging/RepeatedLines.php b/src/Internal/Logging/RepeatedLines.php index 94f363c..6f97b75 100644 --- a/src/Internal/Logging/RepeatedLines.php +++ b/src/Internal/Logging/RepeatedLines.php @@ -31,12 +31,12 @@ public static function begin(): void /** Logs `$line`, except a line the running delivery logged already: that one is counted. */ public static function write(string $line): void { - if (self::$depth > 0 && array_key_exists($line, self::$repeats)) { - ++self::$repeats[$line]; - - return; - } if (self::$depth > 0) { + if (array_key_exists($line, self::$repeats)) { + ++self::$repeats[$line]; + + return; + } self::$repeats[$line] = 0; } Log::write($line); diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index 7c31365..ba7ab4c 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -171,9 +171,15 @@ public function drain(float $deadline): bool #[\Override] public function send(Delivery $delivery, float $deadline = INF): bool { - $this->start($delivery); + RepeatedLines::begin(); // the first step in start() is a pass of the same delivery - return $this->drain($deadline); + try { + $this->start($delivery); + + return $this->drain($deadline); + } finally { + RepeatedLines::end(); + } } #[\Override] diff --git a/tests/Integration/Delivery/RepeatedFailureTest.php b/tests/Integration/Delivery/RepeatedFailureTest.php index 454338a..e945b16 100644 --- a/tests/Integration/Delivery/RepeatedFailureTest.php +++ b/tests/Integration/Delivery/RepeatedFailureTest.php @@ -9,9 +9,11 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Logging\RepeatedLines; use AgentCat\Internal\Pipeline\QueuedEvent; +use AgentCat\Internal\Transport\ApiBaseUrl; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\Delivery; use AgentCat\Internal\Transport\Transport; +use AgentCat\Internal\Wire\WireEncoder; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\MemoryLogger; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; @@ -124,6 +126,60 @@ public function testCurlTransportWorkThatThrowsOnEveryPassLogsTwoLinesPerDrain() ], $this->logger->messages()); } + /** The transport's own drain (`send()`) collapses its repeated failure too. */ + #[MaximumDuration(2000)] + public function testTheTransportsOwnDrainLogsTwoLines(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + + throw new \RuntimeException('exec boom'); + } + + return $code; + }); + $event = $this->queued()->event; + $delivery = Delivery::forEvent($event, ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), WireEncoder::encode($event)); + + $this->assertFalse($transport->send($delivery, $transport->now() + self::FLUSH_SECONDS)); + + $this->assertGreaterThan(2, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 1), + ], $this->logger->messages()); + } + + /** `drain()` on its own, after a `start()` whose first step went well, is one delivery as well. */ + #[MaximumDuration(2000)] + public function testTheTransportsDrainAloneLogsTwoLines(): void + { + $passes = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($passes): int { + if ($function === 'curl_multi_exec') { + $passes[] = $code; + if (count($passes) > 1) { + throw new \RuntimeException('exec boom'); + } + } + + return $code; + }); + $event = $this->queued()->event; + $transport->start(Delivery::forEvent($event, ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), WireEncoder::encode($event))); + $this->assertSame([], $this->logger->messages(), 'the first step went well'); + + $this->assertFalse($transport->drain($transport->now() + self::FLUSH_SECONDS)); + + $this->assertGreaterThan(3, count($passes), 'the drain threw on many passes'); + $this->assertSame([ + self::GAVE_UP . 'RuntimeException: exec boom', + sprintf('%sRuntimeException: exec boom (%d more times in this delivery)', self::GAVE_UP, count($passes) - 2), + ], $this->logger->messages()); + } + private function queued(): QueuedEvent { return new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'repeated')); diff --git a/tests/Unit/Internal/Logging/RepeatedLinesTest.php b/tests/Unit/Internal/Logging/RepeatedLinesTest.php index b171e12..784ef1a 100644 --- a/tests/Unit/Internal/Logging/RepeatedLinesTest.php +++ b/tests/Unit/Internal/Logging/RepeatedLinesTest.php @@ -82,7 +82,11 @@ public function testAnEndWithoutABeginChangesNothing(): void RepeatedLines::end(); RepeatedLines::write('Failed: boom'); RepeatedLines::write('Failed: boom'); + RepeatedLines::begin(); + RepeatedLines::write('Failed: next'); + RepeatedLines::write('Failed: next'); + RepeatedLines::end(); - $this->assertSame(['Failed: boom', 'Failed: boom'], $this->logger->messages()); + $this->assertSame(['Failed: boom', 'Failed: boom', 'Failed: next', 'Failed: next (1 more times in this delivery)'], $this->logger->messages()); } } From 309a44234b7d76b4751aa7cf4104045573eddd78 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:37:57 +0000 Subject: [PATCH 25/68] ci: run every PHPStan configuration, the lowest set, a package probe and the BC check --- .gitattributes | 2 + .github/workflows/ci.yml | 65 +++++++- phpstan.neon.dist | 3 + .../Fixtures/package-probe/composer.json.tpl | 25 +++ tests/Fixtures/package-probe/probe.php | 149 ++++++++++++++++++ tests/bin/package-probe.sh | 79 ++++++++++ 6 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/package-probe/composer.json.tpl create mode 100644 tests/Fixtures/package-probe/probe.php create mode 100755 tests/bin/package-probe.sh diff --git a/.gitattributes b/.gitattributes index a5a3a9e..6df40bd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,5 @@ /docs export-ignore /recipe export-ignore /composer-dependency-analyser.php export-ignore +/phpstan.php85.neon export-ignore +/CONTRIBUTING.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f85352..c712a46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,28 @@ jobs: - run: composer normalize --dry-run - run: vendor/bin/php-cs-fixer check --diff - run: vendor/bin/rector process --dry-run - - run: vendor/bin/phpstan analyse --no-progress --error-format=github + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon - run: vendor/bin/composer-dependency-analyser - run: composer audit + static-lowest: + name: Static analysis (lowest dependencies) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: lowest + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.php85.neon + tests: name: Tests (PHP ${{ matrix.php }}, ${{ matrix.deps }}) runs-on: ubuntu-latest @@ -73,6 +91,8 @@ jobs: - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 with: dependency-versions: highest + # ergebnis/composer-normalize caps PHP below 8.6. + composer-options: --ignore-platform-req=php+ - run: vendor/bin/phpunit coverage: @@ -177,10 +197,51 @@ jobs: env: AGENTCAT_RUNTIME_TESTS: '1' + package: + name: Package probe (PHP 8.3, mcp/sdk ${{ matrix.mcp-sdk }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + mcp-sdk: ['0.8.1', '0.9.0'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + run: tests/bin/package-probe.sh "$MCP_SDK" + env: + MCP_SDK: ${{ matrix.mcp-sdk }} + + bc: + name: Backward compatibility + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + extensions: bcmath + coverage: none + - name: Compare with the latest 1.x tag + run: | + base="$(git tag --list 'v1.*' --sort=-v:refname | head -n 1)" + if [ -z "$base" ]; then echo "No v1.* tag yet: nothing to compare against (D63)."; exit 0; fi + composer global require --no-interaction roave/backward-compatibility-check:8.19.0 + "$(composer global config bin-dir --absolute)/roave-backward-compatibility-check" --from="$base" --format=github-actions --install-development-dependencies + all-green: name: All green if: always() - needs: [static, tests, coverage, mutation, mcp-sdk-0-9, runtime] + needs: [static, static-lowest, tests, coverage, mutation, mcp-sdk-0-9, runtime, package, bc] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b7b587d..47d07a5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,9 @@ includes: parameters: level: max paths: [src, tests] + excludePaths: + # Runs in a fresh project with its own vendor/ (tests/bin/package-probe.sh), not in this repository. + - tests/Fixtures/package-probe tmpDir: .cache/phpstan checkMissingCallableSignature: true checkUninitializedProperties: true diff --git a/tests/Fixtures/package-probe/composer.json.tpl b/tests/Fixtures/package-probe/composer.json.tpl new file mode 100644 index 0000000..1d63d8b --- /dev/null +++ b/tests/Fixtures/package-probe/composer.json.tpl @@ -0,0 +1,25 @@ +{ + "name": "agentcat/package-probe", + "description": "A fresh project that installs agentcat/sdk from its dist, as a customer does.", + "type": "project", + "license": "proprietary", + "repositories": [ + { + "type": "path", + "url": "__DIST__", + "options": { + "symlink": false + } + } + ], + "require": { + "agentcat/sdk": "*@dev", + "mcp/sdk": "__MCP_SDK__" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": {}, + "sort-packages": true + } +} diff --git a/tests/Fixtures/package-probe/probe.php b/tests/Fixtures/package-probe/probe.php new file mode 100644 index 0000000..b08e842 --- /dev/null +++ b/tests/Fixtures/package-probe/probe.php @@ -0,0 +1,149 @@ +`. + * + * Environment: AGENTCAT_PROBE_COLLECTOR_LOG, the file where the collector appends one JSON line per request. + */ + +use Composer\InstalledVersions; + +require __DIR__ . '/vendor/autoload.php'; + +const PROBE_TIMEOUT_SECONDS = 20.0; + +function fail(string $message): never +{ + fwrite(STDERR, "probe failed: {$message}\n"); + + exit(1); +} + +$logFile = getenv('AGENTCAT_PROBE_COLLECTOR_LOG'); +if (!is_string($logFile) || $logFile === '') { + fail('AGENTCAT_PROBE_COLLECTOR_LOG is not set'); +} + +$serverScript = __DIR__ . '/probe-server.php'; +file_put_contents($serverScript, <<<'PHP' + setServerInfo('package-probe-server', '1.0.0') + ->addTool( + static fn(string $msg): string => 'echo: ' . $msg, + 'echo', + description: 'Echo a message', + inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']], + ); + + AgentCat::track($builder, 'proj_' . str_repeat('p', 27)); + + exit($builder->build()->run(new StdioTransport())); + PHP); + +$process = proc_open( + [PHP_BINARY, $serverScript], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + __DIR__, +); +if (!is_resource($process)) { + fail('the server did not start'); +} + +$deadline = microtime(true) + PROBE_TIMEOUT_SECONDS; +$buffer = ''; + +/** Reads stdout until the answer to request $id arrives. */ +$receive = static function (int $id) use (&$pipes, &$buffer, $deadline): stdClass { + while (true) { + while (($newline = strpos($buffer, "\n")) !== false) { + $line = substr($buffer, 0, $newline); + $buffer = substr($buffer, $newline + 1); + $message = json_decode($line); + if ($message instanceof stdClass && ($message->id ?? null) === $id) { + return $message; + } + } + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + fail("no answer to request {$id} before the deadline"); + } + $read = [$pipes[1]]; + $write = null; + $except = null; + if (stream_select($read, $write, $except, 0, (int) min(500_000, $remaining * 1_000_000)) === 1) { + $chunk = fread($pipes[1], 65536); + if ($chunk === '' || $chunk === false) { + fail('the server closed stdout before it answered request ' . $id); + } + $buffer .= $chunk; + } + } +}; +$send = static function (array $message) use (&$pipes): void { + fwrite($pipes[0], json_encode($message, JSON_THROW_ON_ERROR) . "\n"); +}; + +$send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'package-probe-client', 'version' => '1.0.0'], +]]); +$initialize = $receive(1); +if (!isset($initialize->result)) { + fail('initialize did not return a result: ' . json_encode($initialize)); +} +$send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); +$send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'probe']]]); +$call = $receive(2); +// The first call of a session also carries AgentCat's session_id block, so look for the tool's own block. +$texts = array_map(static fn(mixed $block): mixed => $block->text ?? null, $call->result->content ?? []); +if (!in_array('echo: probe', $texts, true)) { + fail('tools/call did not answer "echo: probe": ' . json_encode($call)); +} + +// stdin EOF ends the server; AgentCat delivers the queued event at shutdown. +fclose($pipes[0]); +$status = null; +while (($status = proc_get_status($process))['running']) { + if (microtime(true) > $deadline) { + proc_terminate($process); + fail('the server did not exit after stdin closed'); + } + usleep(20_000); +} +$stderr = stream_get_contents($pipes[2]); +proc_close($process); + +$events = []; +foreach (is_file($logFile) ? (file($logFile, FILE_IGNORE_NEW_LINES) ?: []) : [] as $line) { + $request = json_decode($line, true); + $body = is_array($request) && is_string($request['body'] ?? null) ? json_decode($request['body'], true) : null; + if (is_array($body) && isset($body['resource_name'])) { + $events[] = $body; + } +} +if (count($events) !== 1) { + fail('the collector received ' . count($events) . " events, expected 1 (server stderr: {$stderr})"); +} +if ($events[0]['resource_name'] !== 'echo') { + fail('the event is not for the echo tool: ' . json_encode($events[0])); +} + +$version = InstalledVersions::getPrettyVersion('mcp/sdk') ?? 'unknown'; +echo "probe ok {$version}\n"; diff --git a/tests/bin/package-probe.sh b/tests/bin/package-probe.sh new file mode 100755 index 0000000..9b8f45c --- /dev/null +++ b/tests/bin/package-probe.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# The package probe: installs agentcat/sdk from its dist in a fresh project, as a customer does, and runs one +# tools/call through a tracked mcp/sdk stdio server. The dist is `git archive HEAD`, so it honors export-ignore like +# Packagist's zip; commit before you run it. +# Usage: tests/bin/package-probe.sh (for example 0.8.1, 0.9.0 or "dev-main as 0.9.99") +# Everything stays under .cache/package-probe/, and the probe reaches no host but 127.0.0.1. +set -euo pipefail +constraint="${1:?usage: package-probe.sh }" +repo="$(cd "$(dirname "$0")/../.." && pwd)" +root="$repo/.cache/package-probe" +dist="$root/dist" +project="$root/project" +home="$root/home" +collector_pid='' +cleanup() { + if [ -n "$collector_pid" ]; then kill "$collector_pid" 2>/dev/null || true; fi +} +trap cleanup EXIT + +rm -rf "$root" && mkdir -p "$dist" "$project" "$home" + +# 1. The dist. +cd "$repo" +git archive --format=tar HEAD | tar -x -C "$dist" + +# 2. The dist file list. +fail=0 +require() { if [ ! -e "$dist/$1" ]; then echo "dist: missing $1" >&2; fail=1; fi; } +forbid() { if compgen -G "$dist/$1" >/dev/null; then echo "dist: must not contain $1" >&2; fail=1; fi; } +for path in src config composer.json README.md LICENSE; do require "$path"; done +# TODO(Task 13): CHANGELOG.md and resources/boost/guidelines/core.blade.php are expected only while git tracks them. +# Task 13 makes both strict: delete the git ls-files conditions below, or set PROBE_STRICT_DIST=1 to try it now. +for path in CHANGELOG.md resources/boost/guidelines/core.blade.php; do + if [ "${PROBE_STRICT_DIST:-}" = 1 ] || [ -n "$(git ls-files -- "$path")" ]; then require "$path"; fi +done +for path in tests docs recipe .github 'phpstan*.neon*' rector.php infection.json5 phpunit.xml.dist composer-dependency-analyser.php; do + forbid "$path" +done +[ "$fail" = 0 ] || exit 1 + +# 3. A fresh project. The probe files are copied in: /tests is export-ignored, so the dist does not hold them. +sed -e "s#__DIST__#$dist#" -e "s#__MCP_SDK__#$constraint#" tests/Fixtures/package-probe/composer.json.tpl >"$project/composer.json" +cp tests/Fixtures/package-probe/probe.php "$project/probe.php" +(cd "$project" && composer install --no-interaction --no-progress) + +# 4. A local collector: php -S with a router that appends each request to a file. +cat >"$root/router.php" <<'PHP' + $_SERVER['REQUEST_METHOD'], 'uri' => $_SERVER['REQUEST_URI'], 'body' => file_get_contents('php://input')]) . "\n", + FILE_APPEND | LOCK_EX, +); +http_response_code(202); +header('Content-Type: application/json'); +echo '{"status":"accepted"}'; +PHP +free_port() { php -r '$s = stream_socket_server("tcp://127.0.0.1:0"); echo parse_url(stream_socket_get_name($s, false), PHP_URL_PORT);'; } +log="$root/collector.log" +: >"$log" +port="$(free_port)" +AGENTCAT_PROBE_COLLECTOR_LOG="$log" php -S "127.0.0.1:$port" "$root/router.php" >"$root/collector.out" 2>&1 & +collector_pid=$! +for _ in $(seq 1 50); do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then break; fi + sleep 0.1 +done +closed_port="$(free_port)" # nothing listens on it + +# 5. The probe, with every URL local and a fresh HOME. +cd "$project" +HOME="$home" \ +AGENTCAT_API_URL="http://127.0.0.1:$port" \ +MCPCAT_API_URL="http://127.0.0.1:$port" \ +AGENTCAT_DISABLE_DIAGNOSTICS=true \ +AGENTCAT_DIAGNOSTICS_ENDPOINT="http://127.0.0.1:$closed_port" \ +AGENTCAT_PROBE_COLLECTOR_LOG="$log" \ + timeout 60 php probe.php From 292bfbdbcd8f123f34bbfc4cf9c77804e6fde1a0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 14:56:17 +0000 Subject: [PATCH 26/68] ci: type the package probe for PHPStan, close the probe's proxy and collector gaps, and accept PHP 8.6's cURL option message --- .github/workflows/ci.yml | 1 + phpstan.neon.dist | 10 +++-- tests/Fixtures/package-probe/probe.php | 45 +++++++++++-------- .../Transport/CurlHttpChannelTest.php | 8 +++- .../Transport/CurlTransportTest.php | 9 ++-- tests/bin/package-probe.sh | 16 +++++-- 6 files changed, 60 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c712a46..e7a293e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,6 +221,7 @@ jobs: bc: name: Backward compatibility runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 47d07a5..aaab92e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,9 +3,6 @@ includes: parameters: level: max paths: [src, tests] - excludePaths: - # Runs in a fresh project with its own vendor/ (tests/bin/package-probe.sh), not in this repository. - - tests/Fixtures/package-probe tmpDir: .cache/phpstan checkMissingCallableSignature: true checkUninitializedProperties: true @@ -18,6 +15,13 @@ parameters: # analysed), and it has no Eloquent models. noEnvCallsOutsideOfConfig: false checkModelProperties: false + ignoreErrors: + - + # The probe runs in a fresh project (tests/bin/package-probe.sh) whose vendor/ does not exist in this repository. + message: '#^Path in require\(\) .* is not a file or it does not exist\.$#' + identifier: require.fileNotFound + path: tests/Fixtures/package-probe/probe.php + count: 1 type_coverage: return: 100 param: 100 diff --git a/tests/Fixtures/package-probe/probe.php b/tests/Fixtures/package-probe/probe.php index b08e842..8d58bd9 100644 --- a/tests/Fixtures/package-probe/probe.php +++ b/tests/Fixtures/package-probe/probe.php @@ -55,21 +55,21 @@ function fail(string $message): never exit($builder->build()->run(new StdioTransport())); PHP); -$process = proc_open( - [PHP_BINARY, $serverScript], - [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - __DIR__, -); +$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$process = proc_open([PHP_BINARY, $serverScript], $descriptors, $pipes, __DIR__); if (!is_resource($process)) { fail('the server did not start'); } +[$stdin, $stdout, $stderrPipe] = [$pipes[0] ?? null, $pipes[1] ?? null, $pipes[2] ?? null]; +if (!is_resource($stdin) || !is_resource($stdout) || !is_resource($stderrPipe)) { + fail('the server pipes did not open'); +} $deadline = microtime(true) + PROBE_TIMEOUT_SECONDS; $buffer = ''; /** Reads stdout until the answer to request $id arrives. */ -$receive = static function (int $id) use (&$pipes, &$buffer, $deadline): stdClass { +$receive = static function (int $id) use ($stdout, &$buffer, $deadline): stdClass { while (true) { while (($newline = strpos($buffer, "\n")) !== false) { $line = substr($buffer, 0, $newline); @@ -83,11 +83,11 @@ function fail(string $message): never if ($remaining <= 0) { fail("no answer to request {$id} before the deadline"); } - $read = [$pipes[1]]; + $read = [$stdout]; $write = null; $except = null; if (stream_select($read, $write, $except, 0, (int) min(500_000, $remaining * 1_000_000)) === 1) { - $chunk = fread($pipes[1], 65536); + $chunk = fread($stdout, 65536); if ($chunk === '' || $chunk === false) { fail('the server closed stdout before it answered request ' . $id); } @@ -95,8 +95,9 @@ function fail(string $message): never } } }; -$send = static function (array $message) use (&$pipes): void { - fwrite($pipes[0], json_encode($message, JSON_THROW_ON_ERROR) . "\n"); +/** @param array $message */ +$send = static function (array $message) use ($stdin): void { + fwrite($stdin, json_encode($message, JSON_THROW_ON_ERROR) . "\n"); }; $send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ @@ -112,28 +113,36 @@ function fail(string $message): never $send(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['msg' => 'probe']]]); $call = $receive(2); // The first call of a session also carries AgentCat's session_id block, so look for the tool's own block. -$texts = array_map(static fn(mixed $block): mixed => $block->text ?? null, $call->result->content ?? []); +$result = $call->result ?? null; +$blocks = $result instanceof stdClass && is_array($result->content ?? null) ? $result->content : []; +$texts = []; +foreach ($blocks as $block) { + if ($block instanceof stdClass && is_string($block->text ?? null)) { + $texts[] = $block->text; + } +} if (!in_array('echo: probe', $texts, true)) { fail('tools/call did not answer "echo: probe": ' . json_encode($call)); } // stdin EOF ends the server; AgentCat delivers the queued event at shutdown. -fclose($pipes[0]); -$status = null; -while (($status = proc_get_status($process))['running']) { +fclose($stdin); +while (proc_get_status($process)['running']) { if (microtime(true) > $deadline) { proc_terminate($process); fail('the server did not exit after stdin closed'); } usleep(20_000); } -$stderr = stream_get_contents($pipes[2]); +$stderr = stream_get_contents($stderrPipe); proc_close($process); $events = []; -foreach (is_file($logFile) ? (file($logFile, FILE_IGNORE_NEW_LINES) ?: []) : [] as $line) { +$lines = is_file($logFile) ? file($logFile, FILE_IGNORE_NEW_LINES) : false; +foreach ($lines === false ? [] : $lines as $line) { $request = json_decode($line, true); - $body = is_array($request) && is_string($request['body'] ?? null) ? json_decode($request['body'], true) : null; + $rawBody = is_array($request) ? ($request['body'] ?? null) : null; + $body = is_string($rawBody) ? json_decode($rawBody, true) : null; if (is_array($body) && isset($body['resource_name'])) { $events[] = $body; } diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index f5914f9..3f6c425 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -461,8 +461,12 @@ public function testARequestCurlCannotSetUpFailsThroughItsCallback(): void $this->assertSame(1, $channel->inTransfer(), 'the next request started in the same pass'); $this->assertTrue($this->drain($channel, 2.0)); $this->assertCount(3, $responses); - $nul = 'ValueError: curl_setopt_array(): cURL option must not contain any null bytes'; - $this->assertSame([$nul, $nul, null], array_map(static fn(HttpResponse $response): ?string => $response->error, $responses)); + $errors = array_map(static fn(HttpResponse $response): ?string => $response->error, $responses); + // PHP 8.6 names the option ("cURL option CURLOPT_URL must not …"); 8.3 to 8.5 do not. + $nul = '~^ValueError: curl_setopt_array\(\): cURL option .*must not contain any null bytes$~'; + $this->assertMatchesRegularExpression($nul, (string) $errors[0]); + $this->assertMatchesRegularExpression($nul, (string) $errors[1]); + $this->assertNull($errors[2]); $this->assertSame(202, $responses[2]->status); $this->assertCount(1, $this->collector()->requests($url)); } diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index 1ba7ad7..4ef8df9 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -413,9 +413,12 @@ public function testAnInvalidUrlDropsTheDeliveryWithOneLine(): void $transport->start(new Delivery("http://127.0.0.1/\0", '{}', 'evt_x', 'summary')); $this->assertSame(0, $transport->inFlight()); - $this->assertSame( - [self::GAVE_UP . 'ValueError: curl_setopt_array(): cURL option must not contain any null bytes'], - $this->logger->messages(), + $messages = $this->logger->messages(); + $this->assertCount(1, $messages); + // PHP 8.6 names the option ("cURL option CURLOPT_URL must not …"); 8.3 to 8.5 do not. + $this->assertMatchesRegularExpression( + '~^' . preg_quote(self::GAVE_UP . 'ValueError: curl_setopt_array(): cURL option ', '~') . '.*must not contain any null bytes$~', + $messages[0], ); } diff --git a/tests/bin/package-probe.sh b/tests/bin/package-probe.sh index 9b8f45c..9fa9e3b 100755 --- a/tests/bin/package-probe.sh +++ b/tests/bin/package-probe.sh @@ -17,6 +17,10 @@ cleanup() { } trap cleanup EXIT +# The collector and the probe talk to 127.0.0.1 only: no proxy may intercept them. +unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY +export no_proxy=127.0.0.1,localhost NO_PROXY=127.0.0.1,localhost + rm -rf "$root" && mkdir -p "$dist" "$project" "$home" # 1. The dist. @@ -28,8 +32,8 @@ fail=0 require() { if [ ! -e "$dist/$1" ]; then echo "dist: missing $1" >&2; fail=1; fi; } forbid() { if compgen -G "$dist/$1" >/dev/null; then echo "dist: must not contain $1" >&2; fail=1; fi; } for path in src config composer.json README.md LICENSE; do require "$path"; done -# TODO(Task 13): CHANGELOG.md and resources/boost/guidelines/core.blade.php are expected only while git tracks them. -# Task 13 makes both strict: delete the git ls-files conditions below, or set PROBE_STRICT_DIST=1 to try it now. +# CHANGELOG.md and the Boost guideline (resources/boost/guidelines/core.blade.php) are required once git tracks them. +# Set PROBE_STRICT_DIST=1 to require both whatever git tracks. for path in CHANGELOG.md resources/boost/guidelines/core.blade.php; do if [ "${PROBE_STRICT_DIST:-}" = 1 ] || [ -n "$(git ls-files -- "$path")" ]; then require "$path"; fi done @@ -62,10 +66,16 @@ log="$root/collector.log" port="$(free_port)" AGENTCAT_PROBE_COLLECTOR_LOG="$log" php -S "127.0.0.1:$port" "$root/router.php" >"$root/collector.out" 2>&1 & collector_pid=$! +listening=0 for _ in $(seq 1 50); do - if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then break; fi + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then listening=1; break; fi sleep 0.1 done +if [ "$listening" = 0 ]; then + echo "collector: nothing accepted a connection on 127.0.0.1:$port within 5 s" >&2 + cat "$root/collector.out" >&2 + exit 1 +fi closed_port="$(free_port)" # nothing listens on it # 5. The probe, with every URL local and a fresh HOME. From 6c9d0ffc3457c01c7dbea871cf13fab254c7afea Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 16:12:50 +0000 Subject: [PATCH 27/68] test: fail on src/ diagnostics that framework handlers hide Laravel's HandleExceptions logs a deprecation and turns any other issue into an exception that a request answers with an error, so a notice, warning or deprecation that a file of src/ raises during a request never reaches PHPUnit. After each Laravel application and Symfony kernel of the bridge test cases boots, SrcErrorGuard puts a handler in front of the framework's: it records such an issue with its file:line and still hands it to the framework's handler. tearDown() puts the handler stack back and fails the test with every recorded issue. The warm-up shares the guard's rule for what counts as an issue of src/. --- tests/Support/LaravelMcp/LaravelTestCase.php | 20 ++- tests/Support/SrcErrorGuard.php | 118 ++++++++++++++++++ tests/Support/Symfony/SymfonyTestCase.php | 14 ++- tests/Support/WarmUp.php | 19 ++- tests/Unit/SrcErrorGuardTest.php | 122 +++++++++++++++++++ 5 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 tests/Support/SrcErrorGuard.php create mode 100644 tests/Unit/SrcErrorGuardTest.php diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index d344890..9bc5ff7 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -13,6 +13,8 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; +use AgentCat\Tests\Support\SrcErrorGuard; use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; use Illuminate\Foundation\Application; @@ -23,7 +25,9 @@ * A Laravel application with laravel/mcp, for the adapter and bridge tests. `app.debug` is false, like production. * Every file Laravel writes (the package and service manifests, the caches, storage, the log, the environment file * and published configuration) goes to a directory of this test under the test HOME, removed after the test: none - * goes into vendor/ (Ruling R8). + * goes into vendor/ (Ruling R8). A notice, warning or deprecation from a file of `src/` that Laravel's error handler + * takes (it logs a deprecation and turns any other issue into an exception, which a request answers with an error) + * fails the test (`SrcErrorGuard`). */ abstract class LaravelTestCase extends TestCase { @@ -45,6 +49,9 @@ abstract class LaravelTestCase extends TestCase private ?Environment $environment = null; + /** The error and exception handler stacks before the application, put back after the test. */ + private ?GlobalHandlers $handlers = null; + /** * The first Laravel application of a PHP process is slow: PHP compiles the framework's files then (about 0.07 s on * PHP 8.5, up to 0.3 s on a loaded host, against 0.01 s for each later one). So, once per process and before the @@ -86,6 +93,7 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + $this->handlers = GlobalHandlers::save(); $home = getenv('HOME'); if (!is_string($home) || $home === '') { throw new \LogicException('tests/bootstrap.php sets HOME to the test HOME'); @@ -105,6 +113,14 @@ protected function setUp(): void parent::setUp(); } + /** Each new application (`setUp()`, or a test's own refresh) installs its error handler: the guard goes in front. */ + #[\Override] + protected function refreshApplication(): void + { + parent::refreshApplication(); + SrcErrorGuard::install(); + } + /** @return list */ #[\Override] protected function getPackageProviders(mixed $app): array @@ -147,5 +163,7 @@ protected function tearDown(): void parent::tearDown(); $this->environment?->restore(); FileTree::remove($this->laravelDirectory); + $this->handlers?->restore(); // Laravel's flushState() emptied both stacks and put PHPUnit's handler back + SrcErrorGuard::assertNone($this); } } diff --git a/tests/Support/SrcErrorGuard.php b/tests/Support/SrcErrorGuard.php new file mode 100644 index 0000000..15f0ce8 --- /dev/null +++ b/tests/Support/SrcErrorGuard.php @@ -0,0 +1,118 @@ + 'E_NOTICE', + E_USER_NOTICE => 'E_USER_NOTICE', + E_WARNING => 'E_WARNING', + E_USER_WARNING => 'E_USER_WARNING', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + ]; + + /** @var list each recorded issue: its level, message and `file:line` */ + private static array $recorded = []; + + /** @var list<\Closure(int, string, string, int): bool> the guards installed since the last `assertNone()` */ + private static array $guards = []; + + /** + * Puts a guard in front of the error handler in place, unless a guard is already in front. The guard calls that + * handler itself: a handler that returns false hands the issue to PHP's own handling, not to the one below it. + * + * @param string|null $source the directory whose files' issues are recorded; null: the repository's `src/` + */ + public static function install(?string $source = null): void + { + $inPlace = self::inPlace(); + if ($inPlace !== null && in_array($inPlace, self::$guards, true)) { + return; // a second kernel of the same test: no framework handler came in front of the guard + } + $source ??= self::source(); + $guard = static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { + if (self::fromSource($level, $file, $source)) { + self::$recorded[] = sprintf('%s: %s at %s:%d', self::LEVELS[$level] ?? (string) $level, $message, $file, $line); + } + + return self::forward($inPlace, $level, $message, $file, $line); + }; + self::$guards[] = $guard; + set_error_handler($guard); + } + + /** Fails with every recorded issue; counts as one assertion. It forgets the issues and the guards either way. */ + public static function assertNone(TestCase $test): void + { + $recorded = self::$recorded; + self::$recorded = []; + self::$guards = []; + $test::assertCount(0, $recorded, sprintf("A file of src/ raised %d issue(s) that a framework's error handler took:\n%s", count($recorded), implode("\n", $recorded))); + } + + /** The repository's `src/`, with a trailing slash. */ + public static function source(): string + { + return dirname(__DIR__, 2) . '/src/'; + } + + /** + * Whether an issue is a notice, warning or deprecation, not suppressed with `@`, from a file under `$source` (both + * compared by their real paths). + */ + public static function fromSource(int $level, string $file, string $source): bool + { + if (($level & self::ISSUES) === 0 || (error_reporting() & ~self::INSUPPRESSIBLE) === 0) { + return false; + } + $path = realpath($file); + $directory = realpath($source); + + return str_starts_with($path === false ? $file : $path, ($directory === false ? rtrim($source, '/') : $directory) . '/'); + } + + /** + * The error handler in place (PHP 8.3 has no `get_error_handler()`). + * + * @return (callable(int, string, string, int): bool)|null + */ + public static function inPlace(): ?callable + { + $inPlace = set_error_handler(static fn(): bool => false); + restore_error_handler(); + + return $inPlace; + } + + /** + * Hands an issue to the handler that was in place, as PHP would have: true when it took the issue, false (PHP's own + * handling) when it declined it or there was none. + * + * @param (callable(int, string, string, int): bool)|null $inPlace + */ + public static function forward(?callable $inPlace, int $level, string $message, string $file, int $line): bool + { + return $inPlace !== null && $inPlace($level, $message, $file, $line) !== false; + } +} diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index ef91704..4420eb4 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -15,7 +15,9 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\RecordingTransport; +use AgentCat\Tests\Support\SrcErrorGuard; use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; @@ -29,7 +31,8 @@ * Symfony applications with symfony/mcp-bundle, for the bundle tests. Skipped where the bundle is not installed (the * mcp/sdk 0.9 leg removes it), unless a test class needs no symfony/mcp-bundle (`needsTheMcpBundle()`). Each kernel compiles its container into a directory of this test under the test HOME, * removed after the test (Ruling R8). The AGENTCAT_* variables of the process that runs the tests are cleared, so the - * configuration decides, and the global event queue records its deliveries. + * configuration decides, and the global event queue records its deliveries. A notice, warning or deprecation from a + * file of `src/` that an error handler of the kernel takes fails the test (`SrcErrorGuard`). */ abstract class SymfonyTestCase extends TestCase { @@ -46,6 +49,9 @@ abstract class SymfonyTestCase extends TestCase private ?Environment $environment = null; + /** The error and exception handler stacks before the first kernel, put back after the test. */ + private ?GlobalHandlers $handlers = null; + /** Whether this process ran its warm-up kernel (`setUpBeforeClass()`). */ private static bool $warm = false; @@ -92,6 +98,7 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + $this->handlers = GlobalHandlers::save(); if ($this->needsTheMcpBundle() && !class_exists(McpBundle::class)) { self::markTestSkipped('symfony/mcp-bundle is not installed'); } @@ -133,6 +140,8 @@ protected function tearDown(): void $this->environment?->restore(); FileTree::remove($this->directory); + $this->handlers?->restore(); // the guard goes, and any handler that a kernel left + SrcErrorGuard::assertNone($this); } /** @@ -155,7 +164,7 @@ protected function kernel(array $agentcat = [], array $services = [], bool $with return $kernel; } - /** Boots a kernel of this test, which shuts it down after the test. */ + /** Boots a kernel of this test, which shuts it down after the test. The guard goes in front of its error handler. */ protected function boot(KernelInterface $kernel): void { $this->kernels[] = $kernel; @@ -164,6 +173,7 @@ protected function boot(KernelInterface $kernel): void if ($this->exceptionHandler() !== $handler) { restore_exception_handler(); // FrameworkBundle::boot() leaves Symfony's handler with symfony/error-handler 7.3 } + SrcErrorGuard::install(); } /** This test's directory under the test HOME, removed after the test. */ diff --git a/tests/Support/WarmUp.php b/tests/Support/WarmUp.php index 0762cb4..c6f05c8 100644 --- a/tests/Support/WarmUp.php +++ b/tests/Support/WarmUp.php @@ -10,32 +10,27 @@ * `src/`, so a notice, warning or deprecation that PHP raises once per file (when it includes or compiles it) would * reach no test. Such an issue from a file under the source directory is thrown instead, out of `setUpBeforeClass()`: * every test of the class errors, and the suite fails. Any other issue (from vendor/, or suppressed with `@`) goes to - * the handler in place, as without the guard. The handler stacks are put back afterwards, also after a throw. + * the handler in place, as without the guard. An issue that reaches a framework's own handler is recorded by the + * warm-up's `SrcErrorGuard` and fails its `tearDown()` the same way. The handler stacks are put back afterwards, also + * after a throw. */ final class WarmUp { - /** The issues that fail the warm-up. */ - private const int ISSUES = E_NOTICE | E_USER_NOTICE | E_WARNING | E_USER_WARNING | E_DEPRECATED | E_USER_DEPRECATED; - - /** What `@` leaves of `error_reporting()`: with no other level, the issue is suppressed (PHPUnit's own test). */ - private const int INSUPPRESSIBLE = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR; - /** * @param \Closure(): void $warmUp the test case's lifecycle * @param string|null $source the directory whose files' issues fail the warm-up; null: the repository's `src/` */ public static function run(\Closure $warmUp, ?string $source = null): void { - $source ??= dirname(__DIR__, 2) . '/src/'; + $source ??= SrcErrorGuard::source(); $handlers = GlobalHandlers::save(); // the frameworks change them, and no test puts them back - $inPlace = set_error_handler(static fn(): bool => false); - restore_error_handler(); + $inPlace = SrcErrorGuard::inPlace(); set_error_handler(static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { - if (($level & self::ISSUES) !== 0 && (error_reporting() & ~self::INSUPPRESSIBLE) !== 0 && str_starts_with($file, $source)) { + if (SrcErrorGuard::fromSource($level, $file, $source)) { throw new \ErrorException(sprintf('%s (raised from %s:%d during a test case warm-up)', $message, $file, $line), 0, $level, $file, $line); } - return $inPlace !== null && $inPlace($level, $message, $file, $line) !== false; // false: PHP's own handling + return SrcErrorGuard::forward($inPlace, $level, $message, $file, $line); // false: PHP's own handling }); try { $warmUp(); diff --git a/tests/Unit/SrcErrorGuardTest.php b/tests/Unit/SrcErrorGuardTest.php new file mode 100644 index 0000000..ccda0ed --- /dev/null +++ b/tests/Unit/SrcErrorGuardTest.php @@ -0,0 +1,122 @@ + the messages that the framework's handler took */ + private \ArrayObject $framework; + + #[\Override] + protected function setUp(): void + { + $this->handlers = GlobalHandlers::save(); + $framework = $this->framework = new \ArrayObject(); + set_error_handler(static function (int $level, string $message) use ($framework): bool { + $framework->append($message); + + return true; + }); + } + + #[\Override] + protected function tearDown(): void + { + $this->handlers->restore(); + } + + /** @return iterable */ + public static function issues(): iterable + { + yield 'a deprecation' => [E_USER_DEPRECATED, 'E_USER_DEPRECATED']; + yield 'a notice' => [E_USER_NOTICE, 'E_USER_NOTICE']; + yield 'a warning' => [E_USER_WARNING, 'E_USER_WARNING']; + } + + /** The framework's handler still takes the issue; `assertNone()` then fails with it and its `file:line`, once. */ + #[DataProvider('issues')] + public function testAnIssueFromASourceFileFailsTheTest(int $level, string $name): void + { + SrcErrorGuard::install(__DIR__ . '/'); + trigger_error('raised by src', $level); + $line = __LINE__ - 1; + $this->handlers->restore(); + + $failure = $this->failureOfAssertNone(); + + $this->assertSame(['raised by src'], $this->framework->getArrayCopy()); + $this->assertSame("A file of src/ raised 1 issue(s) that a framework's error handler took:\n" . $name . ': raised by src at ' . __FILE__ . ':' . $line . "\nFailed asserting that actual size 1 matches expected size 0.", $failure); + SrcErrorGuard::assertNone($this); // the issue is forgotten: the next test starts clean + } + + /** @return iterable the source directory, and whether the issue is suppressed */ + public static function otherIssues(): iterable + { + yield 'from another file, as vendor/' => ['/nonexistent/src/', false]; + yield 'from a source file, suppressed with @' => [__DIR__ . '/', true]; + } + + /** Any other issue goes to the framework's handler alone. */ + #[DataProvider('otherIssues')] + public function testAnyOtherIssueIsNotRecorded(string $source, bool $suppressed): void + { + SrcErrorGuard::install($source); + $suppressed ? @trigger_error('not ours', E_USER_DEPRECATED) : trigger_error('not ours', E_USER_DEPRECATED); + $this->handlers->restore(); + + SrcErrorGuard::assertNone($this); + $this->assertSame(['not ours'], $this->framework->getArrayCopy()); + } + + /** A second kernel of a test installs nothing while the guard is in front: each issue is recorded once. */ + public function testAGuardInFrontIsNotInstalledAgain(): void + { + SrcErrorGuard::install(__DIR__ . '/'); + $guard = SrcErrorGuard::inPlace(); + SrcErrorGuard::install(__DIR__ . '/'); + $this->assertSame($guard, SrcErrorGuard::inPlace()); + trigger_error('raised by src', E_USER_DEPRECATED); + $this->handlers->restore(); + + $failure = $this->failureOfAssertNone(); + + $this->assertStringStartsWith("A file of src/ raised 1 issue(s) that a framework's error handler took:\nE_USER_DEPRECATED: raised by src at ", $failure); + } + + /** With nothing recorded, `assertNone()` passes and counts one assertion. */ + public function testNothingRecordedCountsOneAssertion(): void + { + $before = Assert::getCount(); + + SrcErrorGuard::assertNone($this); + + $this->assertSame($before + 1, Assert::getCount()); + } + + /** The message of the assertion failure that `assertNone()` throws. */ + private function failureOfAssertNone(): string + { + $failure = Thrown::by(\Throwable::class, function (): void { + SrcErrorGuard::assertNone($this); + }); + $this->assertStringEndsWith('ExpectationFailedException', $failure::class); // PHPUnit's failure of an assertion + + return $failure->getMessage(); + } +} From bccead0567cdd3e50ec34340ad6dec787a0c7176 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 16:12:58 +0000 Subject: [PATCH 28/68] test: make the finished-transfer bound robust The test timed the collection of a finished transfer against 0.5 s, less than the 1 s of slack the timing tests keep. A time cannot tell the fix from the bug with that slack: without the fix, a finished transfer waits one wait at most, and a wait ends after 1 s. The test now asserts an order: after every start() and pump(), libcurl holds no finished transfer that the transport has not read, and the fast transfer is collected while the other one is still in transfer. That other transfer goes to a local server that never answers, so it cannot hold a worker of the mock collector. The loop stops before its last wait, which saves 1 s. Reading libcurl's queue needs the transport's multi handle, so the one helper that reads it may use reflection. --- phpstan.neon.dist | 2 + .../Transport/CurlTransportTest.php | 58 ++++++++++--------- tests/Support/UnreadTransfers.php | 33 +++++++++++ 3 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 tests/Support/UnreadTransfers.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index aaab92e..dde770d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -42,6 +42,8 @@ parameters: - tests/Contract/* # builds a cyclic `previous` chain and a corrupt trace (05 E10) - tests/Unit/Internal/Errors/ExceptionCaptureTest.php + # reads libcurl's queue of finished transfers off the transport's multi handle: no time can show it + - tests/Support/UnreadTransfers.php services: - class: AgentCat\Tests\Architecture\LayeringTest diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index 4ef8df9..ee1b8b8 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -18,6 +18,7 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Thrown; +use AgentCat\Tests\Support\UnreadTransfers; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -544,57 +545,62 @@ public function testAtMostFiveTransfersRunAtOnce(): void } /** - * Fix round 1: a transfer that finished while a slow logger held the loop is collected at once, not after a wait - * of up to 1 s on the socket of a slower transfer. evt_b fails at once (connection refused) and its line blocks the - * loop while evt_a's answer arrives. The time counts from the later of two moments, the slow logger letting go and - * the collector answering evt_a, so a loaded machine that delays either one does not count against the transport: - * with the fix it collects evt_a within milliseconds, without it about 1 s later. + * Fix round 1: every curl_multi_exec() is followed by a read of the transfers it finished, so a finished transfer is + * collected by the call that finished it, not after a wait of up to 1 s on the socket of a slower transfer. evt_b + * fails at once (connection refused) and its line blocks the loop (a slow customer PSR logger) while evt_a's answer + * arrives; evt_c goes to a local server that never answers, so its socket is the one a wait blocks on. The test + * asserts an order, not a time: after every start() and pump(), libcurl holds no finished transfer that the transport + * has not read, and evt_a is collected while evt_c is still in transfer. A time cannot tell the two apart with 1 s of + * slack: without the fix, evt_a waits one wait at most (`MAX_WAIT_SECONDS`, 1 s). */ #[MaximumDuration(2000)] public function testAFinishedTransferIsCollectedWithoutWaitingForOthers(): void { - $collector = self::collector(); - $slow = $collector->scenario([], ['status' => 202, 'delayMs' => 2000, 'body' => '']); - $fast = $collector->scenario([], ['status' => 202, 'delayMs' => 300, 'body' => '']); + $silent = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); // the kernel accepts; nothing reads + $this->assertNotFalse($silent, 'no local server: ' . $errorMessage); + $address = stream_socket_get_name($silent, false); + $this->assertIsString($address); + $unanswered = 'http://' . $address; + $fast = self::collector()->scenario([], ['status' => 202, 'delayMs' => 300, 'body' => '']); $logger = new class extends AbstractLogger { - /** @var array when each line was logged (wall clock) */ - public array $at = []; - - /** When the slow line was done (wall clock). */ - public float $released = 0.0; + /** @var list */ + public array $lines = []; /** @param mixed[] $context */ #[\Override] public function log($level, string|\Stringable $message, array $context = []): void { - $this->at[(string) $message] = microtime(true); + $this->lines[] = (string) $message; if (str_starts_with((string) $message, 'Failed to send event evt_b')) { usleep(700_000); // a slow customer PSR logger; evt_a's response arrives meanwhile - $this->released = microtime(true); } } }; Log::setLogger($logger); $transport = new CurlTransport(new RetryPolicy([10.0])); - $transport->start(new Delivery(ApiBaseUrl::endpoint($slow), '{}', 'evt_c', 'slow')); - $transport->start(new Delivery(ApiBaseUrl::endpoint($fast), '{}', 'evt_a', 'fast')); - $transport->start(new Delivery(ApiBaseUrl::endpoint(MockCollector::closedPortUrl()), '{}', 'evt_b', 'refused')); + foreach ([[$unanswered, 'evt_c', 'slow'], [$fast, 'evt_a', 'fast'], [MockCollector::closedPortUrl(), 'evt_b', 'refused']] as [$url, $id, $summary]) { + $transport->start(new Delivery(ApiBaseUrl::endpoint($url), '{}', $id, $summary)); + $this->assertSame(0, UnreadTransfers::of($transport), 'start() left a finished transfer unread: ' . implode(' / ', $logger->lines)); + } $sent = 'Successfully sent event evt_a | fast'; - // drain() without its deadline: evt_c runs 2 s and evt_b retries in 10 s, so stop once evt_a is collected. + // drain() without its deadline: evt_c never ends and evt_b retries in 10 s, so stop once evt_a is collected (no + // wait after it: a wait would block on evt_c's socket for 1 s). $deadline = $transport->now() + self::DEADLINE; - while (!isset($logger->at[$sent]) && $transport->now() < $deadline) { + while (true) { $transport->pump(); + $this->assertSame(0, UnreadTransfers::of($transport), 'pump() left a finished transfer unread: ' . implode(' / ', $logger->lines)); + if (in_array($sent, $logger->lines, true) || $transport->now() >= $deadline) { + break; + } $transport->waitForActivity($deadline); } - $lines = implode(' / ', array_keys($logger->at)); - $this->assertArrayHasKey($sent, $logger->at, $lines); + $lines = implode(' / ', $logger->lines); $this->assertStringStartsWith('Failed to send event evt_b, retrying... [Error: cURL error 7: ', $lines); - $this->assertGreaterThan(0.0, $logger->released); - $answered = $collector->requests($fast)[0]->end ?? null; // the collector logs just before it answers - $this->assertIsNumeric($answered); - $this->assertLessThan(0.5, $logger->at[$sent] - max($logger->released, (float) $answered), $lines); + $this->assertContains($sent, $logger->lines, $lines); + $this->assertSame(2, $transport->inFlight(), 'evt_a is collected while evt_c is in transfer and evt_b waits for its retry'); + fclose($silent); } #[MaximumDuration(2000)] diff --git a/tests/Support/UnreadTransfers.php b/tests/Support/UnreadTransfers.php new file mode 100644 index 0000000..73374e2 --- /dev/null +++ b/tests/Support/UnreadTransfers.php @@ -0,0 +1,33 @@ +getValue($transport); + Assert::assertInstanceOf(CurlMulti::class, $multi); + $handle = (new \ReflectionProperty(CurlMulti::class, 'multi'))->getValue($multi); + if (!$handle instanceof \CurlMultiHandle) { + return 0; + } + $unread = 0; + while (curl_multi_info_read($handle) !== false) { + ++$unread; + } + + return $unread; + } +} From 184ca909c5c0c58e3b4ee6f49638ae63b38fff66 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 16:12:58 +0000 Subject: [PATCH 29/68] test: make the positive-wait bound robust A single wait of 0.2 s with a transfer in progress returned after 0.149 s on PHP 8.6 with libcurl 7.88.1: libcurl ends a wait at a timer of its own (one due about 200 ms after the transfer starts), and until a curl_multi_exec() runs that timer, every later wait returns at once. The test now counts waits: pump() and a wait of 0.2 s, for 0.6 s, run about three times, where a wait that does not block runs about 100 000 times. Ten waits with a NaN or a negative bound, each after a pump(), take less than 1 s, where a wait of the maximum takes 10 s. --- .../Transport/CurlHttpChannelTest.php | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index 3f6c425..a746e78 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -585,7 +585,9 @@ public function testAWarningInACallbackStaysInsideTheChannel(): void /** * Ruling P7: only a wait with a transfer in progress reaches the NaN guard. Without the guard, PHP 8.4+ computes * a NAN bound (`max(0.0, NAN)`), which curl_multi_select() rejects with a ValueError: tearDown() sees it swallowed. - * CurlSupportTest pins the bound itself. + * CurlSupportTest pins the bound itself. The test counts waits instead of timing one: libcurl ends a wait early at a + * timer of its own (on libcurl 7.88, one due about 200 ms after the transfer starts), and until a pump() runs that + * timer, every later wait returns at once. */ #[MaximumDuration(3000)] public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): void @@ -615,12 +617,21 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): foreach ([NAN, -1.0] as $seconds) { $begin = hrtime(true); - $channel->waitForActivity($seconds); - $this->assertLessThan(0.5, (hrtime(true) - $begin) / 1e9); + for ($i = 0; $i < 10; ++$i) { + $channel->pump(); // runs libcurl's due timers, which would end a wait early + $channel->waitForActivity($seconds); + } + $this->assertLessThan(1.0, (hrtime(true) - $begin) / 1e9, 'ten waits return at once, not after a second each'); + } + // A positive bound does block: pump() and a wait of 0.2 s, for 0.6 s, run about three times, not thousands. + $passes = 0; + $until = hrtime(true) + 600_000_000; + while (hrtime(true) < $until) { + $channel->pump(); + $channel->waitForActivity(0.2); + ++$passes; } - $begin = hrtime(true); - $channel->waitForActivity(0.2); - $this->assertGreaterThanOrEqual(0.15, (hrtime(true) - $begin) / 1e9, 'a positive bound does block'); + $this->assertLessThan(20, $passes, 'a positive bound does block'); fclose($connection); fclose($server); From c143b19053a50a3426142cd079543c0a76d3bfa2 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 17:24:41 +0000 Subject: [PATCH 30/68] test: guard src/ diagnostics from the first provider boot and harden the timing fixtures - The guard goes in front of Laravel's error handler before the providers register (Testbench resolves RegisterProviders right after HandleExceptions) and in front before a Symfony kernel boots, so a deprecation in AgentCat's provider or bundle fails the test. A test provider and a compiler pass check the position; one guard fronts each application handler, never two. - The guard's assertion runs in an After hook, after every tearDown() of the test, so its failure skips no subclass cleanup. The handler stacks go back in a finally, and setUp() forgets what a test that threw before its assertion left. - UnreadTransfers fails closed: a member that is not what the transport keeps there throws a LogicException that names it. - The positive-wait check allows fewer than 8 passes in 0.6 s, not 20. - SrcErrorGuardTest covers a handler that declines the issue and no handler at all: both give the issue to PHP's own handling. - The slow export of EventQueueDeliveryTest goes to a local server that never answers, so no mock collector worker serves the event after it. --- .../Delivery/EventQueueDeliveryTest.php | 11 +++- .../Laravel/SrcErrorGuardInstallTest.php | 55 +++++++++++++++++++ .../Symfony/SrcErrorGuardInstallTest.php | 44 +++++++++++++++ .../Transport/CurlHttpChannelTest.php | 5 +- .../Support/Laravel/GuardPositionProvider.php | 26 +++++++++ tests/Support/LaravelMcp/LaravelTestCase.php | 42 ++++++++++++-- tests/Support/SrcErrorGuard.php | 33 +++++++++-- tests/Support/Symfony/SymfonyTestCase.php | 51 +++++++++++------ tests/Support/UnreadTransfers.php | 14 +++-- tests/Unit/SrcErrorGuardTest.php | 49 +++++++++++++++++ tests/Unit/UnreadTransfersTest.php | 21 +++++++ 11 files changed, 317 insertions(+), 34 deletions(-) create mode 100644 tests/Integration/Laravel/SrcErrorGuardInstallTest.php create mode 100644 tests/Integration/Symfony/SrcErrorGuardInstallTest.php create mode 100644 tests/Support/Laravel/GuardPositionProvider.php create mode 100644 tests/Unit/UnreadTransfersTest.php diff --git a/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index bbfeb5f..b9a4e53 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -155,14 +155,20 @@ public function testShutdownStopsWaitingAtItsBudget(): void /** * Review Focus 1: an exporter endpoint that hangs, on a server whose AgentCat API works. `pump()` returns at once, - * the event is delivered, and `shutdown()` waits for the export only until its budget, then abandons it. + * the event is delivered, and `shutdown()` waits for the export only until its budget, then abandons it. The export + * goes to a local server that never answers, not to the mock collector: a collector worker that took both + * connections would serve the event after the slow export. */ #[Group('ac-06-63')] #[MaximumDuration(5000)] public function testASlowExportEndpointNeverHoldsTheEventsBack(): void { $events = $this->collector()->scenario(); - $slow = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 3000]); + $silent = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); // the kernel accepts; nothing reads + $this->assertNotFalse($silent, 'no local server: ' . $errorMessage); + $address = stream_socket_get_name($silent, false); + $this->assertIsString($address); + $slow = 'http://' . $address; $channel = new CurlHttpChannel(); $exporter = new readonly class ($channel, $slow) implements ExporterSink { public function __construct(private HttpChannel $channel, private string $url) {} @@ -189,6 +195,7 @@ public function export(Event $event): void $this->assertLessThan(1.5, $elapsed, 'the shutdown budget (0.3 s here) bounds the slow export'); $this->assertCount(1, $this->collector()->requests($events), 'the event is delivered'); $this->assertSame(1, $channel->pending(), 'the slow export is abandoned'); + fclose($silent); } /** diff --git a/tests/Integration/Laravel/SrcErrorGuardInstallTest.php b/tests/Integration/Laravel/SrcErrorGuardInstallTest.php new file mode 100644 index 0000000..eadfdb2 --- /dev/null +++ b/tests/Integration/Laravel/SrcErrorGuardInstallTest.php @@ -0,0 +1,55 @@ + */ + #[\Override] + protected function getPackageProviders(mixed $app): array + { + return [...parent::getPackageProviders($app), GuardPositionProvider::class]; + } + + /** The providers register and boot behind the guard, after AgentCat's own. */ + public function testTheGuardIsInFrontWhileTheProvidersRegisterAndBoot(): void + { + $this->assertSame(['register' => true, 'boot' => true], GuardPositionProvider::$inFront); + } + + public function testOneGuardIsInFrontOfTheApplicationsHandler(): void + { + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + $this->get('/agentcat-guard'); // a request through the HTTP kernel installs no handler of its own + + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } + + /** A second application installs Laravel's handler again: a second guard goes in front of it, not two of one. */ + public function testARefreshedApplicationGetsAGuardOfItsOwn(): void + { + $this->refreshApplication(); + + $this->assertSame(2, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } +} diff --git a/tests/Integration/Symfony/SrcErrorGuardInstallTest.php b/tests/Integration/Symfony/SrcErrorGuardInstallTest.php new file mode 100644 index 0000000..d9fbdce --- /dev/null +++ b/tests/Integration/Symfony/SrcErrorGuardInstallTest.php @@ -0,0 +1,44 @@ +http($this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg')))->listTools(); + $this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg')); + + $this->assertSame(1, SrcErrorGuard::installed()); + $this->assertTrue(SrcErrorGuard::inFront()); + } + + /** The bundles build the container behind the guard (AgentCat's compiler pass runs then). */ + #[MaximumDuration(1000)] + public function testTheGuardIsInFrontWhileTheContainerBuilds(): void + { + $probe = new class implements CompilerPassInterface { + public ?bool $inFront = null; + + #[\Override] + public function process(ContainerBuilder $container): void + { + $this->inFront = SrcErrorGuard::inFront(); + } + }; + + $this->kernel(agentcat: $this->on('proj_ggggggggggggggggggggggggggg'), probe: $probe); + + $this->assertTrue($probe->inFront); + } +} diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index a746e78..6c32682 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -623,7 +623,8 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): } $this->assertLessThan(1.0, (hrtime(true) - $begin) / 1e9, 'ten waits return at once, not after a second each'); } - // A positive bound does block: pump() and a wait of 0.2 s, for 0.6 s, run about three times, not thousands. + // A positive bound does block: pump() and a wait of 0.2 s, for 0.6 s, run about three times. Each libcurl timer + // can end one wait early (one on libcurl 7.88); fewer than 8 passes means waits of more than about 80 ms. $passes = 0; $until = hrtime(true) + 600_000_000; while (hrtime(true) < $until) { @@ -631,7 +632,7 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): $channel->waitForActivity(0.2); ++$passes; } - $this->assertLessThan(20, $passes, 'a positive bound does block'); + $this->assertLessThan(8, $passes, 'a positive bound does block'); fclose($connection); fclose($server); diff --git a/tests/Support/Laravel/GuardPositionProvider.php b/tests/Support/Laravel/GuardPositionProvider.php new file mode 100644 index 0000000..ea163db --- /dev/null +++ b/tests/Support/Laravel/GuardPositionProvider.php @@ -0,0 +1,26 @@ + `register` and `boot`: whether the guard was in front */ + public static array $inFront = []; + + #[\Override] + public function register(): void + { + self::$inFront['register'] = SrcErrorGuard::inFront(); + } + + public function boot(): void + { + self::$inFront['boot'] = SrcErrorGuard::inFront(); + } +} diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 9bc5ff7..2a2402c 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -18,8 +18,10 @@ use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; use Illuminate\Foundation\Application; +use Illuminate\Foundation\Bootstrap\RegisterProviders; use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\After; /** * A Laravel application with laravel/mcp, for the adapter and bridge tests. `app.debug` is false, like production. @@ -85,6 +87,7 @@ public function testWarmUp(): void $warmUp->testWarmUp(); } finally { $warmUp->tearDown(); + $warmUp->assertNoSrcIssueWasHidden(); $warmUp::tearDownAfterClass(); } }); @@ -93,6 +96,7 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + SrcErrorGuard::forget(); $this->handlers = GlobalHandlers::save(); $home = getenv('HOME'); if (!is_string($home) || $home === '') { @@ -113,7 +117,23 @@ protected function setUp(): void parent::setUp(); } - /** Each new application (`setUp()`, or a test's own refresh) installs its error handler: the guard goes in front. */ + /** + * Each new application (`setUp()`, or a test's own refresh) installs Laravel's error handler, then registers and + * boots the providers, AgentCat's among them. The guard goes in front of that handler before the providers register + * (Testbench resolves `RegisterProviders` right after `HandleExceptions`), and again after the application if a + * handler came in front of it since. + * + * @param Application $app + */ + #[\Override] + protected function resolveApplicationResolvingCallback(mixed $app): void + { + parent::resolveApplicationResolvingCallback($app); + $app->afterResolving(RegisterProviders::class, static function (): void { + SrcErrorGuard::install(); + }); + } + #[\Override] protected function refreshApplication(): void { @@ -160,10 +180,22 @@ protected function tearDown(): void TrackedServers::resetIncomplete(); TrackedServers::resetSkipped(); - parent::tearDown(); - $this->environment?->restore(); - FileTree::remove($this->laravelDirectory); - $this->handlers?->restore(); // Laravel's flushState() emptied both stacks and put PHPUnit's handler back + parent::tearDown(); // Laravel's flushState() empties both stacks and puts PHPUnit's handler back, also before a throw + try { + $this->environment?->restore(); + FileTree::remove($this->laravelDirectory); + } finally { + $this->handlers?->restore(); + } + } + + /** + * Fails with each `src/` issue that a guard recorded. It runs after every `tearDown()` of the test, the subclasses' + * too, so its failure skips none of their cleanup; `setUp()` forgets what a test that threw earlier left. + */ + #[After(-100)] + public function assertNoSrcIssueWasHidden(): void + { SrcErrorGuard::assertNone($this); } } diff --git a/tests/Support/SrcErrorGuard.php b/tests/Support/SrcErrorGuard.php index 15f0ce8..f761822 100644 --- a/tests/Support/SrcErrorGuard.php +++ b/tests/Support/SrcErrorGuard.php @@ -13,7 +13,7 @@ * way, PHPUnit never sees it. After each application or kernel of a bridge test case boots, `install()` puts a handler * in front of the framework's: it records an issue from a file under the source directory, then hands every issue to * the framework's handler, as without the guard. The test case's `tearDown()` puts the handler stack back - * (`GlobalHandlers`) and calls `assertNone()`. + * (`GlobalHandlers`), and an `#[After]` method calls `assertNone()` once every `tearDown()` of the test has run. */ final class SrcErrorGuard { @@ -46,10 +46,10 @@ final class SrcErrorGuard */ public static function install(?string $source = null): void { - $inPlace = self::inPlace(); - if ($inPlace !== null && in_array($inPlace, self::$guards, true)) { - return; // a second kernel of the same test: no framework handler came in front of the guard + if (self::inFront()) { + return; // a second kernel of the same test, or after the boot: no handler came in front of the guard } + $inPlace = self::inPlace(); $source ??= self::source(); $guard = static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { if (self::fromSource($level, $file, $source)) { @@ -71,6 +71,28 @@ public static function assertNone(TestCase $test): void $test::assertCount(0, $recorded, sprintf("A file of src/ raised %d issue(s) that a framework's error handler took:\n%s", count($recorded), implode("\n", $recorded))); } + /** + * Forgets the issues and the guards of a test that ended before its `assertNone()`: its `tearDown()` threw, so it + * failed already. A bridge test case calls it first in `setUp()`, so no other test reports them. + */ + public static function forget(): void + { + self::$recorded = []; + self::$guards = []; + } + + /** The guards installed since the last `assertNone()`: one for each application or kernel handler they front. */ + public static function installed(): int + { + return count(self::$guards); + } + + /** Whether a guard is the error handler in place. */ + public static function inFront(): bool + { + return in_array(self::inPlace(), self::$guards, true); + } + /** The repository's `src/`, with a trailing slash. */ public static function source(): string { @@ -107,7 +129,8 @@ public static function inPlace(): ?callable /** * Hands an issue to the handler that was in place, as PHP would have: true when it took the issue, false (PHP's own - * handling) when it declined it or there was none. + * handling) when it declined it or there was none. Every level goes to it: PHP does not tell which levels a handler + * was registered for (`set_error_handler()`'s second argument), and no framework in use registers one. * * @param (callable(int, string, string, int): bool)|null $inPlace */ diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 4420eb4..ff529f1 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -21,6 +21,7 @@ use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; use AgentCat\Tests\Support\WarmUp; +use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -90,6 +91,7 @@ public function testWarmUp(): void $warmUp->testWarmUp(); } finally { $warmUp->tearDown(); + $warmUp->assertNoSrcIssueWasHidden(); $warmUp::tearDownAfterClass(); } }); @@ -98,6 +100,7 @@ public function testWarmUp(): void #[\Override] protected function setUp(): void { + SrcErrorGuard::forget(); $this->handlers = GlobalHandlers::save(); if ($this->needsTheMcpBundle() && !class_exists(McpBundle::class)) { self::markTestSkipped('symfony/mcp-bundle is not installed'); @@ -124,23 +127,35 @@ protected function needsTheMcpBundle(): bool #[\Override] protected function tearDown(): void { - foreach ($this->kernels as $kernel) { - $kernel->shutdown(); + try { + foreach ($this->kernels as $kernel) { + $kernel->shutdown(); + } + $this->kernels = []; + ToolCalls::reset(); + TestMode::reset(); + BridgeConfig::reset(); + Log::reset(); + Diagnostics::reset(); + EventQueue::setGlobal(null); + AgentCat::setClient(null); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + + $this->environment?->restore(); + FileTree::remove($this->directory); + } finally { // a throw above leaves no guard to the next test + $this->handlers?->restore(); // the guard goes, and any handler that a kernel left } - $this->kernels = []; - ToolCalls::reset(); - TestMode::reset(); - BridgeConfig::reset(); - Log::reset(); - Diagnostics::reset(); - EventQueue::setGlobal(null); - AgentCat::setClient(null); - TrackedServers::resetIncomplete(); - TrackedServers::resetSkipped(); + } - $this->environment?->restore(); - FileTree::remove($this->directory); - $this->handlers?->restore(); // the guard goes, and any handler that a kernel left + /** + * Fails with each `src/` issue that a guard recorded. It runs after every `tearDown()` of the test, the subclasses' + * too, so its failure skips none of their cleanup; `setUp()` forgets what a test that threw earlier left. + */ + #[After(-100)] + public function assertNoSrcIssueWasHidden(): void + { SrcErrorGuard::assertNone($this); } @@ -164,11 +179,15 @@ protected function kernel(array $agentcat = [], array $services = [], bool $with return $kernel; } - /** Boots a kernel of this test, which shuts it down after the test. The guard goes in front of its error handler. */ + /** + * Boots a kernel of this test, which shuts it down after the test. The guard is in front while the bundles build the + * container and boot (AgentCat's among them), and again after the boot if a handler came in front of it. + */ protected function boot(KernelInterface $kernel): void { $this->kernels[] = $kernel; $handler = $this->exceptionHandler(); + SrcErrorGuard::install(); $kernel->boot(); if ($this->exceptionHandler() !== $handler) { restore_exception_handler(); // FrameworkBundle::boot() leaves Symfony's handler with symfony/error-handler 7.3 diff --git a/tests/Support/UnreadTransfers.php b/tests/Support/UnreadTransfers.php index 73374e2..a210320 100644 --- a/tests/Support/UnreadTransfers.php +++ b/tests/Support/UnreadTransfers.php @@ -6,7 +6,6 @@ use AgentCat\Internal\Transport\CurlMulti; use AgentCat\Internal\Transport\CurlTransport; -use PHPUnit\Framework\Assert; /** * The transfers that libcurl finished and the transport has not read yet, off the transport's multi handle. No time @@ -14,14 +13,21 @@ */ final class UnreadTransfers { - /** Reads them off the multi handle, so a test that sees one fails at once. Zero before the first delivery made the handle. */ + /** + * Reads them off the multi handle, so a test that sees one fails at once. Call it after the first delivery started, + * which makes the handle. + * + * @throws \LogicException when a member is not what the transport keeps there: the check would pass for nothing + */ public static function of(CurlTransport $transport): int { $multi = (new \ReflectionProperty(CurlTransport::class, 'multi'))->getValue($transport); - Assert::assertInstanceOf(CurlMulti::class, $multi); + if (!$multi instanceof CurlMulti) { + throw new \LogicException('CurlTransport::$multi is ' . get_debug_type($multi) . ', not a CurlMulti'); + } $handle = (new \ReflectionProperty(CurlMulti::class, 'multi'))->getValue($multi); if (!$handle instanceof \CurlMultiHandle) { - return 0; + throw new \LogicException('CurlMulti::$multi is ' . get_debug_type($handle) . ', not a CurlMultiHandle: no delivery started, or the member changed'); } $unread = 0; while (curl_multi_info_read($handle) !== false) { diff --git a/tests/Unit/SrcErrorGuardTest.php b/tests/Unit/SrcErrorGuardTest.php index ccda0ed..f648f3b 100644 --- a/tests/Unit/SrcErrorGuardTest.php +++ b/tests/Unit/SrcErrorGuardTest.php @@ -84,6 +84,40 @@ public function testAnyOtherIssueIsNotRecorded(string $source, bool $suppressed) $this->assertSame(['not ours'], $this->framework->getArrayCopy()); } + /** A handler in place that declines the issue (returns false) gives it to PHP's own handling, as without the guard. */ + public function testAnIssueThatTheHandlerInPlaceDeclinesGoesOnToPhp(): void + { + $declined = new \ArrayObject(); + set_error_handler(static function (int $level, string $message) use ($declined): bool { + $declined->append($message); + + return false; + }); + SrcErrorGuard::install(__DIR__ . '/'); + $this->triggerQuietly('declined'); + $this->handlers->restore(); + + $this->assertSame(['declined'], $declined->getArrayCopy()); + $this->assertSame([], $this->framework->getArrayCopy(), 'a handler does not hand on to the one below it'); + $this->assertSame('declined', error_get_last()['message'] ?? null, "PHP's own handling ran"); + $this->assertStringContainsString("\nE_USER_DEPRECATED: declined at " . __FILE__ . ':', $this->failureOfAssertNone()); + } + + /** With no handler in place, the guard records the issue and gives it to PHP's own handling. */ + public function testWithoutAHandlerInPlaceTheIssueGoesOnToPhp(): void + { + while (SrcErrorGuard::inPlace() !== null) { + restore_error_handler(); // also PHPUnit's: tearDown() puts the stack back + } + SrcErrorGuard::install(__DIR__ . '/'); + $this->triggerQuietly('unhandled'); + $this->handlers->restore(); + + $this->assertSame([], $this->framework->getArrayCopy()); + $this->assertSame('unhandled', error_get_last()['message'] ?? null, "PHP's own handling ran"); + $this->assertStringContainsString("\nE_USER_DEPRECATED: unhandled at " . __FILE__ . ':', $this->failureOfAssertNone()); + } + /** A second kernel of a test installs nothing while the guard is in front: each issue is recorded once. */ public function testAGuardInFrontIsNotInstalledAgain(): void { @@ -109,6 +143,21 @@ public function testNothingRecordedCountsOneAssertion(): void $this->assertSame($before + 1, Assert::getCount()); } + /** A deprecation that PHP's own handling records (`error_get_last()`) but neither prints nor logs. */ + private function triggerQuietly(string $message): void + { + $display = ini_set('display_errors', '0'); + $log = ini_set('log_errors', '0'); + error_clear_last(); + + try { + trigger_error($message, E_USER_DEPRECATED); + } finally { + ini_set('display_errors', (string) $display); + ini_set('log_errors', (string) $log); + } + } + /** The message of the assertion failure that `assertNone()` throws. */ private function failureOfAssertNone(): string { diff --git a/tests/Unit/UnreadTransfersTest.php b/tests/Unit/UnreadTransfersTest.php new file mode 100644 index 0000000..c6bf707 --- /dev/null +++ b/tests/Unit/UnreadTransfersTest.php @@ -0,0 +1,21 @@ + UnreadTransfers::of(new CurlTransport())); + + $this->assertSame('CurlMulti::$multi is null, not a CurlMultiHandle: no delivery started, or the member changed', $error->getMessage()); + } +} From 1e5a39dd04a189ab6b663b97ebfb3c1c95e944b6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 17:35:42 +0000 Subject: [PATCH 31/68] ci: add the nightly upstream, runtime and release checks with one issue per failing job --- .github/nightly-issue.sh | 14 ++ .github/workflows/nightly.yml | 271 +++++++++++++++++++++++ tests/Runtime/ApacheTest.php | 2 +- tests/Runtime/CliTest.php | 6 +- tests/Runtime/FastCgiClientTest.php | 2 +- tests/Runtime/FpmTest.php | 2 +- tests/Runtime/FrankenPhpTest.php | 2 +- tests/Runtime/HarnessTest.php | 20 +- tests/Runtime/SmokeTest.php | 20 +- tests/Runtime/Support/DockerRuntime.php | 13 +- tests/Runtime/Support/Images.php | 72 +++++- tests/Runtime/UnitTest.php | 2 +- tests/Runtime/docker/Dockerfile | 3 +- tests/Runtime/docker/swoole/Dockerfile | 3 +- tests/Unit/RuntimeSupport/ImagesTest.php | 109 +++++++++ 15 files changed, 495 insertions(+), 46 deletions(-) create mode 100755 .github/nightly-issue.sh create mode 100644 .github/workflows/nightly.yml create mode 100644 tests/Unit/RuntimeSupport/ImagesTest.php diff --git a/.github/nightly-issue.sh b/.github/nightly-issue.sh new file mode 100755 index 0000000..0cbd76f --- /dev/null +++ b/.github/nightly-issue.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Opens one issue per failing nightly job, or comments on the open one. A person closes it after the fix. +set -euo pipefail +job="$1" +title="Nightly: ${job} failed" +run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +gh label create nightly --color B60205 --description "A scheduled upstream or release check failed" 2>/dev/null || true +number="$(gh issue list --label nightly --state open --limit 100 --json number,title --jq ".[] | select(.title == \"${title}\") | .number" | head -n 1)" +body="The nightly job \`${job}\` failed: ${run_url}" +if [ -n "${number}" ]; then + gh issue comment "${number}" --body "${body}" +else + gh issue create --title "${title}" --label nightly --body "${body}" +fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..ee46589 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,271 @@ +name: Nightly + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + force_failure: + description: 'A job name to fail on purpose, to prove the issue step (for example audit)' + required: false + default: '' + type: string + pull_request: + paths: + - '.github/workflows/nightly.yml' + - '.github/nightly-issue.sh' + +permissions: + contents: read + issues: write + +jobs: + mcp-sdk-dev-main: + name: mcp/sdk dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install mcp/sdk dev-main + run: composer require --dev --no-interaction -W "mcp/sdk:dev-main as 0.9.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.no-bundle.neon + - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + laravel-mcp-dev-main: + name: laravel/mcp dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install laravel/mcp dev-main + run: composer require --dev --no-interaction -W "laravel/mcp:dev-main as 1.0.99" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + mcp-bundle-dev-main: + name: symfony/mcp-bundle dev-main (PHP 8.5) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Install symfony/mcp-bundle dev-main with mcp/sdk 0.9 + run: composer require --dev --no-interaction -W "symfony/mcp-bundle:dev-main as 0.14.99" "mcp/sdk:^0.9" + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + php-nightly: + name: Tests (PHP nightly) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: 'nightly' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + # ergebnis/composer-normalize caps PHP below the nightly. + composer-options: --ignore-platform-req=php+ + - run: vendor/bin/phpunit + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + mutation-full: + name: Mutation testing (full run) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: pcov + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/infection --threads=max --logger-github + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + audit: + name: Composer audit (highest dependencies) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: composer audit + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + network: + name: Network tests (live OpenAPI schema) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - run: vendor/bin/phpunit --group network + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + runtime-latest: + name: Runtime delivery (Docker, latest image patches) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Build the CLI and Swoole images on the latest bases and pull the others + run: | + docker build --pull --build-arg BASE=php:8.3-cli-bookworm --tag agentcat-runtime-cli:latest tests/Runtime/docker + docker build --pull --build-arg BASE=phpswoole/swoole:php8.3 --tag agentcat-runtime-swoole:latest tests/Runtime/docker/swoole + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm dunglas/frankenphp:1-php8.3-bookworm unit:php8.3; do docker pull "$image"; done + - name: Record the resolved PHP version of each image + run: | + for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm agentcat-runtime-cli:latest dunglas/frankenphp:1-php8.3-bookworm agentcat-runtime-swoole:latest unit:php8.3; do + printf '%s: ' "$image" + docker run --rm --pull never --network none --entrypoint php "$image" -r 'echo PHP_VERSION, "\n";' + done + - run: vendor/bin/phpunit --testsuite Runtime --group runtime + env: + AGENTCAT_RUNTIME_TESTS: '1' + AGENTCAT_RUNTIME_IMAGES: latest + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" + + package-dev-main: + name: Package probe (PHP 8.3, mcp/sdk dev-main) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - name: Install the dist in a fresh project and track an mcp/sdk server + run: tests/bin/package-probe.sh "dev-main as 0.9.99" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" diff --git a/tests/Runtime/ApacheTest.php b/tests/Runtime/ApacheTest.php index 101ed17..e8664f4 100644 --- a/tests/Runtime/ApacheTest.php +++ b/tests/Runtime/ApacheTest.php @@ -105,7 +105,7 @@ private function apache(string $events, string $diagnostics): array foreach ($files as $name => $contents) { $this->assertNotFalse(file_put_contents($out . '/' . $name, $contents . "\n")); } - $runtime = DockerRuntime::start(Images::APACHE, ['apache2-foreground'], $env, [ + $runtime = DockerRuntime::start(Images::apache(), ['apache2-foreground'], $env, [ $out . '/ports.conf' => '/etc/apache2/ports.conf', $out . '/site.conf' => '/etc/apache2/sites-enabled/000-default.conf', $out . '/php.ini' => '/usr/local/etc/php/conf.d/zz-agentcat.ini', diff --git a/tests/Runtime/CliTest.php b/tests/Runtime/CliTest.php index 714a70f..6d6fa30 100644 --- a/tests/Runtime/CliTest.php +++ b/tests/Runtime/CliTest.php @@ -59,7 +59,7 @@ public function testSigtermDeliversTheQueueOnAStockBuildAndStdoutStaysJsonRpc(ar { $scenario = self::$collector->scenario(); $out = $this->out(); - $client = StdioMcpClient::command(DockerRuntime::command(Images::CLI, $server, $this->env($scenario), [$out => '/out']), timeout: self::CLIENT_SECONDS); + $client = StdioMcpClient::command(DockerRuntime::command(Images::cli(), $server, $this->env($scenario), [$out => '/out']), timeout: self::CLIENT_SECONDS); // The handshake era: mcp/sdk 0.8.1 serves 2026-07-28 over stdio only on main; laravel/mcp serves both. $client->send(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'cli', 'version' => '1']]]); $this->assertSame('2025-06-18', JsonAssert::at(HttpHarness::result($client->receive(1, self::ANSWER_SECONDS)), 'protocolVersion')); @@ -91,8 +91,8 @@ public function testNoSignalHandlerIsInstalledUnderSwoole(): void $script = ['php', self::repo() . '/tests/Runtime/Fixtures/swoole-guard.php']; $out = $this->out(); - $swoole = DockerRuntime::run(Images::SWOOLE, $script, $this->env(), [$out => '/out']); - $plain = DockerRuntime::run(Images::CLI, $script, $this->env(), [$out => '/out']); + $swoole = DockerRuntime::run(Images::swoole(), $script, $this->env(), [$out => '/out']); + $plain = DockerRuntime::run(Images::cli(), $script, $this->env(), [$out => '/out']); $this->assertSame(0, $swoole['exitCode'], $swoole['stderr']); $this->assertEquals((object) ['swoole' => true, 'pcntl' => true, 'agentcatHandler' => false], Json::decode($swoole['stdout']), $swoole['stderr']); diff --git a/tests/Runtime/FastCgiClientTest.php b/tests/Runtime/FastCgiClientTest.php index 2c60385..34d283f 100644 --- a/tests/Runtime/FastCgiClientTest.php +++ b/tests/Runtime/FastCgiClientTest.php @@ -66,7 +66,7 @@ private function fpm(): int $out = $this->out(); $port = $this->freePort(); file_put_contents($out . '/pool.conf', "[www]\nlisten = 127.0.0.1:{$port}\npm = static\npm.max_children = 1\n"); - DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env(), [$out . '/pool.conf' => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf'], port: $port); + DockerRuntime::start(Images::fpm(), ['php-fpm', '-F'], $this->env(), [$out . '/pool.conf' => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf'], port: $port); return $port; } diff --git a/tests/Runtime/FpmTest.php b/tests/Runtime/FpmTest.php index d39200e..42db559 100644 --- a/tests/Runtime/FpmTest.php +++ b/tests/Runtime/FpmTest.php @@ -263,7 +263,7 @@ private function fpm(?string $collector = null, int $workers = 2, ?string $disab ...($disableFunctions === null ? [] : ['php_admin_value[disable_functions] = ' . $disableFunctions]), '', ])); - $this->runtime = DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); + $this->runtime = DockerRuntime::start(Images::fpm(), ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); return new FastCgiClient('127.0.0.1', $port); } diff --git a/tests/Runtime/FrankenPhpTest.php b/tests/Runtime/FrankenPhpTest.php index d70670a..994dc64 100644 --- a/tests/Runtime/FrankenPhpTest.php +++ b/tests/Runtime/FrankenPhpTest.php @@ -238,7 +238,7 @@ private function frankenphp(bool $worker, ?string $collector = null, array $env '', ])); $this->runtime = DockerRuntime::start( - Images::FRANKENPHP, + Images::frankenphp(), ['frankenphp', 'run', '--config', self::CADDYFILE], [...$this->env($collector), ...$env], [$caddyfile => self::CADDYFILE, $out => '/out'], diff --git a/tests/Runtime/HarnessTest.php b/tests/Runtime/HarnessTest.php index 027caeb..ec94dfa 100644 --- a/tests/Runtime/HarnessTest.php +++ b/tests/Runtime/HarnessTest.php @@ -95,7 +95,7 @@ public function testAStartedContainerEndsOnItsOwnAfterItsLifetime(): void $prefix = 'agentcat-rt-' . getmypid() . '-'; $started = microtime(true); - DockerRuntime::start(Images::CLI, ['php', '-r', 'sleep(120);'], $this->env(), [], lifetime: 2.0); + DockerRuntime::start(Images::cli(), ['php', '-r', 'sleep(120);'], $this->env(), [], lifetime: 2.0); $this->assertNotSame('', $this->containerIds($prefix)); $deadline = $started + 20.0; @@ -119,11 +119,11 @@ public function testOnlyTheOutputDirectoryCanBeMounted(): void $this->assertTrue(mkdir($elsewhere)); try { - $refused = Thrown::by(\InvalidArgumentException::class, fn(): array => DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$elsewhere => '/kernel'])); + $refused = Thrown::by(\InvalidArgumentException::class, fn(): array => DockerRuntime::command(Images::cli(), ['true'], $this->env(), [$elsewhere => '/kernel'])); } finally { rmdir($elsewhere); } - $command = DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$out => '/out', $out . '/pool.conf' => '/etc/pool.conf']); + $command = DockerRuntime::command(Images::cli(), ['true'], $this->env(), [$out => '/out', $out . '/pool.conf' => '/etc/pool.conf']); $this->assertStringContainsString('output directory', $refused->getMessage()); $this->assertContains('type=bind,source=' . realpath($out) . ',target=/out', $command); @@ -136,11 +136,11 @@ public function testPhpCliImagesGetAnInitWhateverTheCallerSays(): void { $out = $this->out(); - DockerRuntime::start(Images::CLI, ['php', '-r', 'file_put_contents("/out/pid", (string) getmypid()); sleep(30);'], $this->env(), [$out => '/out'], init: false); + DockerRuntime::start(Images::cli(), ['php', '-r', 'file_put_contents("/out/pid", (string) getmypid()); sleep(30);'], $this->env(), [$out => '/out'], init: false); - $this->assertContains('--init', DockerRuntime::command(Images::CLI, ['true'], $this->env(), [], init: false)); - $this->assertContains('--init', DockerRuntime::command(Images::SWOOLE, ['true'], $this->env(), [], init: false)); - $this->assertNotContains('--init', DockerRuntime::command(Images::FPM, ['true'], $this->env(), [], init: false)); + $this->assertContains('--init', DockerRuntime::command(Images::cli(), ['true'], $this->env(), [], init: false)); + $this->assertContains('--init', DockerRuntime::command(Images::swoole(), ['true'], $this->env(), [], init: false)); + $this->assertNotContains('--init', DockerRuntime::command(Images::fpm(), ['true'], $this->env(), [], init: false)); $deadline = microtime(true) + 20.0; while (!is_file($out . '/pid') && microtime(true) < $deadline) { usleep(50_000); @@ -159,12 +159,12 @@ public function testAgentCatsDefaultHostsResolveToTheLocalHostInEveryContainer() $hosts = [parse_url(Constants::DEFAULT_API_BASE_URL, PHP_URL_HOST), parse_url(Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, PHP_URL_HOST)]; $code = sprintf('echo gethostbyname(%s), " ", gethostbyname(%s);', var_export($hosts[0], true), var_export($hosts[1], true)); - $run = DockerRuntime::run(Images::CLI, ['env', '-i', '/usr/local/bin/php', '-r', $code], $this->env(), []); // an empty environment + $run = DockerRuntime::run(Images::cli(), ['env', '-i', '/usr/local/bin/php', '-r', $code], $this->env(), []); // an empty environment $this->assertSame(0, $run['exitCode'], $run['stderr']); $this->assertSame('127.0.0.1 127.0.0.1', $run['stdout']); $env = ['AGENTCAT_API_URL' => 'http://127.0.0.1:@example.com/'] + $this->env(); // user information, host example.com - $this->assertStringContainsString('AGENTCAT_API_URL', Thrown::by(\InvalidArgumentException::class, static fn(): array => DockerRuntime::command(Images::CLI, ['true'], $env, []))->getMessage()); + $this->assertStringContainsString('AGENTCAT_API_URL', Thrown::by(\InvalidArgumentException::class, static fn(): array => DockerRuntime::command(Images::cli(), ['true'], $env, []))->getMessage()); } /** @@ -183,7 +183,7 @@ private function smokeRun(array $env): Process /** Starts a container under `$name` that sleeps 60 s and is removed when it ends. */ private function detachedSleep(string $name): string { - $run = new Process(['docker', 'run', '--detach', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', Images::CLI, 'sleep', '60'], null, null, null, 30.0); + $run = new Process(['docker', 'run', '--detach', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', Images::cli(), 'sleep', '60'], null, null, null, 30.0); $run->run(); $this->assertTrue($run->isSuccessful(), $run->getErrorOutput()); diff --git a/tests/Runtime/SmokeTest.php b/tests/Runtime/SmokeTest.php index 00465cb..3b6f2e2 100644 --- a/tests/Runtime/SmokeTest.php +++ b/tests/Runtime/SmokeTest.php @@ -18,12 +18,12 @@ final class SmokeTest extends RuntimeTestCase /** @return iterable */ public static function images(): iterable { - yield 'fpm' => [Images::FPM]; - yield 'apache' => [Images::APACHE]; - yield 'cli' => [Images::CLI]; - yield 'frankenphp' => [Images::FRANKENPHP]; - yield 'swoole' => [Images::SWOOLE]; - yield 'unit' => [Images::UNIT]; + yield 'fpm' => [Images::fpm()]; + yield 'apache' => [Images::apache()]; + yield 'cli' => [Images::cli()]; + yield 'frankenphp' => [Images::frankenphp()]; + yield 'swoole' => [Images::swoole()]; + yield 'unit' => [Images::unit()]; } #[DataProvider('images')] @@ -32,17 +32,17 @@ public function testTheImageRunsTheRepositorysPhp(string $image): void { $run = DockerRuntime::run($image, ['php', self::repo() . '/tests/Runtime/Fixtures/smoke.php'], $this->env(), []); - $this->assertSame(0, $run['exitCode'], $run['stderr']); - $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout']); + $this->assertSame(0, $run['exitCode'], $image . ': ' . $run['stderr']); + $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout'], $image); } /** The Swoole guard test needs ext-pcntl next to ext-swoole: `docker/swoole/Dockerfile` adds it to the image. */ #[MaximumDuration(60000)] public function testTheSwooleImageLoadsSwooleAndPcntl(): void { - $run = DockerRuntime::run(Images::SWOOLE, ['php', '-m'], $this->env(), []); + $run = DockerRuntime::run(Images::swoole(), ['php', '-m'], $this->env(), []); - $this->assertSame(0, $run['exitCode'], $run['stderr']); + $this->assertSame(0, $run['exitCode'], Images::swoole() . ': ' . $run['stderr']); $modules = preg_split('/\R/', $run['stdout']); $this->assertIsArray($modules); $this->assertContains('swoole', $modules); diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index 1e045b3..8800516 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -43,9 +43,6 @@ final class DockerRuntime private const string NAME_PREFIX = 'agentcat-rt-'; - /** The images whose main process is PHP CLI: PHP must not be PID 1 there (header conflict 11). */ - private const array INIT_IMAGES = [Images::CLI, Images::SWOOLE]; - /** The test's output directory is `/runtime-` (`RuntimeTestCase::out()`). */ public const string OUTPUT_DIRECTORY_PREFIX = 'runtime-'; @@ -108,7 +105,7 @@ public static function repo(): string * @param non-empty-list $command the command, after the image's entrypoint * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own * @param array $mounts host path (in the output directory) => container path - * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it + * @param bool $init `--init`; `Images::cli()` and `Images::swoole()` always get it * @param float $timeout how long the port may take to accept a connection * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) * @param float $lifetime how long the container may run at most: longer than any runtime test @@ -162,7 +159,7 @@ public static function run(string $image, array $command, array $env, array $mou * @param list $command * @param array $env * @param array $mounts host path (in the output directory) => container path - * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it + * @param bool $init `--init`; `Images::cli()` and `Images::swoole()` always get it * * @return list */ @@ -206,9 +203,9 @@ public static function removeStale(): void public static function makeRemovable(string $dir): void { $name = self::newName(); - $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::inOutputDirectory($dir), '/out'), Images::CLI, 'chmod', '-R', 'a+rwX', '/out']); + $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::inOutputDirectory($dir), '/out'), Images::cli(), 'chmod', '-R', 'a+rwX', '/out']); if (!$run->isSuccessful()) { - throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::CLI, $run->getErrorOutput())); + throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::cli(), $run->getErrorOutput())); } } @@ -380,7 +377,7 @@ private static function options(string $image, string $name, array $env, array $ if ($remove) { $options[] = '--rm'; } - if ($init || in_array($image, self::INIT_IMAGES, true)) { + if ($init || in_array($image, Images::initImages(), true)) { $options[] = '--init'; } foreach ($mounts as $host => $container) { diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php index e088181..72760ab 100644 --- a/tests/Runtime/Support/Images.php +++ b/tests/Runtime/Support/Images.php @@ -6,31 +6,87 @@ /** * The Docker images of the runtime tests, pinned by tag (header conflict 10). The CI job pulls them before the suite, - * and builds `CLI` and `SWOOLE`; a test never pulls (`docker run --pull never`). + * and builds `cli()` and `swoole()`; a test never pulls (`docker run --pull never`). + * + * With `AGENTCAT_RUNTIME_IMAGES=latest` (the nightly `runtime-latest` job) every method returns the floating tag of + * the same line instead: the latest patch of PHP 8.3, FrankenPHP 1, Swoole 6 and Unit. The two local images are then + * built with `--build-arg BASE=` and tagged `:latest`. */ final class Images { + public const string ENVIRONMENT_VARIABLE = 'AGENTCAT_RUNTIME_IMAGES'; + + /** The `--build-arg BASE` of `tests/Runtime/docker/Dockerfile` in latest mode. */ + public const string CLI_BASE_LATEST = 'php:8.3-cli-bookworm'; + + /** + * The `--build-arg BASE` of `tests/Runtime/docker/swoole/Dockerfile` in latest mode. The image has no `6-php8.3` + * tag: `php8.3` is the newest Swoole on PHP 8.3 (6.3.0RC1 on 2026-10-20), so the nightly sees a Swoole 6.3 release early. + */ + public const string SWOOLE_BASE_LATEST = 'phpswoole/swoole:php8.3'; + /** Its stop signal is SIGQUIT: a graceful stop. */ - public const string FPM = 'php:8.3.35-fpm-bookworm'; + public static function fpm(): string + { + return self::pick('php:8.3.35-fpm-bookworm', 'php:8.3-fpm-bookworm'); + } /** Its stop signal is SIGWINCH: a graceful stop. */ - public const string APACHE = 'php:8.3.35-apache-bookworm'; + public static function apache(): string + { + return self::pick('php:8.3.35-apache-bookworm', 'php:8.3-apache-bookworm'); + } /** * Built locally: `docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker` (the official image plus - * ext-pcntl). Its stop signal is SIGTERM, as for `FRANKENPHP`, `SWOOLE` and `UNIT`. + * ext-pcntl). Its stop signal is SIGTERM, as for `frankenphp()`, `swoole()` and `unit()`. */ - public const string CLI = 'agentcat-runtime-cli:8.3.35'; + public static function cli(): string + { + return self::pick('agentcat-runtime-cli:8.3.35', 'agentcat-runtime-cli:latest'); + } - public const string FRANKENPHP = 'dunglas/frankenphp:1.13.1-php8.3.35-bookworm'; + public static function frankenphp(): string + { + return self::pick('dunglas/frankenphp:1.13.1-php8.3.35-bookworm', 'dunglas/frankenphp:1-php8.3-bookworm'); + } /** * Built locally: `docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole` * (`phpswoole/swoole:6.2.3-php8.3`, PHP 8.3.33, plus ext-pcntl). Its entrypoint splits the command at spaces * (`exec $@`): run a script, not `php -r`. */ - public const string SWOOLE = 'agentcat-runtime-swoole:6.2.3-php8.3'; + public static function swoole(): string + { + return self::pick('agentcat-runtime-swoole:6.2.3-php8.3', 'agentcat-runtime-swoole:latest'); + } /** NGINX Unit with PHP 8.3.26 (ruling P19). */ - public const string UNIT = 'unit:1.34.2-php8.3'; + public static function unit(): string + { + return self::pick('unit:1.34.2-php8.3', 'unit:php8.3'); + } + + /** + * The images whose main process is PHP CLI: PHP must not be PID 1 there (header conflict 11). + * + * @return list + */ + public static function initImages(): array + { + return [self::cli(), self::swoole()]; + } + + private static function pick(string $pinned, string $latest): string + { + $mode = getenv(self::ENVIRONMENT_VARIABLE); + if ($mode === false || $mode === '') { + return $pinned; + } + if ($mode === 'latest') { + return $latest; + } + + throw new \InvalidArgumentException(sprintf('%s must be empty or "latest", got "%s".', self::ENVIRONMENT_VARIABLE, $mode)); + } } diff --git a/tests/Runtime/UnitTest.php b/tests/Runtime/UnitTest.php index b311fc7..6575a0e 100644 --- a/tests/Runtime/UnitTest.php +++ b/tests/Runtime/UnitTest.php @@ -97,7 +97,7 @@ private function unit(string $diagnostics): array 'options' => ['admin' => ['log_errors' => '1']], ]], ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR))); - $runtime = DockerRuntime::start(Images::UNIT, ['unitd', '--no-daemon', '--control', 'unix:/var/run/control.unit.sock'], $env, [$state => self::STATE_DIRECTORY, $out => '/out'], port: $port); + $runtime = DockerRuntime::start(Images::unit(), ['unitd', '--no-daemon', '--control', 'unix:/var/run/control.unit.sock'], $env, [$state => self::STATE_DIRECTORY, $out => '/out'], port: $port); $this->assertFalse(DockerRuntime::accepts($port, '127.0.0.2'), 'Unit listens beyond 127.0.0.1'); return [$runtime, 'http://127.0.0.1:' . $port]; diff --git a/tests/Runtime/docker/Dockerfile b/tests/Runtime/docker/Dockerfile index 66cb64a..559b5f7 100644 --- a/tests/Runtime/docker/Dockerfile +++ b/tests/Runtime/docker/Dockerfile @@ -1,3 +1,4 @@ # The CLI image of the runtime tests: the official image plus ext-pcntl for the signal drain (D39). -FROM php:8.3.35-cli-bookworm +ARG BASE=php:8.3.35-cli-bookworm +FROM ${BASE} RUN docker-php-ext-install pcntl diff --git a/tests/Runtime/docker/swoole/Dockerfile b/tests/Runtime/docker/swoole/Dockerfile index 2598c1c..9830c86 100644 --- a/tests/Runtime/docker/swoole/Dockerfile +++ b/tests/Runtime/docker/swoole/Dockerfile @@ -1,3 +1,4 @@ # The Swoole image of the runtime tests: phpswoole/swoole plus ext-pcntl, so the Swoole guard test has a signal handler to guard. -FROM phpswoole/swoole:6.2.3-php8.3 +ARG BASE=phpswoole/swoole:6.2.3-php8.3 +FROM ${BASE} RUN docker-php-ext-install pcntl diff --git a/tests/Unit/RuntimeSupport/ImagesTest.php b/tests/Unit/RuntimeSupport/ImagesTest.php new file mode 100644 index 0000000..d2418c1 --- /dev/null +++ b/tests/Unit/RuntimeSupport/ImagesTest.php @@ -0,0 +1,109 @@ +before = getenv(Images::ENVIRONMENT_VARIABLE); + } + + #[\Override] + protected function tearDown(): void + { + $this->set($this->before === false ? null : $this->before); + } + + public function testEveryImageIsPinnedByDefault(): void + { + $this->set(null); + + $this->assertSame($this->pinned(), $this->images()); + } + + public function testAnEmptyValueMeansPinned(): void + { + $this->set(''); + + $this->assertSame($this->pinned(), $this->images()); + } + + public function testLatestSelectsTheFloatingTagOfEveryImage(): void + { + $this->set('latest'); + + $this->assertSame([ + 'fpm' => 'php:8.3-fpm-bookworm', + 'apache' => 'php:8.3-apache-bookworm', + 'cli' => 'agentcat-runtime-cli:latest', + 'frankenphp' => 'dunglas/frankenphp:1-php8.3-bookworm', + 'swoole' => 'agentcat-runtime-swoole:latest', + 'unit' => 'unit:php8.3', + ], $this->images()); + $this->assertSame(['agentcat-runtime-cli:latest', 'agentcat-runtime-swoole:latest'], Images::initImages()); + } + + public function testAnUnknownValueIsRefused(): void + { + $this->set('newest'); + + $thrown = Thrown::by(\InvalidArgumentException::class, fn(): string => Images::fpm()); + + $this->assertSame('AGENTCAT_RUNTIME_IMAGES must be empty or "latest", got "newest".', $thrown->getMessage()); + } + + public function testTheBuildBasesMatchThePinnedDockerfiles(): void + { + $root = dirname(__DIR__, 3) . '/tests/Runtime/docker'; + + $this->assertStringContainsString("\nARG BASE=php:8.3.35-cli-bookworm\nFROM \${BASE}\n", (string) file_get_contents($root . '/Dockerfile')); + $this->assertStringContainsString("\nARG BASE=phpswoole/swoole:6.2.3-php8.3\nFROM \${BASE}\n", (string) file_get_contents($root . '/swoole/Dockerfile')); + + $this->set('latest'); + $workflow = (string) file_get_contents(dirname(__DIR__, 3) . '/.github/workflows/nightly.yml'); + $this->assertStringContainsString('--build-arg BASE=' . Images::CLI_BASE_LATEST . ' --tag ' . Images::cli() . ' ', $workflow); + $this->assertStringContainsString('--build-arg BASE=' . Images::SWOOLE_BASE_LATEST . ' --tag ' . Images::swoole() . ' ', $workflow); + } + + /** @return array */ + private function pinned(): array + { + return [ + 'fpm' => 'php:8.3.35-fpm-bookworm', + 'apache' => 'php:8.3.35-apache-bookworm', + 'cli' => 'agentcat-runtime-cli:8.3.35', + 'frankenphp' => 'dunglas/frankenphp:1.13.1-php8.3.35-bookworm', + 'swoole' => 'agentcat-runtime-swoole:6.2.3-php8.3', + 'unit' => 'unit:1.34.2-php8.3', + ]; + } + + /** @return array */ + private function images(): array + { + return [ + 'fpm' => Images::fpm(), + 'apache' => Images::apache(), + 'cli' => Images::cli(), + 'frankenphp' => Images::frankenphp(), + 'swoole' => Images::swoole(), + 'unit' => Images::unit(), + ]; + } + + private function set(?string $value): void + { + putenv($value === null ? Images::ENVIRONMENT_VARIABLE : Images::ENVIRONMENT_VARIABLE . '=' . $value); + } +} From ebadab1c080797b3527db228d71f6e9e8636a21c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:27:16 +0000 Subject: [PATCH 32/68] test: refresh the OpenAPI snapshot with the agentcat:custom event type --- tests/Fixtures/schema/SOURCE.md | 6 +++--- tests/Fixtures/schema/publish-event-request.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Fixtures/schema/SOURCE.md b/tests/Fixtures/schema/SOURCE.md index d61b7dc..78059ab 100644 --- a/tests/Fixtures/schema/SOURCE.md +++ b/tests/Fixtures/schema/SOURCE.md @@ -1,7 +1,7 @@ # publish-event-request.json - Source: https://api.agentcat.com/v1/schema/public/?format=json (OpenAPI 3.0.3) -- Fetched: 2026-10-17 +- Fetched: 2026-10-20 - JSON pointer: `/paths/~1v1~1public~1events~1/post/requestBody/content/application~1json/schema` - Command: @@ -10,8 +10,8 @@ php -r '$d = json_decode(file_get_contents("https://api.agentcat.com/v1/schema/p sha256sum tests/Fixtures/schema/publish-event-request.json ``` -- Digest (6353 bytes): +- Digest (6388 bytes): ``` -9dac28d9ce0fed0f108ec28d2bd1c2b2e8b894a606bd6ff79a235d1876e5ab56 tests/Fixtures/schema/publish-event-request.json +ef2e2f996f1fbe55d81225869a129c754faa00c9d0cd3a98794305ceb4ec054d tests/Fixtures/schema/publish-event-request.json ``` diff --git a/tests/Fixtures/schema/publish-event-request.json b/tests/Fixtures/schema/publish-event-request.json index c4fe2c4..d1c6b8c 100644 --- a/tests/Fixtures/schema/publish-event-request.json +++ b/tests/Fixtures/schema/publish-event-request.json @@ -46,7 +46,8 @@ "mcp:tasks/result", "mcp:tasks/list", "mcp:tasks/cancel", - "agentcat:identify" + "agentcat:identify", + "agentcat:custom" ] }, "is_error": { From 403de74ada1a78b894547b8bddd1c25110783d05 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:27:16 +0000 Subject: [PATCH 33/68] test: name the images of the containers in every failing runtime test --- tests/Runtime/Support/DockerRuntime.php | 20 +++++++++++++++++ tests/Runtime/Support/RuntimeTestCase.php | 26 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index 8800516..79880d8 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -59,8 +59,27 @@ final class DockerRuntime private static ?string $problem = null; + /** @var array the images of the containers that this process started since `forgetImages()` */ + private static array $images = []; + private function __construct(private readonly string $name) {} + /** + * The images that this process started a container from since `forgetImages()`, in start order: a failing test + * names them (`RuntimeTestCase::onNotSuccessfulTest()`). + * + * @return list + */ + public static function imagesUsed(): array + { + return array_keys(self::$images); + } + + public static function forgetImages(): void + { + self::$images = []; + } + /** `AGENTCAT_RUNTIME_TESTS=1`: the runtime tests run, and a Docker that does not answer fails them. */ public static function requested(): bool { @@ -368,6 +387,7 @@ private static function options(string $image, string $name, array $env, array $ throw new \InvalidArgumentException(sprintf('A container needs %s on http://127.0.0.1:, the collector of the test or a closed port: pass RuntimeTestCase::env()', $required)); } } + self::$images[$image] = true; $repo = self::repo(); $options = ['--pull', 'never', '--name', $name, '--network', 'host', '--mount', self::bind($repo, $repo, readonly: true), '--workdir', $repo]; foreach ([Constants::DEFAULT_API_BASE_URL, Constants::DEFAULT_DIAGNOSTICS_ENDPOINT] as $default) { diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index abb4aeb..96f8c88 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -10,6 +10,7 @@ use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\HttpHarness; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; /** @@ -84,6 +85,7 @@ static function (): void { #[\Override] protected function setUp(): void { + DockerRuntime::forgetImages(); if (!DockerRuntime::requested()) { self::markTestSkipped('The runtime tests run with AGENTCAT_RUNTIME_TESTS=1'); } @@ -118,6 +120,30 @@ static function () use ($out): void { ]); } + /** + * A failing runtime test names the images of the containers it started (`AGENTCAT_RUNTIME_IMAGES=latest` runs + * floating tags, so a nightly failure must say which image broke). The original throwable stays as `previous`. + */ + #[\Override] + protected function invokeTestMethod(string $methodName, array $testArguments): mixed + { + try { + return parent::invokeTestMethod($methodName, $testArguments); + } catch (\Throwable $t) { + $images = DockerRuntime::imagesUsed(); + $isExpectation = $t instanceof ExpectationFailedException; + if ($images === [] || (!$isExpectation && str_starts_with($t::class, 'PHPUnit\\'))) { // a skip, an incomplete test or fail() + throw $t; + } + $message = $t->getMessage() . "\nRuntime images: " . implode(', ', $images); + if ($isExpectation) { + throw new ExpectationFailedException($message, $t->getComparisonFailure(), $t); + } + + throw new \RuntimeException(sprintf('%s: %s', $t::class, $message), 0, $t); + } + } + /** The repository's absolute path, which every container mounts read-only at the same path. */ final protected static function repo(): string { From 1f997824e5a0866979f2e7b9d599ff89779cbe73 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:27:16 +0000 Subject: [PATCH 34/68] ci: use the Swoole 6.2 line for latest, add step timeouts and a pull request concurrency group to the nightly --- .github/workflows/nightly.yml | 24 +++++++++++++++++++++++- tests/Runtime/Support/Images.php | 6 +++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ee46589..d26adf7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -19,6 +19,11 @@ permissions: contents: read issues: write +# A new push cancels an older run of the same pull request; scheduled and dispatched runs are never cancelled. +concurrency: + group: nightly-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: mcp-sdk-dev-main: name: mcp/sdk dev-main (PHP 8.5) @@ -38,9 +43,12 @@ jobs: - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 run: composer remove --dev --no-update symfony/mcp-bundle - name: Install mcp/sdk dev-main + timeout-minutes: 8 run: composer require --dev --no-interaction -W "mcp/sdk:dev-main as 0.9.99" - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G --configuration=phpstan.no-bundle.neon + timeout-minutes: 6 - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + timeout-minutes: 12 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -66,9 +74,12 @@ jobs: with: dependency-versions: highest - name: Install laravel/mcp dev-main + timeout-minutes: 8 run: composer require --dev --no-interaction -W "laravel/mcp:dev-main as 1.0.99" - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + timeout-minutes: 12 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -94,9 +105,12 @@ jobs: with: dependency-versions: highest - name: Install symfony/mcp-bundle dev-main with mcp/sdk 0.9 + timeout-minutes: 8 run: composer require --dev --no-interaction -W "symfony/mcp-bundle:dev-main as 0.14.99" "mcp/sdk:^0.9" - run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G + timeout-minutes: 6 - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + timeout-minutes: 12 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -124,6 +138,7 @@ jobs: # ergebnis/composer-normalize caps PHP below the nightly. composer-options: --ignore-platform-req=php+ - run: vendor/bin/phpunit + timeout-minutes: 20 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -149,6 +164,7 @@ jobs: with: dependency-versions: highest - run: vendor/bin/infection --threads=max --logger-github + timeout-minutes: 50 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -174,6 +190,7 @@ jobs: with: dependency-versions: highest - run: composer audit + timeout-minutes: 5 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -199,6 +216,7 @@ jobs: with: dependency-versions: highest - run: vendor/bin/phpunit --group network + timeout-minutes: 5 - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -224,17 +242,20 @@ jobs: with: dependency-versions: highest - name: Build the CLI and Swoole images on the latest bases and pull the others + timeout-minutes: 8 run: | docker build --pull --build-arg BASE=php:8.3-cli-bookworm --tag agentcat-runtime-cli:latest tests/Runtime/docker - docker build --pull --build-arg BASE=phpswoole/swoole:php8.3 --tag agentcat-runtime-swoole:latest tests/Runtime/docker/swoole + docker build --pull --build-arg BASE=phpswoole/swoole:6.2-php8.3 --tag agentcat-runtime-swoole:latest tests/Runtime/docker/swoole for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm dunglas/frankenphp:1-php8.3-bookworm unit:php8.3; do docker pull "$image"; done - name: Record the resolved PHP version of each image + timeout-minutes: 2 run: | for image in php:8.3-fpm-bookworm php:8.3-apache-bookworm agentcat-runtime-cli:latest dunglas/frankenphp:1-php8.3-bookworm agentcat-runtime-swoole:latest unit:php8.3; do printf '%s: ' "$image" docker run --rm --pull never --network none --entrypoint php "$image" -r 'echo PHP_VERSION, "\n";' done - run: vendor/bin/phpunit --testsuite Runtime --group runtime + timeout-minutes: 15 env: AGENTCAT_RUNTIME_TESTS: '1' AGENTCAT_RUNTIME_IMAGES: latest @@ -260,6 +281,7 @@ jobs: php-version: '8.3' coverage: none - name: Install the dist in a fresh project and track an mcp/sdk server + timeout-minutes: 8 run: tests/bin/package-probe.sh "dev-main as 0.9.99" - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php index 72760ab..64e2c98 100644 --- a/tests/Runtime/Support/Images.php +++ b/tests/Runtime/Support/Images.php @@ -20,10 +20,10 @@ final class Images public const string CLI_BASE_LATEST = 'php:8.3-cli-bookworm'; /** - * The `--build-arg BASE` of `tests/Runtime/docker/swoole/Dockerfile` in latest mode. The image has no `6-php8.3` - * tag: `php8.3` is the newest Swoole on PHP 8.3 (6.3.0RC1 on 2026-10-20), so the nightly sees a Swoole 6.3 release early. + * The `--build-arg BASE` of `tests/Runtime/docker/swoole/Dockerfile` in latest mode: the line of the pinned tag. + * The image has no `6-php8.3` tag, and `php8.3` is the newest Swoole, release candidates included. */ - public const string SWOOLE_BASE_LATEST = 'phpswoole/swoole:php8.3'; + public const string SWOOLE_BASE_LATEST = 'phpswoole/swoole:6.2-php8.3'; /** Its stop signal is SIGQUIT: a graceful stop. */ public static function fpm(): string From d1629d0fa82d8768f2a1a3c010da887fa43270b0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:31:54 +0000 Subject: [PATCH 35/68] build: require agentcat/api 0.0.4, which lists the agentcat:custom event type --- composer.json | 2 +- tests/Unit/Internal/Wire/WireMapperTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 54172b0..e5905f4 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "ext-json": "*", "ext-mbstring": "*", "composer-runtime-api": "^2.2", - "agentcat/api": "~0.0.3", + "agentcat/api": "~0.0.4", "psr/log": "^2.0 || ^3.0" }, "require-dev": { diff --git a/tests/Unit/Internal/Wire/WireMapperTest.php b/tests/Unit/Internal/Wire/WireMapperTest.php index 8917af6..4aadf34 100644 --- a/tests/Unit/Internal/Wire/WireMapperTest.php +++ b/tests/Unit/Internal/Wire/WireMapperTest.php @@ -4,6 +4,7 @@ namespace AgentCat\Tests\Unit\Internal\Wire; +use AgentCat\Api\Model\PublishEventRequest; use AgentCat\Event; use AgentCat\Internal\Constants; use AgentCat\Internal\Events\EventFields; @@ -362,4 +363,17 @@ public function testNestedEmptyStdClassStaysObject(): void $this->assertStringContainsString('"properties":{"device":{}}', $body); } + + /** @return iterable */ + public static function sentEventTypes(): iterable + { + yield 'tools/call' => [Constants::TOOLS_CALL_EVENT_TYPE]; + yield 'custom' => [Constants::CUSTOM_EVENT_TYPE]; + } + + #[DataProvider('sentEventTypes')] + public function testEveryEventTypeTheSdkSendsIsKnownToTheApiModel(string $eventType): void + { + $this->assertContains($eventType, PublishEventRequest::EVENT_TYPE_KNOWN_VALUES); + } } From eabc53e799b987873d9f1d56d06dc25721365c9a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:41:25 +0000 Subject: [PATCH 36/68] test: snapshot every member of the library classes AgentCat touches, and diff them nightly --- .github/workflows/nightly.yml | 46 + tests/Contract/Snapshot/MemberSnapshot.php | 193 ++ tests/Contract/Snapshot/SnapshotDiff.php | 215 ++ tests/Contract/Snapshot/SnapshotDiffTest.php | 216 ++ tests/Contract/Snapshot/SnapshotTest.php | 77 + .../Contract/snapshots/laravel-mcp-1.0.0.json | 882 ++++++ .../Contract/snapshots/laravel-mcp-1.0.2.json | 905 ++++++ .../Contract/snapshots/laravel-mcp-1.1.0.json | 905 ++++++ tests/Contract/snapshots/mcp-sdk-0.8.1.json | 2196 ++++++++++++++ tests/Contract/snapshots/mcp-sdk-0.9.0.json | 2701 +++++++++++++++++ tests/bin/member-snapshot.php | 58 + 11 files changed, 8394 insertions(+) create mode 100644 tests/Contract/Snapshot/MemberSnapshot.php create mode 100644 tests/Contract/Snapshot/SnapshotDiff.php create mode 100644 tests/Contract/Snapshot/SnapshotDiffTest.php create mode 100644 tests/Contract/Snapshot/SnapshotTest.php create mode 100644 tests/Contract/snapshots/laravel-mcp-1.0.0.json create mode 100644 tests/Contract/snapshots/laravel-mcp-1.0.2.json create mode 100644 tests/Contract/snapshots/laravel-mcp-1.1.0.json create mode 100644 tests/Contract/snapshots/mcp-sdk-0.8.1.json create mode 100644 tests/Contract/snapshots/mcp-sdk-0.9.0.json create mode 100644 tests/bin/member-snapshot.php diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d26adf7..9fda692 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -291,3 +291,49 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: .github/nightly-issue.sh "${{ github.job }}" + + member-snapshot: + name: Member snapshot (${{ matrix.library }} dev-main) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - library: mcp/sdk + constraint: 'mcp/sdk:dev-main as 0.9.99' + - library: laravel/mcp + constraint: 'laravel/mcp:dev-main as 1.0.99' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.5' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + if: matrix.library == 'mcp/sdk' + run: composer remove --dev --no-update symfony/mcp-bundle + - name: Install ${{ matrix.library }} dev-main + timeout-minutes: 8 + run: composer require --dev --no-interaction -W "${{ matrix.constraint }}" + - name: Diff the members against the newest committed snapshot + timeout-minutes: 5 + env: + LIBRARY: ${{ matrix.library }} + run: | + prefix="tests/Contract/snapshots/${LIBRARY//\//-}-" + newest="$(ls "$prefix"*.json | sort -V | tail -n 1)" + php tests/bin/member-snapshot.php "$LIBRARY" --diff "$newest" + - name: Fail on purpose (workflow_dispatch force_failure) + if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job + run: exit 1 + - name: Open or update the nightly issue + if: failure() && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: .github/nightly-issue.sh "${{ github.job }}" diff --git a/tests/Contract/Snapshot/MemberSnapshot.php b/tests/Contract/Snapshot/MemberSnapshot.php new file mode 100644 index 0000000..578ffd5 --- /dev/null +++ b/tests/Contract/Snapshot/MemberSnapshot.php @@ -0,0 +1,193 @@ +, return: string, static: bool, visibility: string} + * @phpstan-type ConstantShape array{visibility: string} + * @phpstan-type ClassShape array{constants: array, final: bool, methods: array, properties: array} + * @phpstan-type Snapshot array{classes: array, library: string, version: string} + */ +final class MemberSnapshot +{ + /** The library's root namespace, by Composer package name. */ + private const array NAMESPACES = [ + 'laravel/mcp' => 'Laravel\\Mcp\\', + 'mcp/sdk' => 'Mcp\\', + ]; + + /** @return list the Composer package names a snapshot can be taken of */ + public static function libraries(): array + { + return array_keys(self::NAMESPACES); + } + + /** + * The installed version of the library, without a leading `v` (`0.8.1`; `dev-main` on a branch install). + * + * @throws \InvalidArgumentException when the library is unknown or not installed + */ + public static function installedVersion(string $library): string + { + self::namespaceOf($library); + $version = InstalledVersions::isInstalled($library) ? InstalledVersions::getPrettyVersion($library) : null; + if ($version === null) { + throw new \InvalidArgumentException($library . ' is not installed'); + } + + return ltrim($version, 'v'); + } + + /** + * @return Snapshot + * + * @throws \InvalidArgumentException when the library is unknown or not installed + */ + public static function of(string $library): array + { + $classes = []; + foreach (self::classesOf($library) as $class) { + $classes[$class] = self::ofClass($class); + } + ksort($classes, SORT_STRING); + + return ['classes' => $classes, 'library' => $library, 'version' => self::installedVersion($library)]; + } + + /** + * The library's classes that the manifest names and that exist in the installed version, sorted. + * + * @return list + */ + public static function classesOf(string $library): array + { + $namespace = self::namespaceOf($library); + $names = PrivateMemberManifest::extendedClasses(); + array_push($names, ...array_keys(PrivateMemberManifest::EXTENDED)); + foreach (PrivateMemberManifest::entries() as $entry) { + $names[] = $entry['member']->class; + } + + $classes = []; + foreach (array_unique($names) as $name) { + if (str_starts_with($name, $namespace) && class_exists($name)) { + $classes[] = $name; + } + } + sort($classes, SORT_STRING); + + return $classes; + } + + /** @param Snapshot $snapshot */ + public static function toJson(array $snapshot): string + { + return json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; + } + + /** + * @return Snapshot + * + * @throws \InvalidArgumentException when the text is not the JSON of a snapshot + * @throws \JsonException + */ + public static function fromJson(string $json): array + { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || !is_array($data['classes'] ?? null) || !is_string($data['library'] ?? null) || !is_string($data['version'] ?? null)) { + throw new \InvalidArgumentException('not a member snapshot'); + } + + /** @var Snapshot $data */ + return $data; + } + + /** + * @param class-string $class + * + * @return ClassShape + */ + private static function ofClass(string $class): array + { + $reflection = new \ReflectionClass($class); + + $constants = []; + foreach ($reflection->getReflectionConstants() as $constant) { + if ($constant->getDeclaringClass()->getName() === $class) { + $constants[$constant->getName()] = ['visibility' => self::visibility($constant->isPublic(), $constant->isProtected())]; + } + } + + $properties = []; + foreach ($reflection->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() === $class) { + $properties[$property->getName()] = [ + 'readonly' => $property->isReadOnly(), + 'static' => $property->isStatic(), + 'type' => self::type($property->getType(), $class), + 'visibility' => self::visibility($property->isPublic(), $property->isProtected()), + ]; + } + } + + $methods = []; + foreach ($reflection->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() === $class) { + $parameters = []; + foreach ($method->getParameters() as $parameter) { + $parameters[] = [ + 'byReference' => $parameter->isPassedByReference(), + 'hasDefault' => $parameter->isDefaultValueAvailable(), + 'name' => $parameter->getName(), + 'type' => self::type($parameter->getType(), $class), + 'variadic' => $parameter->isVariadic(), + ]; + } + $methods[$method->getName()] = [ + 'abstract' => $method->isAbstract(), + 'final' => $method->isFinal(), + 'parameters' => $parameters, + 'return' => self::type($method->getReturnType(), $class), + 'static' => $method->isStatic(), + 'visibility' => self::visibility($method->isPublic(), $method->isProtected()), + ]; + } + } + + ksort($constants, SORT_STRING); + ksort($properties, SORT_STRING); + ksort($methods, SORT_STRING); + + return ['constants' => $constants, 'final' => $reflection->isFinal(), 'methods' => $methods, 'properties' => $properties]; + } + + /** + * The type as written, with `self` spelled as the class: PHP 8.5 reports `self` as the class name, earlier versions + * as `self`, and a snapshot must not depend on the PHP version that took it. + */ + private static function type(?\ReflectionType $type, string $class): string + { + return preg_replace('/(? $breaking + * @param list $other + */ + private function __construct( + private array $breaking, + private array $other, + ) {} + + /** + * @param Snapshot $old + * @param Snapshot $new + * @param list $readMembers `Class::$property` and `Class::method(...)`; any other string is ignored + */ + public static function between(array $old, array $new, array $readMembers): self + { + $properties = []; + $methods = []; + foreach ($readMembers as $member) { + if (preg_match('/^([A-Za-z0-9_\\\\]+)::\$(\w+)$/', $member, $match) === 1) { + $properties[$match[1]][$match[2]] = true; + } elseif (preg_match('/^([A-Za-z0-9_\\\\]+)::(\w+)\(/', $member, $match) === 1) { + $methods[$match[1]][$match[2]] = true; + } + } + + $breaking = []; + $other = []; + $classes = array_unique([...array_keys($old['classes']), ...array_keys($new['classes'])]); + sort($classes, SORT_STRING); + foreach ($classes as $class) { + $oldClass = $old['classes'][$class] ?? null; + $newClass = $new['classes'][$class] ?? null; + $readProperties = array_keys($properties[$class] ?? []); + $readMethods = array_keys($methods[$class] ?? []); + + if ($newClass === null) { + $other[] = $class . ': class removed'; + foreach ($readProperties as $name) { + if (isset($oldClass['properties'][$name])) { + $breaking[] = $class . '::$' . $name . ': ' . self::property($name, $oldClass['properties'][$name]) . ' → removed'; + } + } + foreach ($readMethods as $name) { + if (isset($oldClass['methods'][$name])) { + $breaking[] = $class . '::' . $name . '(): ' . self::method($name, $oldClass['methods'][$name]) . ' → removed'; + } + } + + continue; + } + if ($oldClass === null) { + $other[] = $class . ': class added'; + + continue; + } + + if ($oldClass['final'] !== $newClass['final']) { + $line = $class . ': ' . ($oldClass['final'] ? 'final' : 'not final') . ' → ' . ($newClass['final'] ? 'final' : 'not final'); + if ($newClass['final'] && $readMethods !== []) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + + self::compareProperties($class, $oldClass, $newClass, $readProperties, $breaking, $other); + self::compareMethods($class, $oldClass, $newClass, $readMethods, $breaking, $other); + self::compareConstants($class, $oldClass, $newClass, $other); + } + + sort($breaking, SORT_STRING); + sort($other, SORT_STRING); + + return new self($breaking, $other); + } + + /** @return list the changes that break AgentCat, each naming `Class::member` and old → new */ + public function breaking(): array + { + return $this->breaking; + } + + /** @return list every other difference */ + public function other(): array + { + return $this->other; + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $read + * @param list $breaking + * @param list $other + */ + private static function compareProperties(string $class, array $old, array $new, array $read, array &$breaking, array &$other): void + { + $added = array_diff_key($new['properties'], $old['properties']); + foreach ($old['properties'] as $name => $oldShape) { + $subject = $class . '::$' . $name; + $isRead = in_array($name, $read, true); + $newShape = $new['properties'][$name] ?? null; + if ($newShape === $oldShape) { + continue; + } + if ($newShape === null) { + $renamedTo = array_search($oldShape, $added, true); + if ($renamedTo !== false) { + unset($added[$renamedTo]); + $line = $subject . ': renamed to $' . $renamedTo; + } else { + $line = $subject . ': ' . self::property($name, $oldShape) . ' → removed'; + } + } else { + $line = $subject . ': ' . self::property($name, $oldShape) . ' → ' . self::property($name, $newShape); + } + if ($isRead) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + foreach ($added as $name => $shape) { + $other[] = $class . '::$' . $name . ': added (' . self::property($name, $shape) . ')'; + } + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $read + * @param list $breaking + * @param list $other + */ + private static function compareMethods(string $class, array $old, array $new, array $read, array &$breaking, array &$other): void + { + foreach ($old['methods'] as $name => $oldShape) { + $newShape = $new['methods'][$name] ?? null; + if ($newShape === $oldShape) { + continue; + } + $line = $class . '::' . $name . '(): ' . self::method($name, $oldShape) . ' → ' . ($newShape === null ? 'removed' : self::method($name, $newShape)); + if (in_array($name, $read, true)) { + $breaking[] = $line; + } else { + $other[] = $line; + } + } + foreach (array_diff_key($new['methods'], $old['methods']) as $name => $shape) { + $other[] = $class . '::' . $name . '(): added (' . self::method($name, $shape) . ')'; + } + } + + /** + * @param ClassShape $old + * @param ClassShape $new + * @param list $other + */ + private static function compareConstants(string $class, array $old, array $new, array &$other): void + { + foreach ($old['constants'] as $name => $shape) { + $newShape = $new['constants'][$name] ?? null; + if ($newShape !== $shape) { + $other[] = $class . '::' . $name . ': ' . $shape['visibility'] . ' → ' . ($newShape === null ? 'removed' : $newShape['visibility']); + } + } + foreach (array_diff_key($new['constants'], $old['constants']) as $name => $shape) { + $other[] = $class . '::' . $name . ': added (' . $shape['visibility'] . ')'; + } + } + + /** @param array{readonly: bool, static: bool, type: string, visibility: string} $shape */ + private static function property(string $name, array $shape): string + { + return self::words([$shape['visibility'], $shape['static'] ? 'static' : '', $shape['readonly'] ? 'readonly' : '', $shape['type'], '$' . $name]); + } + + /** @param array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} $shape */ + private static function method(string $name, array $shape): string + { + $parameters = []; + foreach ($shape['parameters'] as $parameter) { + $parameters[] = self::words([ + $parameter['type'], + ($parameter['byReference'] ? '&' : '') . ($parameter['variadic'] ? '...' : '') . '$' . $parameter['name'] . ($parameter['hasDefault'] ? ' = …' : ''), + ]); + } + + return self::words([$shape['visibility'], $shape['abstract'] ? 'abstract' : '', $shape['final'] ? 'final' : '', $shape['static'] ? 'static' : '', 'function']) + . ' ' . $name . '(' . implode(', ', $parameters) . ')' . ($shape['return'] === '' ? '' : ': ' . $shape['return']); + } + + /** @param list $words */ + private static function words(array $words): string + { + return implode(' ', array_filter($words, static fn(string $word): bool => $word !== '')); + } +} diff --git a/tests/Contract/Snapshot/SnapshotDiffTest.php b/tests/Contract/Snapshot/SnapshotDiffTest.php new file mode 100644 index 0000000..d618486 --- /dev/null +++ b/tests/Contract/Snapshot/SnapshotDiffTest.php @@ -0,0 +1,216 @@ +snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['partList' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + $this->assertSame(['Mcp\\Server\\Builder::$parts: renamed to $partList'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testARenamedMemberThatNoOneReadsIsNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['partList' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, []); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder::$parts: renamed to $partList'], $diff->other()); + } + + public function testARemovedOrRetypedMemberIsBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $retyped = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('?array')])]); + $removed = $this->snapshot([self::BUILDER => $this->shape()]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts → private ?array $parts'], + SnapshotDiff::between($old, $retyped, [self::PARTS])->breaking(), + ); + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts → removed'], + SnapshotDiff::between($old, $removed, [self::PARTS])->breaking(), + ); + } + + public function testARemovedClassBreaksTheMembersAgentCatReads(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $this->snapshot([]), [self::PARTS]); + + $this->assertSame(['Mcp\\Server\\Builder::$parts: private array $parts → removed'], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder: class removed'], $diff->other()); + } + + public function testAnAddedMethodIsNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')], methods: ['build' => $this->method([], \Mcp\Server::class)])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder::build(): added (public function build(): Mcp\\Server)'], $diff->other()); + } + + public function testAnAddedClassAndAChangedConstantAreNotBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(constants: ['A' => 'public', 'B' => 'public'])]); + $new = $this->snapshot([ + self::BUILDER => $this->shape(constants: ['A' => 'protected', 'C' => 'public']), + self::RESULT => $this->shape(), + ]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS, self::CONSTRUCTOR]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame( + [ + 'Mcp\\Schema\\Result\\ListToolsResult: class added', + 'Mcp\\Server\\Builder::A: public → protected', + 'Mcp\\Server\\Builder::B: public → removed', + 'Mcp\\Server\\Builder::C: added (public)', + ], + $diff->other(), + ); + } + + public function testAFinalParentIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + $new = $this->snapshot([self::RESULT => $this->shape(final: true, methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult: not final → final'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testAClassNobodyExtendsTurningFinalIsNotBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::BUILDER => $this->shape()]), + $this->snapshot([self::BUILDER => $this->shape(final: true)]), + [self::PARTS, self::CONSTRUCTOR], + ); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\Builder: not final → final'], $diff->other()); + } + + public function testAChangedExtendedSignatureIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string'], '')])]); + $new = $this->snapshot([self::RESULT => $this->shape(methods: ['__construct' => $this->method(['tools' => 'array', 'nextCursor' => '?string', 'limit' => 'int'], '')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame( + ['Mcp\\Schema\\Result\\ListToolsResult::__construct(): public function __construct(array $tools, ?string $nextCursor) → public function __construct(array $tools, ?string $nextCursor, int $limit)'], + $diff->breaking(), + ); + } + + public function testAChangedMethodNobodyDependsOnIsNotBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(methods: ['jsonSerialize' => $this->method([], 'array')])]); + $new = $this->snapshot([self::RESULT => $this->shape(methods: ['jsonSerialize' => $this->method([], 'mixed')])]); + + $diff = SnapshotDiff::between($old, $new, [self::CONSTRUCTOR]); + + $this->assertSame([], $diff->breaking()); + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::jsonSerialize(): public function jsonSerialize(): array → public function jsonSerialize(): mixed'], $diff->other()); + } + + public function testAnIdenticalSnapshotHasNoLinesAndTheBundleEntriesAreIgnored(): void + { + $snapshot = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($snapshot, $snapshot, [self::PARTS, 'symfony/mcp-bundle: builder without a configurator']); + + $this->assertSame([], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testTheLinesAreSorted(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape()]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['b' => $this->property('int'), 'a' => $this->property('int')])]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$a: added (private int $a)', 'Mcp\\Server\\Builder::$b: added (private int $b)'], + SnapshotDiff::between($old, $new, [])->other(), + ); + } + + /** + * @param array $classes + * + * @return Snapshot + */ + private function snapshot(array $classes): array + { + return ['classes' => $classes, 'library' => 'mcp/sdk', 'version' => '0.0.0']; + } + + /** + * @param array $properties + * @param array, return: string, static: bool, visibility: string}> $methods + * @param array $constants constant name => visibility + * + * @return ClassShape + */ + private function shape(bool $final = false, array $properties = [], array $methods = [], array $constants = []): array + { + return [ + 'constants' => array_map(static fn(string $visibility): array => ['visibility' => $visibility], $constants), + 'final' => $final, + 'methods' => $methods, + 'properties' => $properties, + ]; + } + + /** @return array{readonly: bool, static: bool, type: string, visibility: string} */ + private function property(string $type): array + { + return ['readonly' => false, 'static' => false, 'type' => $type, 'visibility' => 'private']; + } + + /** + * @param array $parameters type by name + * + * @return array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} + */ + private function method(array $parameters, string $return): array + { + $list = []; + foreach ($parameters as $name => $type) { + $list[] = ['byReference' => false, 'hasDefault' => false, 'name' => $name, 'type' => $type, 'variadic' => false]; + } + + return ['abstract' => false, 'final' => false, 'parameters' => $list, 'return' => $return, 'static' => false, 'visibility' => 'public']; + } +} diff --git a/tests/Contract/Snapshot/SnapshotTest.php b/tests/Contract/Snapshot/SnapshotTest.php new file mode 100644 index 0000000..98902b6 --- /dev/null +++ b/tests/Contract/Snapshot/SnapshotTest.php @@ -0,0 +1,77 @@ + */ + public static function libraries(): array + { + $rows = []; + foreach (MemberSnapshot::libraries() as $library) { + $rows[$library] = [$library]; + } + + return $rows; + } + + #[DataProvider('libraries')] + public function testTheInstalledVersionMatchesItsCommittedSnapshot(string $library): void + { + $version = MemberSnapshot::installedVersion($library); + $path = __DIR__ . '/../snapshots/' . str_replace('/', '-', $library) . '-' . $version . '.json'; + if (!is_file($path)) { + self::markTestSkipped('no committed snapshot for ' . $library . ' ' . $version . ' (a dev-main leg)'); + } + + $committed = MemberSnapshot::fromJson((string) file_get_contents($path)); + $live = MemberSnapshot::of($library); + + $diff = SnapshotDiff::between($committed, $live, PrivateMemberManifest::members()); + $this->assertSame([], $diff->breaking(), 'breaking changes in ' . $library . ' ' . $version); + $this->assertSame([], $diff->other(), 'changes in ' . $library . ' ' . $version); + $this->assertSame($committed, $live); + } + + public function testTheSnapshotCoversEveryClassTheManifestNamesForTheLibrary(): void + { + $classes = array_keys(MemberSnapshot::of('mcp/sdk')['classes']); + + $this->assertContains(\Mcp\Server\Builder::class, $classes); + $this->assertContains(\Mcp\Schema\Result\ListToolsResult::class, $classes); + $this->assertNotContains(\Laravel\Mcp\Server::class, $classes); + $this->assertContains(\Laravel\Mcp\Server::class, array_keys(MemberSnapshot::of('laravel/mcp')['classes'])); + } + + public function testSelfIsSpelledAsTheClassWhateverThePhpVersion(): void + { + $success = MemberSnapshot::of('mcp/sdk')['classes'][\Mcp\Schema\Result\CallToolResult::class]['methods']['success'] ?? null; + + $this->assertNotNull($success); + $this->assertSame(\Mcp\Schema\Result\CallToolResult::class, $success['return']); + } + + public function testAnUnknownLibraryIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + MemberSnapshot::of('symfony/mcp-bundle'); + } + + public function testASnapshotSurvivesItsJson(): void + { + $snapshot = MemberSnapshot::of('mcp/sdk'); + + $this->assertSame($snapshot, MemberSnapshot::fromJson(MemberSnapshot::toJson($snapshot))); + } +} diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.0.json b/tests/Contract/snapshots/laravel-mcp-1.0.0.json new file mode 100644 index 0000000..a8b890d --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.0.0.json @@ -0,0 +1,882 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + } + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [] + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "constants": [], + "final": false, + "methods": { + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": [] + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + } + } + }, + "library": "laravel/mcp", + "version": "1.0.0" +} diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.2.json b/tests/Contract/snapshots/laravel-mcp-1.0.2.json new file mode 100644 index 0000000..d18637e --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.0.2.json @@ -0,0 +1,905 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + } + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [] + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "mirrorStructuredContent", + "type": "bool", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": { + "mirrorStructuredContent": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "protected" + } + } + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + } + } + }, + "library": "laravel/mcp", + "version": "1.0.2" +} diff --git a/tests/Contract/snapshots/laravel-mcp-1.1.0.json b/tests/Contract/snapshots/laravel-mcp-1.1.0.json new file mode 100644 index 0000000..bbf8e22 --- /dev/null +++ b/tests/Contract/snapshots/laravel-mcp-1.1.0.json @@ -0,0 +1,905 @@ +{ + "classes": { + "Laravel\\Mcp\\Server": { + "constants": { + "CACHEABLE_METHODS": { + "visibility": "public" + }, + "CAPABILITY_COMPLETIONS": { + "visibility": "public" + }, + "CAPABILITY_PROMPTS": { + "visibility": "public" + }, + "CAPABILITY_RESOURCES": { + "visibility": "public" + }, + "CAPABILITY_TOOLS": { + "visibility": "public" + } + }, + "final": false, + "methods": { + "__callStatic": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "arguments", + "type": "array", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Testing\\PendingTestResponse|Laravel\\Mcp\\Server\\Testing\\TestResponse|Laravel\\Mcp\\Server\\Testing\\TestListResponse", + "static": true, + "visibility": "public" + }, + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "addCapability": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "value", + "type": "bool", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "addMethod": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "method", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "boot": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "cacheHints": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "createContext": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Laravel\\Mcp\\Server\\ServerContext", + "static": false, + "visibility": "public" + }, + "detectUiCapability": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "protected" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "rawMessage", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "icons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveAttribute": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "resolveAttributes": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "attributeClass", + "type": "string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveCacheHints": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolveResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Server\\Resource", + "static": false, + "visibility": "protected" + }, + "resolvedCapabilities": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "resolvedIcons": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "resourceCacheable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "?Laravel\\Mcp\\Server\\Attributes\\Cacheable", + "static": false, + "visibility": "protected" + }, + "runMethodHandle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Traversable|Laravel\\Mcp\\Transport\\JsonRpcResponse|array", + "static": false, + "visibility": "protected" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "request", + "type": "?Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "start": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "validateProtocolMeta": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + } + }, + "properties": { + "attributeCache": { + "readonly": false, + "static": true, + "type": "array", + "visibility": "protected" + }, + "capabilities": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "defaultPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "extensions": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "instructions": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "maxPaginationLength": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "public" + }, + "methods": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "name": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + }, + "prompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "resources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "supportedProtocolVersion": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "tools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "protected" + }, + "transport": { + "readonly": false, + "static": false, + "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", + "visibility": "protected" + }, + "version": { + "readonly": false, + "static": false, + "type": "string", + "visibility": "protected" + } + } + }, + "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "constants": [], + "final": false, + "methods": { + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "Laravel\\Mcp\\Server\\ServerContext", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + } + }, + "properties": [] + }, + "Laravel\\Mcp\\Server\\ToolInvoker": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "mirrorStructuredContent", + "type": "bool", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "callHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "protected" + }, + "invoke": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + } + ], + "return": "Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "public" + }, + "isBinary": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "protected" + }, + "serializable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Laravel\\Mcp\\Server\\Tool", + "variadic": false + } + ], + "return": "callable", + "static": false, + "visibility": "protected" + }, + "toErrorMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "protected" + }, + "toErrorResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Response", + "static": false, + "visibility": "protected" + }, + "toJsonRpcException": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "mixed", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Exceptions\\JsonRpcException", + "static": false, + "visibility": "protected" + }, + "toJsonRpcResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\Transport\\JsonRpcResponse", + "static": false, + "visibility": "protected" + }, + "toJsonRpcStreamedResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Laravel\\Mcp\\Transport\\JsonRpcRequest", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "responses", + "type": "iterable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "serializable", + "type": "callable", + "variadic": false + } + ], + "return": "Generator", + "static": false, + "visibility": "protected" + }, + "toResponseFactory": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Laravel\\Mcp\\Response|Laravel\\Mcp\\ResponseFactory|array|string", + "variadic": false + } + ], + "return": "Laravel\\Mcp\\ResponseFactory", + "static": false, + "visibility": "private" + } + }, + "properties": { + "mirrorStructuredContent": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "protected" + } + } + }, + "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Illuminate\\Http\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handler", + "type": "?Closure", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "reply", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "stream", + "type": "?Closure", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "getHeaders": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "protected" + }, + "onReceive": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Illuminate\\Http\\Response|Symfony\\Component\\HttpFoundation\\StreamedResponse", + "static": false, + "visibility": "public" + }, + "send": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendStreamMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "protected" + }, + "statusCode": { + "abstract": false, + "final": false, + "parameters": [], + "return": "int", + "static": false, + "visibility": "protected" + }, + "stream": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "stream", + "type": "Closure", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handler": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + }, + "reply": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "protected" + }, + "request": { + "readonly": false, + "static": false, + "type": "Illuminate\\Http\\Request", + "visibility": "protected" + }, + "stream": { + "readonly": false, + "static": false, + "type": "?Closure", + "visibility": "protected" + } + } + } + }, + "library": "laravel/mcp", + "version": "1.1.0" +} diff --git a/tests/Contract/snapshots/mcp-sdk-0.8.1.json b/tests/Contract/snapshots/mcp-sdk-0.8.1.json new file mode 100644 index 0000000..943845b --- /dev/null +++ b/tests/Contract/snapshots/mcp-sdk-0.8.1.json @@ -0,0 +1,2196 @@ +{ + "classes": { + "Mcp\\Schema\\Result\\CallToolResult": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "isError", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "structuredContent", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "error": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "success": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + } + }, + "properties": { + "content": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + }, + "isError": { + "readonly": true, + "static": false, + "type": "bool", + "visibility": "public" + }, + "meta": { + "readonly": true, + "static": false, + "type": "?array", + "visibility": "public" + }, + "structuredContent": { + "readonly": true, + "static": false, + "type": "mixed", + "visibility": "public" + } + } + }, + "Mcp\\Schema\\Result\\ListToolsResult": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tools", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nextCursor", + "type": "?string", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\ListToolsResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "nextCursor": { + "readonly": true, + "static": false, + "type": "?string", + "visibility": "public" + }, + "tools": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + } + } + }, + "Mcp\\Server": { + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocol", + "type": "Mcp\\Server\\Protocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "statelessProtocol", + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "builder": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": true, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "public" + } + }, + "properties": { + "logger": { + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "protocol": { + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Protocol", + "visibility": "private" + }, + "statelessProtocol": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "visibility": "private" + } + } + }, + "Mcp\\Server\\Builder": { + "constants": [], + "final": true, + "methods": { + "add": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "definition", + "type": "Mcp\\Schema\\Tool|Mcp\\Schema\\ResourceDefinition|Mcp\\Schema\\ResourceTemplate|Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\ElementHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoader": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoaders": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loaders", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Notification\\NotificationHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Request\\RequestHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "size", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\ToolAnnotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputSchema", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "outputSchema", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "assemble": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "build": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server", + "static": false, + "visibility": "public" + }, + "buildStateless": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "supportedVersions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Stateless\\StatelessProtocol", + "static": false, + "visibility": "public" + }, + "createDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "static": false, + "visibility": "private" + }, + "detectCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eagerlyLoaded", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\ServerCapabilities", + "static": false, + "visibility": "private" + }, + "enableExtension": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "extensions", + "type": "Mcp\\Schema\\Extension\\ExtensionInterface", + "variadic": true + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "requestStateCodec": { + "abstract": false, + "final": false, + "parameters": [], + "return": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "static": false, + "visibility": "private" + }, + "resolve": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "setCachePolicy": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "policy", + "type": "Mcp\\Server\\Wire\\CachePolicy", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "serverCapabilities", + "type": "Mcp\\Schema\\ServerCapabilities", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setContainer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "container", + "type": "Psr\\Container\\ContainerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "discoverer", + "type": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscovery": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "basePath", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "scanDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "excludeDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cache", + "type": "?Psr\\SimpleCache\\CacheInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "namePatterns", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setEventDispatcher": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setHeaderValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "headerValidation", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInputRequiredLimits": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "maxRounds", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "roundTimeout", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInstructions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "instructions", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLazyLoading": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "lazyLoading", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLogger": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setModernVersions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "versions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setNotificationBus": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "bus", + "type": "Mcp\\Server\\Subscription\\NotificationBusInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setPaginationLimit": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "paginationLimit", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setProtocolVersion": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocolVersion", + "type": "Mcp\\Schema\\Enum\\ProtocolVersion", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setReferenceHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRegistry": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRequestState": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "ttl", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setResourceSubscriptionManager": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "subscriptionManager", + "type": "Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaGenerator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaGenerator", + "type": "Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setServerInfo": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "version", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "websiteUrl", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "sessionStore", + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionManager", + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcProbability", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcDivisor", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSubscriptionLifetime": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "seconds", + "type": "float", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutInputRequiredShim": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutModernEra": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + } + }, + "properties": { + "cachePolicy": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Wire\\CachePolicy", + "visibility": "private" + }, + "container": { + "readonly": false, + "static": false, + "type": "?Psr\\Container\\ContainerInterface", + "visibility": "private" + }, + "discoverer": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", + "visibility": "private" + }, + "discoveryBasePath": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "discoveryCache": { + "readonly": false, + "static": false, + "type": "?Psr\\SimpleCache\\CacheInterface", + "visibility": "private" + }, + "discoveryExcludeDirs": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryNamePatterns": { + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "discoveryScanDirs": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "readonly": false, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "explicitPrompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResourceTemplates": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitTools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMessages": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMethods": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensions": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "gcDivisor": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "gcProbability": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "hasCustomRegistry": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "headerValidation": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredRounds": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "inputRequiredShim": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredTimeout": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "instructions": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "lazyLoading": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loaders": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "logger": { + "readonly": false, + "static": false, + "type": "?Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "modernVersions": { + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "notificationBus": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", + "visibility": "private" + }, + "notificationHandlers": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "paginationLimit": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "parts": { + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "prompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "protocolVersion": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", + "visibility": "private" + }, + "referenceHandler": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "requestHandlers": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateKey": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "requestStateTtl": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "resourceTemplates": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "schemaGenerator": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "visibility": "private" + }, + "serverCapabilities": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\ServerCapabilities", + "visibility": "private" + }, + "serverInfo": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Implementation", + "visibility": "private" + }, + "sessionManager": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + }, + "sessionStore": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "visibility": "private" + }, + "subscriptionLifetime": { + "readonly": false, + "static": false, + "type": "float", + "visibility": "private" + }, + "subscriptionManager": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "visibility": "private" + }, + "tools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + } + }, + "Mcp\\Server\\Protocol": { + "constants": { + "INTERNAL_ERROR_MESSAGE": { + "visibility": "private" + }, + "SESSION_ACTIVE_REQUEST_META": { + "visibility": "public" + }, + "SESSION_LOGGING_LEVEL": { + "visibility": "public" + }, + "SESSION_OUTGOING_QUEUE": { + "visibility": "private" + }, + "SESSION_PENDING_REQUESTS": { + "visibility": "private" + }, + "SESSION_REQUEST_ID_COUNTER": { + "visibility": "private" + }, + "SESSION_RESPONSES": { + "visibility": "private" + } + }, + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "notificationHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messageFactory", + "type": "Mcp\\JsonRpc\\MessageFactory", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionManager", + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputRequiredShim", + "type": "?Mcp\\Server\\InputRequiredShim", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "requestStateCodec", + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "checkResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error|null", + "static": false, + "visibility": "public" + }, + "connect": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "consumeOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "destroySession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "dispatchEvent": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "object", + "static": false, + "visibility": "private" + }, + "doProcessInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "findResponseId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "getPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "handleFiberYield": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "yieldedValue", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "handleInvalidMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "exception", + "type": "Mcp\\Exception\\InvalidInputMessageException", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "hasInitializeRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "processInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "queueOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification|Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "resolveSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "?Mcp\\Server\\Session\\SessionInterface", + "static": false, + "visibility": "private" + }, + "sendNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "int", + "static": false, + "visibility": "public" + }, + "sendResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "?Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + } + }, + "properties": { + "eventDispatcher": { + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "inputRequiredShim": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\InputRequiredShim", + "visibility": "private" + }, + "logger": { + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "messageFactory": { + "readonly": true, + "static": false, + "type": "Mcp\\JsonRpc\\MessageFactory", + "visibility": "private" + }, + "notificationHandlers": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestHandlers": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateCodec": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "visibility": "private" + }, + "sessionManager": { + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + } + } + } + }, + "library": "mcp/sdk", + "version": "0.8.1" +} diff --git a/tests/Contract/snapshots/mcp-sdk-0.9.0.json b/tests/Contract/snapshots/mcp-sdk-0.9.0.json new file mode 100644 index 0000000..44609c0 --- /dev/null +++ b/tests/Contract/snapshots/mcp-sdk-0.9.0.json @@ -0,0 +1,2701 @@ +{ + "classes": { + "Mcp\\Schema\\Result\\CallToolResult": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "isError", + "type": "bool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "structuredContent", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "error": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + }, + "success": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "content", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\CallToolResult", + "static": true, + "visibility": "public" + } + }, + "properties": { + "content": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + }, + "isError": { + "readonly": true, + "static": false, + "type": "bool", + "visibility": "public" + }, + "meta": { + "readonly": true, + "static": false, + "type": "?array", + "visibility": "public" + }, + "structuredContent": { + "readonly": true, + "static": false, + "type": "mixed", + "visibility": "public" + } + } + }, + "Mcp\\Schema\\Result\\ListToolsResult": { + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tools", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nextCursor", + "type": "?string", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "fromArray": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Result\\ListToolsResult", + "static": true, + "visibility": "public" + }, + "jsonSerialize": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "nextCursor": { + "readonly": true, + "static": false, + "type": "?string", + "visibility": "public" + }, + "tools": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "public" + } + } + }, + "Mcp\\Server": { + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocol", + "type": "Mcp\\Server\\Protocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "statelessProtocol", + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "handshakeVersions", + "type": "?array", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "builder": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": true, + "visibility": "public" + }, + "run": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "public" + } + }, + "properties": { + "handshakeVersions": { + "readonly": true, + "static": false, + "type": "?array", + "visibility": "private" + }, + "logger": { + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "protocol": { + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Protocol", + "visibility": "private" + }, + "statelessProtocol": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", + "visibility": "private" + } + } + }, + "Mcp\\Server\\Builder": { + "constants": [], + "final": true, + "methods": { + "add": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "definition", + "type": "Mcp\\Schema\\Tool|Mcp\\Schema\\ResourceDefinition|Mcp\\Schema\\ResourceTemplate|Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\ElementHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoader": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addLoaders": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loaders", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Notification\\NotificationHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addNotificationHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Mcp\\Server\\Handler\\Request\\RequestHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addRequestHandlers": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handlers", + "type": "iterable", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "size", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "Closure|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "mimeType", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\Annotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "addTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "name", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "annotations", + "type": "?Mcp\\Schema\\ToolAnnotations", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputSchema", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "meta", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "outputSchema", + "type": "?array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "assemble": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "build": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server", + "static": false, + "visibility": "public" + }, + "buildStateless": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "supportedVersions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Stateless\\StatelessProtocol", + "static": false, + "visibility": "public" + }, + "createDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "static": false, + "visibility": "private" + }, + "detectCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "eagerlyLoaded", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Schema\\ServerCapabilities", + "static": false, + "visibility": "private" + }, + "enableExtension": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "extensions", + "type": "Mcp\\Schema\\Extension\\ExtensionInterface", + "variadic": true + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "requestStateCodec": { + "abstract": false, + "final": false, + "parameters": [], + "return": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "static": false, + "visibility": "private" + }, + "resolve": { + "abstract": false, + "final": false, + "parameters": [], + "return": "array", + "static": false, + "visibility": "private" + }, + "setCachePolicy": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "policy", + "type": "Mcp\\Server\\Wire\\CachePolicy", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setCapabilities": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "serverCapabilities", + "type": "Mcp\\Schema\\ServerCapabilities", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setContainer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "container", + "type": "Psr\\Container\\ContainerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscoverer": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "discoverer", + "type": "Mcp\\Capability\\Discovery\\DiscovererInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setDiscovery": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "basePath", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "scanDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "excludeDirs", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cache", + "type": "?Psr\\SimpleCache\\CacheInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "namePatterns", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setEventDispatcher": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "eventDispatcher", + "type": "Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setHeaderValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "headerValidation", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInputRequiredLimits": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "maxRounds", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "roundTimeout", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setInstructions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "instructions", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLazyLoading": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "lazyLoading", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setLogger": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setModernVersions": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "versions", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setNotificationBus": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "bus", + "type": "Mcp\\Server\\Subscription\\NotificationBusInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setPaginationLimit": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "paginationLimit", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setProtocolVersion": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "protocolVersion", + "type": "Mcp\\Schema\\Enum\\ProtocolVersion", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setReferenceHandler": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRegistry": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setRequestState": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "key", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "ttl", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setResourceSubscriptionManager": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "subscriptionManager", + "type": "Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaGenerator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaGenerator", + "type": "Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSchemaValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "schemaValidator", + "type": "Mcp\\Capability\\Discovery\\SchemaValidator", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setServerInfo": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "version", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "description", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "icons", + "type": "?array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "websiteUrl", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "title", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "sessionStore", + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionManager", + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcProbability", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "gcDivisor", + "type": "int", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionLock", + "type": "Mcp\\Server\\Session\\SessionLockInterface", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "setSubscriptionLifetime": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "seconds", + "type": "float", + "variadic": false + } + ], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutInputRequiredShim": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + }, + "withoutModernEra": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Mcp\\Server\\Builder", + "static": false, + "visibility": "public" + } + }, + "properties": { + "cachePolicy": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Wire\\CachePolicy", + "visibility": "private" + }, + "container": { + "readonly": false, + "static": false, + "type": "?Psr\\Container\\ContainerInterface", + "visibility": "private" + }, + "discoverer": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", + "visibility": "private" + }, + "discoveryBasePath": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "discoveryCache": { + "readonly": false, + "static": false, + "type": "?Psr\\SimpleCache\\CacheInterface", + "visibility": "private" + }, + "discoveryExcludeDirs": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryNamePatterns": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "discoveryScanDirs": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "readonly": false, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "explicitPrompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResourceTemplates": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitResources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "explicitTools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMessages": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensionMethods": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "extensions": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "gcDivisor": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "gcProbability": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "hasCustomRegistry": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "headerValidation": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredRounds": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "inputRequiredShim": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "inputRequiredTimeout": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "instructions": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "lazyLoading": { + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loaders": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "logger": { + "readonly": false, + "static": false, + "type": "?Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "modernVersions": { + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "notificationBus": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", + "visibility": "private" + }, + "notificationHandlers": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "paginationLimit": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "parts": { + "readonly": false, + "static": false, + "type": "?array", + "visibility": "private" + }, + "prompts": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "protocolVersion": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", + "visibility": "private" + }, + "referenceHandler": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "requestHandlers": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateKey": { + "readonly": false, + "static": false, + "type": "?string", + "visibility": "private" + }, + "requestStateTtl": { + "readonly": false, + "static": false, + "type": "int", + "visibility": "private" + }, + "resourceTemplates": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "schemaGenerator": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", + "visibility": "private" + }, + "schemaValidator": { + "readonly": false, + "static": false, + "type": "?Mcp\\Capability\\Discovery\\SchemaValidator", + "visibility": "private" + }, + "serverCapabilities": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\ServerCapabilities", + "visibility": "private" + }, + "serverInfo": { + "readonly": false, + "static": false, + "type": "?Mcp\\Schema\\Implementation", + "visibility": "private" + }, + "sessionLock": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "visibility": "private" + }, + "sessionManager": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + }, + "sessionStore": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionStoreInterface", + "visibility": "private" + }, + "subscriptionLifetime": { + "readonly": false, + "static": false, + "type": "float", + "visibility": "private" + }, + "subscriptionManager": { + "readonly": false, + "static": false, + "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", + "visibility": "private" + }, + "tools": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + } + }, + "Mcp\\Server\\Protocol": { + "constants": { + "ABANDONED_REQUEST_GRACE": { + "visibility": "private" + }, + "INTERNAL_ERROR_MESSAGE": { + "visibility": "private" + }, + "SESSION_ACTIVE_REQUEST_META": { + "visibility": "public" + }, + "SESSION_BUSY_MESSAGE": { + "visibility": "private" + }, + "SESSION_LOGGING_LEVEL": { + "visibility": "public" + }, + "SESSION_OUTGOING_QUEUE": { + "visibility": "private" + }, + "SESSION_PENDING_REQUESTS": { + "visibility": "private" + }, + "SESSION_RESPONSES": { + "visibility": "private" + } + }, + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "notificationHandlers", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messageFactory", + "type": "Mcp\\JsonRpc\\MessageFactory", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionManager", + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "inputRequiredShim", + "type": "?Mcp\\Server\\InputRequiredShim", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "requestStateCodec", + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "sessionLock", + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "acquireSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "wait", + "type": "bool", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "checkResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error|null", + "static": false, + "visibility": "public" + }, + "connect": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "consumeOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "destroySession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "dispatchEvent": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "object", + "static": false, + "visibility": "private" + }, + "dispatchResult": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "result", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "doProcessInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "dropAbandonedRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "encodeOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "?array", + "static": false, + "visibility": "private" + }, + "errorForThrowable": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "e", + "type": "Throwable", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "expireRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "?Mcp\\Schema\\JsonRpc\\Error", + "static": false, + "visibility": "private" + }, + "findRequestId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "findResponseId": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + } + ], + "return": "string|int|null", + "static": true, + "visibility": "private" + }, + "getAwaitedPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "fiber", + "type": "Fiber", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "getPendingRequests": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + }, + "handleFiberYield": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "yieldedValue", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "transport", + "type": "?Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "?Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "public" + }, + "handleInvalidMessage": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "exception", + "type": "Mcp\\Exception\\InvalidInputMessageException", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "handleResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "hasInitializeRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "hasTimedOut": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "pending", + "type": "array", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + }, + "processInput": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "input", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "accessToken", + "type": "?Mcp\\Server\\Authorization\\AccessToken", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "queueFiberOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "queueOutgoing": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "message", + "type": "Mcp\\Schema\\JsonRpc\\Request|Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "context", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "registerRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "private" + }, + "releaseSessionLock": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "resolveSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "messages", + "type": "array", + "variadic": false + } + ], + "return": "?Mcp\\Server\\Session\\SessionInterface", + "static": false, + "visibility": "private" + }, + "saveResumedSession": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "sendNotification": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "notification", + "type": "Mcp\\Schema\\JsonRpc\\Notification", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "sendRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "timeout", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "Mcp\\Server\\Session\\SessionInterface", + "variadic": false + } + ], + "return": "Symfony\\Component\\Uid\\Uuid", + "static": false, + "visibility": "public" + }, + "sendResponse": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "response", + "type": "Mcp\\Schema\\JsonRpc\\Response|Mcp\\Schema\\JsonRpc\\Error", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "session", + "type": "?Mcp\\Server\\Session\\SessionInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "context", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "settleAwaitedRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "id", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "takeFiberOutgoingMessages": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "transport", + "type": "?Mcp\\Server\\Transport\\TransportInterface", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "trackAwaitedRequest": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "fiber", + "type": "Fiber", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "requestId", + "type": "?Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "tryAcquireSessionLockForPoll": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "sessionId", + "type": "Symfony\\Component\\Uid\\Uuid", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "private" + } + }, + "properties": { + "awaitedRequestIds": { + "readonly": false, + "static": false, + "type": "WeakMap", + "visibility": "private" + }, + "droppedAwaitedRequestIds": { + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "eventDispatcher": { + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "fiberOutgoingMessages": { + "readonly": false, + "static": false, + "type": "WeakMap", + "visibility": "private" + }, + "inputRequiredShim": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\InputRequiredShim", + "visibility": "private" + }, + "logger": { + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "messageFactory": { + "readonly": true, + "static": false, + "type": "Mcp\\JsonRpc\\MessageFactory", + "visibility": "private" + }, + "notificationHandlers": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestHandlers": { + "readonly": true, + "static": false, + "type": "array", + "visibility": "private" + }, + "requestStateCodec": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", + "visibility": "private" + }, + "sessionLock": { + "readonly": true, + "static": false, + "type": "?Mcp\\Server\\Session\\SessionLockInterface", + "visibility": "private" + }, + "sessionManager": { + "readonly": true, + "static": false, + "type": "Mcp\\Server\\Session\\SessionManagerInterface", + "visibility": "private" + } + } + } + }, + "library": "mcp/sdk", + "version": "0.9.0" +} diff --git a/tests/bin/member-snapshot.php b/tests/bin/member-snapshot.php new file mode 100644 index 0000000..f3f7e37 --- /dev/null +++ b/tests/bin/member-snapshot.php @@ -0,0 +1,58 @@ + [--diff ] +// +// Without --diff: prints the snapshot of the installed version as JSON. +// With --diff: diffs the installed version against the committed snapshot, prints both lists (and appends them to +// $GITHUB_STEP_SUMMARY when it is set), and exits 1 when a member AgentCat reads changed. + +use AgentCat\Tests\Contract\PrivateMemberManifest; +use AgentCat\Tests\Contract\Snapshot\MemberSnapshot; +use AgentCat\Tests\Contract\Snapshot\SnapshotDiff; + +require __DIR__ . '/../../vendor/autoload.php'; + +$arguments = array_slice($argv, 1); +$library = $arguments[0] ?? ''; +$committedPath = ($arguments[1] ?? '') === '--diff' ? ($arguments[2] ?? '') : null; +if (count($arguments) !== ($committedPath === null ? 1 : 3) || $library === '' || $committedPath === '') { + fwrite(STDERR, "usage: php tests/bin/member-snapshot.php [--diff ]\n"); + exit(2); +} + +try { + $snapshot = MemberSnapshot::of($library); + if ($committedPath === null) { + echo MemberSnapshot::toJson($snapshot); + exit(0); + } + + $json = file_get_contents($committedPath); + if ($json === false) { + throw new RuntimeException('cannot read ' . $committedPath); + } + $committed = MemberSnapshot::fromJson($json); +} catch (Throwable $e) { + fwrite(STDERR, $e->getMessage() . "\n"); + exit(2); +} + +$diff = SnapshotDiff::between($committed, $snapshot, PrivateMemberManifest::members()); +$report = '### ' . $library . ' ' . $committed['version'] . ' → ' . $snapshot['version'] . "\n\n"; +foreach (['breaking' => $diff->breaking(), 'other' => $diff->other()] as $title => $lines) { + $report .= '#### ' . $title . ' (' . count($lines) . ")\n\n"; + foreach ($lines as $line) { + $report .= '- ' . $line . "\n"; + } + $report .= "\n"; +} +echo $report; + +$summary = getenv('GITHUB_STEP_SUMMARY'); +if ($summary !== false && $summary !== '') { + file_put_contents($summary, $report, FILE_APPEND); +} + +exit($diff->breaking() === [] ? 0 : 1); From 71729ed36b2a603c2e008fdeb0c4d478c5090081 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 18:59:36 +0000 Subject: [PATCH 37/68] test: compare unsnapshotted releases with the newest lower snapshot, and name the library and the changes in the nightly issue --- .github/nightly-issue.sh | 6 + .github/workflows/nightly.yml | 10 +- .../Contract/Snapshot/CommittedSnapshots.php | 94 ++++++++++ tests/Contract/Snapshot/MemberSnapshot.php | 38 +++- .../Snapshot/MemberSnapshotCliTest.php | 110 ++++++++++++ tests/Contract/Snapshot/SnapshotDiff.php | 55 ++++-- tests/Contract/Snapshot/SnapshotDiffTest.php | 72 +++++++- tests/Contract/Snapshot/SnapshotTest.php | 164 +++++++++++++++++- .../Contract/snapshots/laravel-mcp-1.0.0.json | 38 +++- .../Contract/snapshots/laravel-mcp-1.0.2.json | 39 ++++- .../Contract/snapshots/laravel-mcp-1.1.0.json | 39 ++++- tests/Contract/snapshots/mcp-sdk-0.8.1.json | 91 +++++++++- tests/Contract/snapshots/mcp-sdk-0.9.0.json | 98 ++++++++++- 13 files changed, 794 insertions(+), 60 deletions(-) create mode 100644 tests/Contract/Snapshot/CommittedSnapshots.php create mode 100644 tests/Contract/Snapshot/MemberSnapshotCliTest.php diff --git a/.github/nightly-issue.sh b/.github/nightly-issue.sh index 0cbd76f..6eea709 100755 --- a/.github/nightly-issue.sh +++ b/.github/nightly-issue.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Opens one issue per failing nightly job, or comments on the open one. A person closes it after the fix. +# Usage: nightly-issue.sh [report file]. The text of the report file, if it exists, goes into the issue or comment. set -euo pipefail job="$1" title="Nightly: ${job} failed" @@ -7,6 +8,11 @@ run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} gh label create nightly --color B60205 --description "A scheduled upstream or release check failed" 2>/dev/null || true number="$(gh issue list --label nightly --state open --limit 100 --json number,title --jq ".[] | select(.title == \"${title}\") | .number" | head -n 1)" body="The nightly job \`${job}\` failed: ${run_url}" +report="${2:-}" +if [ -n "${report}" ] && [ -s "${report}" ]; then + # An issue body holds at most 65536 characters. + body+=$'\n\n'"$(head -c 60000 "${report}")" +fi if [ -n "${number}" ]; then gh issue comment "${number}" --body "${body}" else diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9fda692..af47ed3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -325,10 +325,12 @@ jobs: timeout-minutes: 5 env: LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md run: | + set -o pipefail prefix="tests/Contract/snapshots/${LIBRARY//\//-}-" - newest="$(ls "$prefix"*.json | sort -V | tail -n 1)" - php tests/bin/member-snapshot.php "$LIBRARY" --diff "$newest" + newest="$(printf '%s\n' "$prefix"*.json | sort -V | tail -n 1)" + php tests/bin/member-snapshot.php "$LIBRARY" --diff "$newest" | tee "$REPORT" - name: Fail on purpose (workflow_dispatch force_failure) if: github.event_name == 'workflow_dispatch' && inputs.force_failure == github.job run: exit 1 @@ -336,4 +338,6 @@ jobs: if: failure() && github.event_name != 'pull_request' env: GH_TOKEN: ${{ github.token }} - run: .github/nightly-issue.sh "${{ github.job }}" + LIBRARY: ${{ matrix.library }} + REPORT: ${{ runner.temp }}/member-snapshot.md + run: .github/nightly-issue.sh "member-snapshot (${LIBRARY})" "$REPORT" diff --git a/tests/Contract/Snapshot/CommittedSnapshots.php b/tests/Contract/Snapshot/CommittedSnapshots.php new file mode 100644 index 0000000..88402c3 --- /dev/null +++ b/tests/Contract/Snapshot/CommittedSnapshots.php @@ -0,0 +1,94 @@ +-.json`. + * + * @phpstan-import-type Snapshot from MemberSnapshot + */ +final readonly class CommittedSnapshots +{ + public function __construct( + private string $directory, + ) {} + + public static function committed(): self + { + return new self(__DIR__ . '/../snapshots'); + } + + /** The file of exactly this version, or null. */ + public function exact(string $library, string $version): ?string + { + $path = $this->directory . '/' . $this->prefix($library) . $version . '.json'; + + return is_file($path) ? $path : null; + } + + /** The file of the highest version below this one, or null when there is none. */ + public function newestBelow(string $library, string $version): ?string + { + $newest = null; + foreach ($this->versions($library) as $candidate) { + if (version_compare($candidate, $version, '<') && ($newest === null || version_compare($candidate, $newest, '>'))) { + $newest = $candidate; + } + } + + return $newest === null ? null : $this->directory . '/' . $this->prefix($library) . $newest . '.json'; + } + + /** + * The live snapshot against the file of exactly this version, or, for a release without a file, against the + * newest lower one. Null when there is no file at or below the version. + * + * @param Snapshot $live + * @param list $readMembers + * + * @return array{path: string, exact: bool, diff: SnapshotDiff}|null + */ + public function compare(string $library, string $version, array $live, array $readMembers): ?array + { + $exact = $this->exact($library, $version); + $path = $exact ?? $this->newestBelow($library, $version); + if ($path === null) { + return null; + } + + return [ + 'path' => $path, + 'exact' => $exact !== null, + 'diff' => SnapshotDiff::between(MemberSnapshot::fromJson((string) file_get_contents($path)), $live, $readMembers), + ]; + } + + /** @return list the path of every snapshot of the library */ + public function all(string $library): array + { + return array_map( + fn(string $version): string => $this->directory . '/' . $this->prefix($library) . $version . '.json', + $this->versions($library), + ); + } + + /** @return list */ + private function versions(string $library): array + { + $prefix = $this->prefix($library); + $versions = []; + $paths = glob($this->directory . '/' . $prefix . '*.json'); + foreach ($paths === false ? [] : $paths as $path) { + $versions[] = substr(basename($path), strlen($prefix), -strlen('.json')); + } + + return $versions; + } + + private function prefix(string $library): string + { + return str_replace('/', '-', $library) . '-'; + } +} diff --git a/tests/Contract/Snapshot/MemberSnapshot.php b/tests/Contract/Snapshot/MemberSnapshot.php index 578ffd5..12e6886 100644 --- a/tests/Contract/Snapshot/MemberSnapshot.php +++ b/tests/Contract/Snapshot/MemberSnapshot.php @@ -13,10 +13,10 @@ * file per library version can be compared with the installed one, and the nightly job can diff `dev-main` against it. * * @phpstan-type ParameterShape array{byReference: bool, hasDefault: bool, name: string, type: string, variadic: bool} - * @phpstan-type PropertyShape array{readonly: bool, static: bool, type: string, visibility: string} + * @phpstan-type PropertyShape array{hasDefault: bool, readonly: bool, static: bool, type: string, visibility: string} * @phpstan-type MethodShape array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} * @phpstan-type ConstantShape array{visibility: string} - * @phpstan-type ClassShape array{constants: array, final: bool, methods: array, properties: array} + * @phpstan-type ClassShape array{abstract: bool, abstractMethods: list, constants: array, final: bool, methods: array, properties: array, readonly: bool} * @phpstan-type Snapshot array{classes: array, library: string, version: string} */ final class MemberSnapshot @@ -133,6 +133,7 @@ private static function ofClass(string $class): array foreach ($reflection->getProperties() as $property) { if ($property->getDeclaringClass()->getName() === $class) { $properties[$property->getName()] = [ + 'hasDefault' => $property->hasDefaultValue(), 'readonly' => $property->isReadOnly(), 'static' => $property->isStatic(), 'type' => self::type($property->getType(), $class), @@ -142,7 +143,11 @@ private static function ofClass(string $class): array } $methods = []; + $abstractMethods = []; foreach ($reflection->getMethods() as $method) { + if ($method->isAbstract()) { + $abstractMethods[] = $method->getName(); + } if ($method->getDeclaringClass()->getName() === $class) { $parameters = []; foreach ($method->getParameters() as $parameter) { @@ -168,17 +173,33 @@ private static function ofClass(string $class): array ksort($constants, SORT_STRING); ksort($properties, SORT_STRING); ksort($methods, SORT_STRING); - - return ['constants' => $constants, 'final' => $reflection->isFinal(), 'methods' => $methods, 'properties' => $properties]; + sort($abstractMethods, SORT_STRING); + + return [ + 'abstract' => $reflection->isAbstract(), + 'abstractMethods' => $abstractMethods, + 'constants' => $constants, + 'final' => $reflection->isFinal(), + 'methods' => $methods, + 'properties' => $properties, + 'readonly' => $reflection->isReadOnly(), + ]; } /** - * The type as written, with `self` spelled as the class: PHP 8.5 reports `self` as the class name, earlier versions - * as `self`, and a snapshot must not depend on the PHP version that took it. + * The type as written, with `self` and `parent` spelled as the class names: PHP 8.5 reports the class names, + * earlier versions report the keywords, and a snapshot must not depend on the PHP version that took it. + * + * @param class-string $class */ private static function type(?\ReflectionType $type, string $class): string { - return preg_replace('/(? $match[1] === 'self' ? $class : ($parent === false ? 'parent' : $parent), + (string) $type, + ) ?? (string) $type; } private static function visibility(bool $public, bool $protected): string @@ -186,7 +207,8 @@ private static function visibility(bool $public, bool $protected): string return $public ? 'public' : ($protected ? 'protected' : 'private'); } - private static function namespaceOf(string $library): string + /** The root namespace of the library's classes, with its trailing backslash. */ + public static function namespaceOf(string $library): string { return self::NAMESPACES[$library] ?? throw new \InvalidArgumentException('unknown library ' . $library . ' (expected ' . implode(' or ', self::libraries()) . ')'); } diff --git a/tests/Contract/Snapshot/MemberSnapshotCliTest.php b/tests/Contract/Snapshot/MemberSnapshotCliTest.php new file mode 100644 index 0000000..3ff50e0 --- /dev/null +++ b/tests/Contract/Snapshot/MemberSnapshotCliTest.php @@ -0,0 +1,110 @@ +directory = sys_get_temp_dir() . '/agentcat-snapshot-cli-' . bin2hex(random_bytes(4)); + mkdir($this->directory); + } + + #[\Override] + protected function tearDown(): void + { + FileTree::remove($this->directory); + } + + public function testACleanDiffExitsZero(): void + { + $file = $this->snapshotFile(MemberSnapshot::of('mcp/sdk')); + + $process = $this->script(['mcp/sdk', '--diff', $file]); + + $this->assertSame(0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput()); + $this->assertStringContainsString('#### breaking (0)', $process->getOutput()); + } + + public function testABreakingLineExitsOneAndNamesTheMember(): void + { + $tampered = $this->withTamperedType(MemberSnapshot::of('mcp/sdk'), \Mcp\Server::class, 'protocol'); + + $process = $this->script(['mcp/sdk', '--diff', $this->snapshotFile($tampered)]); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString('#### breaking (1)', $process->getOutput()); + $this->assertStringContainsString('Mcp\Server::$protocol: private readonly Tampered $protocol → private readonly Mcp\Server\Protocol $protocol', $process->getOutput()); + } + + public function testTheReportGoesToTheStepSummaryWhenItIsSet(): void + { + $summary = $this->directory . '/summary.md'; + + $process = $this->script(['mcp/sdk', '--diff', $this->snapshotFile(MemberSnapshot::of('mcp/sdk'))], ['GITHUB_STEP_SUMMARY' => $summary]); + + $this->assertSame(0, $process->getExitCode()); + $this->assertSame($process->getOutput(), (string) file_get_contents($summary)); + } + + public function testAnUnknownLibraryExitsTwo(): void + { + $this->assertSame(2, $this->script(['symfony/mcp-bundle'])->getExitCode()); + } + + public function testAMissingSnapshotFileExitsTwo(): void + { + $this->assertSame(2, $this->script(['mcp/sdk', '--diff', $this->directory . '/missing.json'])->getExitCode()); + } + + public function testNoArgumentsExitTwo(): void + { + $this->assertSame(2, $this->script([])->getExitCode()); + } + + /** @param Snapshot $snapshot */ + private function snapshotFile(array $snapshot): string + { + $path = $this->directory . '/committed.json'; + file_put_contents($path, MemberSnapshot::toJson($snapshot)); + + return $path; + } + + /** + * @param list $arguments + * @param array $env the summary file is unset unless a test sets it, so a CI run never writes to its own + */ + private function script(array $arguments, array $env = []): Process + { + $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/member-snapshot.php', ...$arguments], null, ['GITHUB_STEP_SUMMARY' => false, ...$env], null, 60); + $process->run(); + + return $process; + } + + /** + * The snapshot with the type of one property changed. + * + * @param Snapshot $snapshot + * + * @return Snapshot + */ + private function withTamperedType(array $snapshot, string $class, string $name): array + { + $changed = array_replace_recursive($snapshot, ['classes' => [$class => ['properties' => [$name => ['type' => 'Tampered']]]]]); + + return MemberSnapshot::fromJson(json_encode($changed, JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Contract/Snapshot/SnapshotDiff.php b/tests/Contract/Snapshot/SnapshotDiff.php index b8bfd8d..1e72a3c 100644 --- a/tests/Contract/Snapshot/SnapshotDiff.php +++ b/tests/Contract/Snapshot/SnapshotDiff.php @@ -30,15 +30,7 @@ private function __construct( */ public static function between(array $old, array $new, array $readMembers): self { - $properties = []; - $methods = []; - foreach ($readMembers as $member) { - if (preg_match('/^([A-Za-z0-9_\\\\]+)::\$(\w+)$/', $member, $match) === 1) { - $properties[$match[1]][$match[2]] = true; - } elseif (preg_match('/^([A-Za-z0-9_\\\\]+)::(\w+)\(/', $member, $match) === 1) { - $methods[$match[1]][$match[2]] = true; - } - } + ['properties' => $properties, 'methods' => $methods] = self::readMembers($readMembers); $breaking = []; $other = []; @@ -47,8 +39,8 @@ public static function between(array $old, array $new, array $readMembers): self foreach ($classes as $class) { $oldClass = $old['classes'][$class] ?? null; $newClass = $new['classes'][$class] ?? null; - $readProperties = array_keys($properties[$class] ?? []); - $readMethods = array_keys($methods[$class] ?? []); + $readProperties = $properties[$class] ?? []; + $readMethods = $methods[$class] ?? []; if ($newClass === null) { $other[] = $class . ': class removed'; @@ -80,6 +72,20 @@ public static function between(array $old, array $new, array $readMembers): self } } + if ($newClass['readonly'] && !$oldClass['readonly'] && $readMethods !== []) { + // A class that extends a readonly class must be readonly itself: AgentCat's subclass is a fatal error. + $breaking[] = $class . ': not readonly → readonly'; + } + foreach (array_diff($newClass['abstractMethods'], $oldClass['abstractMethods']) as $name) { + $line = $class . '::' . $name . '(): added (abstract)'; + if ($readMethods !== []) { + // AgentCat's subclass does not implement it: a fatal error at load. + $breaking[] = $line; + } else { + $other[] = $line; + } + } + self::compareProperties($class, $oldClass, $newClass, $readProperties, $breaking, $other); self::compareMethods($class, $oldClass, $newClass, $readMethods, $breaking, $other); self::compareConstants($class, $oldClass, $newClass, $other); @@ -91,6 +97,29 @@ public static function between(array $old, array $new, array $readMembers): self return new self($breaking, $other); } + /** + * The members AgentCat reads, by class: `Class::$property` entries and `Class::method(...)` entries. Any other + * string (the bundle's service definitions) is ignored. + * + * @param list $readMembers + * + * @return array{properties: array>, methods: array>} + */ + public static function readMembers(array $readMembers): array + { + $properties = []; + $methods = []; + foreach ($readMembers as $member) { + if (preg_match('/^([A-Za-z0-9_\\\\]+)::\$(\w+)$/', $member, $match) === 1) { + $properties[$match[1]][] = $match[2]; + } elseif (preg_match('/^([A-Za-z0-9_\\\\]+)::(\w+)\(/', $member, $match) === 1) { + $methods[$match[1]][] = $match[2]; + } + } + + return ['properties' => $properties, 'methods' => $methods]; + } + /** @return list the changes that break AgentCat, each naming `Class::member` and old → new */ public function breaking(): array { @@ -186,10 +215,10 @@ private static function compareConstants(string $class, array $old, array $new, } } - /** @param array{readonly: bool, static: bool, type: string, visibility: string} $shape */ + /** @param array{hasDefault: bool, readonly: bool, static: bool, type: string, visibility: string} $shape */ private static function property(string $name, array $shape): string { - return self::words([$shape['visibility'], $shape['static'] ? 'static' : '', $shape['readonly'] ? 'readonly' : '', $shape['type'], '$' . $name]); + return self::words([$shape['visibility'], $shape['static'] ? 'static' : '', $shape['readonly'] ? 'readonly' : '', $shape['type'], '$' . $name . ($shape['hasDefault'] ? ' = …' : '')]); } /** @param array{abstract: bool, final: bool, parameters: list, return: string, static: bool, visibility: string} $shape */ diff --git a/tests/Contract/Snapshot/SnapshotDiffTest.php b/tests/Contract/Snapshot/SnapshotDiffTest.php index d618486..6ef2035 100644 --- a/tests/Contract/Snapshot/SnapshotDiffTest.php +++ b/tests/Contract/Snapshot/SnapshotDiffTest.php @@ -134,6 +134,64 @@ public function testAChangedExtendedSignatureIsBreaking(): void ); } + public function testAnExtendedClassTurningReadonlyIsBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::RESULT => $this->shape()]), + $this->snapshot([self::RESULT => $this->shape(readonly: true)]), + [self::CONSTRUCTOR], + ); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult: not readonly → readonly'], $diff->breaking()); + $this->assertSame([], $diff->other()); + } + + public function testAClassNobodyExtendsTurningReadonlyIsNotBreaking(): void + { + $diff = SnapshotDiff::between( + $this->snapshot([self::BUILDER => $this->shape()]), + $this->snapshot([self::BUILDER => $this->shape(readonly: true)]), + [self::PARTS, self::CONSTRUCTOR], + ); + + $this->assertSame([], $diff->breaking()); + } + + public function testAnAddedAbstractMethodOfAnExtendedClassIsBreaking(): void + { + $old = $this->snapshot([self::RESULT => $this->shape(abstractMethods: ['first'])]); + $new = $this->snapshot([self::RESULT => $this->shape(abstractMethods: ['first', 'second'])]); + + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::second(): added (abstract)'], SnapshotDiff::between($old, $new, [self::CONSTRUCTOR])->breaking()); + $this->assertSame([], SnapshotDiff::between($old, $new, [self::PARTS])->breaking()); + $this->assertSame(['Mcp\\Schema\\Result\\ListToolsResult::second(): added (abstract)'], SnapshotDiff::between($old, $new, [self::PARTS])->other()); + } + + public function testAReadPropertyThatLosesItsDefaultIsBreaking(): void + { + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array', hasDefault: true)])]); + $new = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')])]); + + $this->assertSame( + ['Mcp\\Server\\Builder::$parts: private array $parts = … → private array $parts'], + SnapshotDiff::between($old, $new, [self::PARTS])->breaking(), + ); + $this->assertSame([], SnapshotDiff::between($old, $new, [])->breaking()); + } + + public function testAReadPropertyMovedToAParentClassIsBreaking(): void + { + $parent = 'Mcp\\Server\\BaseBuilder'; + $old = $this->snapshot([self::BUILDER => $this->shape(properties: ['parts' => $this->property('array')]), $parent => $this->shape()]); + $new = $this->snapshot([self::BUILDER => $this->shape(), $parent => $this->shape(properties: ['parts' => $this->property('array')])]); + + $diff = SnapshotDiff::between($old, $new, [self::PARTS]); + + // The accessor reads the property in the scope of the declaring class named in the manifest, so a move breaks it. + $this->assertSame(['Mcp\\Server\\Builder::$parts: private array $parts → removed'], $diff->breaking()); + $this->assertSame(['Mcp\\Server\\BaseBuilder::$parts: added (private array $parts)'], $diff->other()); + } + public function testAChangedMethodNobodyDependsOnIsNotBreaking(): void { $old = $this->snapshot([self::RESULT => $this->shape(methods: ['jsonSerialize' => $this->method([], 'array')])]); @@ -177,15 +235,19 @@ private function snapshot(array $classes): array } /** - * @param array $properties + * @param array $properties * @param array, return: string, static: bool, visibility: string}> $methods * @param array $constants constant name => visibility + * @param list $abstractMethods * * @return ClassShape */ - private function shape(bool $final = false, array $properties = [], array $methods = [], array $constants = []): array + private function shape(bool $final = false, array $properties = [], array $methods = [], array $constants = [], bool $readonly = false, array $abstractMethods = []): array { return [ + 'abstract' => $abstractMethods !== [], + 'abstractMethods' => $abstractMethods, + 'readonly' => $readonly, 'constants' => array_map(static fn(string $visibility): array => ['visibility' => $visibility], $constants), 'final' => $final, 'methods' => $methods, @@ -193,10 +255,10 @@ private function shape(bool $final = false, array $properties = [], array $metho ]; } - /** @return array{readonly: bool, static: bool, type: string, visibility: string} */ - private function property(string $type): array + /** @return array{hasDefault: bool, readonly: bool, static: bool, type: string, visibility: string} */ + private function property(string $type, bool $hasDefault = false): array { - return ['readonly' => false, 'static' => false, 'type' => $type, 'visibility' => 'private']; + return ['hasDefault' => $hasDefault, 'readonly' => false, 'static' => false, 'type' => $type, 'visibility' => 'private']; } /** diff --git a/tests/Contract/Snapshot/SnapshotTest.php b/tests/Contract/Snapshot/SnapshotTest.php index 98902b6..ef9e44a 100644 --- a/tests/Contract/Snapshot/SnapshotTest.php +++ b/tests/Contract/Snapshot/SnapshotTest.php @@ -5,6 +5,8 @@ namespace AgentCat\Tests\Contract\Snapshot; use AgentCat\Tests\Contract\PrivateMemberManifest; +use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\Thrown; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -12,6 +14,7 @@ * The classes AgentCat touches, member by member, against the snapshot committed for the installed version. A * `dev-main` leg has no snapshot and skips; the nightly job diffs it with tests/bin/member-snapshot.php instead. */ +/** @phpstan-import-type Snapshot from MemberSnapshot */ final class SnapshotTest extends TestCase { /** @return array */ @@ -29,18 +32,137 @@ public static function libraries(): array public function testTheInstalledVersionMatchesItsCommittedSnapshot(string $library): void { $version = MemberSnapshot::installedVersion($library); - $path = __DIR__ . '/../snapshots/' . str_replace('/', '-', $library) . '-' . $version . '.json'; - if (!is_file($path)) { + $files = CommittedSnapshots::committed(); + if ($files->exact($library, $version) === null && str_starts_with($version, 'dev-')) { self::markTestSkipped('no committed snapshot for ' . $library . ' ' . $version . ' (a dev-main leg)'); } + // A release that has no snapshot yet (a new patch) is compared with the newest lower one: only a change to a + // member AgentCat reads fails, so an upstream patch does not break the build until someone commits its snapshot. + $result = $files->compare($library, $version, MemberSnapshot::of($library), PrivateMemberManifest::members()); + $this->assertNotNull($result, 'no committed snapshot of ' . $library . ' at or below ' . $version); + + $report = $library . ' ' . $version . ' against ' . basename($result['path']) . ":\n" . implode("\n", $result['diff']->other()); + $this->assertSame([], $result['diff']->breaking(), 'breaking changes in ' . $report); + if ($result['exact']) { + $this->assertSame([], $result['diff']->other(), 'changes in ' . $report); + } + } + + public function testAVersionWithoutASnapshotIsComparedWithTheNewestLowerOne(): void + { + $directory = sys_get_temp_dir() . '/agentcat-snapshots-' . bin2hex(random_bytes(4)); + mkdir($directory); + + try { + foreach (['0.9.0', '0.10.0', '0.8.1', '0.11.0-rc1'] as $version) { + file_put_contents($directory . '/mcp-sdk-' . $version . '.json', '{}'); + } + $files = new CommittedSnapshots($directory); + + $this->assertSame($directory . '/mcp-sdk-0.9.0.json', $files->newestBelow('mcp/sdk', '0.9.5')); + $this->assertSame($directory . '/mcp-sdk-0.10.0.json', $files->newestBelow('mcp/sdk', '0.10.1')); + $this->assertNull($files->newestBelow('mcp/sdk', '0.8.0')); + $this->assertNull($files->newestBelow('laravel/mcp', '9.9.9')); + $this->assertSame($directory . '/mcp-sdk-0.9.0.json', $files->exact('mcp/sdk', '0.9.0')); + $this->assertNull($files->exact('mcp/sdk', '0.9.1')); + $this->assertCount(4, $files->all('mcp/sdk')); + } finally { + FileTree::remove($directory); + } + } + + #[DataProvider('libraries')] + public function testAFakeNewPatchPassesWhenNothingAgentCatReadsChanged(string $library): void + { + $live = MemberSnapshot::of($library); + $directory = $this->directoryWith($library, '0.0.1', $live); - $committed = MemberSnapshot::fromJson((string) file_get_contents($path)); + try { + $result = (new CommittedSnapshots($directory))->compare($library, '0.0.2', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNotNull($result); + $this->assertFalse($result['exact']); + $this->assertSame([], $result['diff']->breaking()); + $this->assertSame([], $result['diff']->other()); + } + + #[DataProvider('libraries')] + public function testAFakeNewPatchFailsWhenAMemberAgentCatReadsChanged(string $library): void + { $live = MemberSnapshot::of($library); + $tampered = $live; + $owner = null; + foreach (SnapshotDiff::readMembers(PrivateMemberManifest::members())['properties'] as $class => $names) { + if (!str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + continue; + } + foreach ($names as $name) { + $owner = $class . '::$' . $name; + $tampered = $this->withTamperedType($live, $class, $name); + + break 2; + } + } + $directory = $this->directoryWith($library, '0.0.1', $tampered); - $diff = SnapshotDiff::between($committed, $live, PrivateMemberManifest::members()); - $this->assertSame([], $diff->breaking(), 'breaking changes in ' . $library . ' ' . $version); - $this->assertSame([], $diff->other(), 'changes in ' . $library . ' ' . $version); - $this->assertSame($committed, $live); + try { + $result = (new CommittedSnapshots($directory))->compare($library, '0.0.2', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNotNull($owner); + $this->assertNotNull($result); + $breaking = $result['diff']->breaking(); + $this->assertCount(1, $breaking); + $this->assertStringStartsWith($owner . ': ', implode('', $breaking)); + $this->assertStringContainsString('Tampered', implode('', $breaking)); + } + + public function testAFakeVersionBelowEverySnapshotHasNothingToCompareWith(): void + { + $live = MemberSnapshot::of('mcp/sdk'); + $directory = $this->directoryWith('mcp/sdk', '0.5.0', $live); + + try { + $result = (new CommittedSnapshots($directory))->compare('mcp/sdk', '0.4.0', $live, PrivateMemberManifest::members()); + } finally { + FileTree::remove($directory); + } + + $this->assertNull($result); + } + + public function testEveryMemberTheManifestListsIsInEveryCommittedSnapshotOfItsLibrary(): void + { + $read = SnapshotDiff::readMembers(PrivateMemberManifest::members()); + $checked = 0; + foreach (MemberSnapshot::libraries() as $library) { + foreach (CommittedSnapshots::committed()->all($library) as $path) { + $classes = MemberSnapshot::fromJson((string) file_get_contents($path))['classes']; + foreach ($read['properties'] as $class => $names) { + if (str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + foreach ($names as $name) { + $this->assertArrayHasKey($name, $classes[$class]['properties'] ?? [], basename($path) . ': ' . $class . '::$' . $name); + ++$checked; + } + } + } + foreach ($read['methods'] as $class => $names) { + if (str_starts_with($class, MemberSnapshot::namespaceOf($library))) { + foreach ($names as $name) { + $this->assertArrayHasKey($name, $classes[$class]['methods'] ?? [], basename($path) . ': ' . $class . '::' . $name . '()'); + ++$checked; + } + } + } + } + } + + $this->assertGreaterThan(30, $checked); } public function testTheSnapshotCoversEveryClassTheManifestNamesForTheLibrary(): void @@ -63,9 +185,9 @@ public function testSelfIsSpelledAsTheClassWhateverThePhpVersion(): void public function testAnUnknownLibraryIsRejected(): void { - $this->expectException(\InvalidArgumentException::class); + $e = Thrown::by(\InvalidArgumentException::class, static fn(): array => MemberSnapshot::of('symfony/mcp-bundle')); - MemberSnapshot::of('symfony/mcp-bundle'); + $this->assertSame('unknown library symfony/mcp-bundle (expected laravel/mcp or mcp/sdk)', $e->getMessage()); } public function testASnapshotSurvivesItsJson(): void @@ -74,4 +196,28 @@ public function testASnapshotSurvivesItsJson(): void $this->assertSame($snapshot, MemberSnapshot::fromJson(MemberSnapshot::toJson($snapshot))); } + + /** @param Snapshot $snapshot */ + private function directoryWith(string $library, string $version, array $snapshot): string + { + $directory = sys_get_temp_dir() . '/agentcat-snapshots-' . bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory . '/' . str_replace('/', '-', $library) . '-' . $version . '.json', MemberSnapshot::toJson($snapshot)); + + return $directory; + } + + /** + * The snapshot with the type of one property changed. + * + * @param Snapshot $snapshot + * + * @return Snapshot + */ + private function withTamperedType(array $snapshot, string $class, string $name): array + { + $changed = array_replace_recursive($snapshot, ['classes' => [$class => ['properties' => [$name => ['type' => 'Tampered']]]]]); + + return MemberSnapshot::fromJson(json_encode($changed, JSON_THROW_ON_ERROR)); + } } diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.0.json b/tests/Contract/snapshots/laravel-mcp-1.0.0.json index a8b890d..2a06a1f 100644 --- a/tests/Contract/snapshots/laravel-mcp-1.0.0.json +++ b/tests/Contract/snapshots/laravel-mcp-1.0.0.json @@ -1,6 +1,8 @@ { "classes": { "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], "constants": { "CACHEABLE_METHODS": { "visibility": "public" @@ -388,92 +390,109 @@ }, "properties": { "attributeCache": { + "hasDefault": true, "readonly": false, "static": true, "type": "array", "visibility": "protected" }, "capabilities": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "defaultPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "extensions": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "instructions": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "maxPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "methods": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "name": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "prompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "resources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "supportedProtocolVersion": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "tools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "transport": { + "hasDefault": false, "readonly": false, "static": false, "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", "visibility": "protected" }, "version": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" } - } + }, + "readonly": false }, "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -501,9 +520,12 @@ "visibility": "public" } }, - "properties": [] + "properties": [], + "readonly": false }, "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -717,9 +739,12 @@ "visibility": "private" } }, - "properties": [] + "properties": [], + "readonly": false }, "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -851,30 +876,35 @@ }, "properties": { "handler": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" }, "reply": { + "hasDefault": false, "readonly": false, "static": false, "type": "?string", "visibility": "protected" }, "request": { + "hasDefault": false, "readonly": false, "static": false, "type": "Illuminate\\Http\\Request", "visibility": "protected" }, "stream": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" } - } + }, + "readonly": false } }, "library": "laravel/mcp", diff --git a/tests/Contract/snapshots/laravel-mcp-1.0.2.json b/tests/Contract/snapshots/laravel-mcp-1.0.2.json index d18637e..cfc5c05 100644 --- a/tests/Contract/snapshots/laravel-mcp-1.0.2.json +++ b/tests/Contract/snapshots/laravel-mcp-1.0.2.json @@ -1,6 +1,8 @@ { "classes": { "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], "constants": { "CACHEABLE_METHODS": { "visibility": "public" @@ -388,92 +390,109 @@ }, "properties": { "attributeCache": { + "hasDefault": true, "readonly": false, "static": true, "type": "array", "visibility": "protected" }, "capabilities": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "defaultPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "extensions": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "instructions": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "maxPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "methods": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "name": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "prompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "resources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "supportedProtocolVersion": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "tools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "transport": { + "hasDefault": false, "readonly": false, "static": false, "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", "visibility": "protected" }, "version": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" } - } + }, + "readonly": false }, "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -501,9 +520,12 @@ "visibility": "public" } }, - "properties": [] + "properties": [], + "readonly": false }, "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -735,14 +757,18 @@ }, "properties": { "mirrorStructuredContent": { + "hasDefault": false, "readonly": false, "static": false, "type": "bool", "visibility": "protected" } - } + }, + "readonly": false }, "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -874,30 +900,35 @@ }, "properties": { "handler": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" }, "reply": { + "hasDefault": false, "readonly": false, "static": false, "type": "?string", "visibility": "protected" }, "request": { + "hasDefault": false, "readonly": false, "static": false, "type": "Illuminate\\Http\\Request", "visibility": "protected" }, "stream": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" } - } + }, + "readonly": false } }, "library": "laravel/mcp", diff --git a/tests/Contract/snapshots/laravel-mcp-1.1.0.json b/tests/Contract/snapshots/laravel-mcp-1.1.0.json index bbf8e22..20a71bd 100644 --- a/tests/Contract/snapshots/laravel-mcp-1.1.0.json +++ b/tests/Contract/snapshots/laravel-mcp-1.1.0.json @@ -1,6 +1,8 @@ { "classes": { "Laravel\\Mcp\\Server": { + "abstract": true, + "abstractMethods": [], "constants": { "CACHEABLE_METHODS": { "visibility": "public" @@ -388,92 +390,109 @@ }, "properties": { "attributeCache": { + "hasDefault": true, "readonly": false, "static": true, "type": "array", "visibility": "protected" }, "capabilities": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "defaultPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "extensions": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "instructions": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "maxPaginationLength": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "public" }, "methods": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "name": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" }, "prompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "resources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "supportedProtocolVersion": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "tools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "protected" }, "transport": { + "hasDefault": false, "readonly": false, "static": false, "type": "Laravel\\Mcp\\Server\\Contracts\\Transport", "visibility": "protected" }, "version": { + "hasDefault": true, "readonly": false, "static": false, "type": "string", "visibility": "protected" } - } + }, + "readonly": false }, "Laravel\\Mcp\\Server\\Methods\\CallTool": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -501,9 +520,12 @@ "visibility": "public" } }, - "properties": [] + "properties": [], + "readonly": false }, "Laravel\\Mcp\\Server\\ToolInvoker": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -735,14 +757,18 @@ }, "properties": { "mirrorStructuredContent": { + "hasDefault": false, "readonly": false, "static": false, "type": "bool", "visibility": "protected" } - } + }, + "readonly": false }, "Laravel\\Mcp\\Server\\Transport\\HttpTransport": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -874,30 +900,35 @@ }, "properties": { "handler": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" }, "reply": { + "hasDefault": false, "readonly": false, "static": false, "type": "?string", "visibility": "protected" }, "request": { + "hasDefault": false, "readonly": false, "static": false, "type": "Illuminate\\Http\\Request", "visibility": "protected" }, "stream": { + "hasDefault": false, "readonly": false, "static": false, "type": "?Closure", "visibility": "protected" } - } + }, + "readonly": false } }, "library": "laravel/mcp", diff --git a/tests/Contract/snapshots/mcp-sdk-0.8.1.json b/tests/Contract/snapshots/mcp-sdk-0.8.1.json index 943845b..9d2f35b 100644 --- a/tests/Contract/snapshots/mcp-sdk-0.8.1.json +++ b/tests/Contract/snapshots/mcp-sdk-0.8.1.json @@ -1,6 +1,8 @@ { "classes": { "Mcp\\Schema\\Result\\CallToolResult": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -114,32 +116,39 @@ }, "properties": { "content": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "public" }, "isError": { + "hasDefault": false, "readonly": true, "static": false, "type": "bool", "visibility": "public" }, "meta": { + "hasDefault": false, "readonly": true, "static": false, "type": "?array", "visibility": "public" }, "structuredContent": { + "hasDefault": false, "readonly": true, "static": false, "type": "mixed", "visibility": "public" } - } + }, + "readonly": false }, "Mcp\\Schema\\Result\\ListToolsResult": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -193,20 +202,25 @@ }, "properties": { "nextCursor": { + "hasDefault": false, "readonly": true, "static": false, "type": "?string", "visibility": "public" }, "tools": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "public" } - } + }, + "readonly": false }, "Mcp\\Server": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": true, "methods": { @@ -267,26 +281,32 @@ }, "properties": { "logger": { + "hasDefault": false, "readonly": true, "static": false, "type": "Psr\\Log\\LoggerInterface", "visibility": "private" }, "protocol": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\Server\\Protocol", "visibility": "private" }, "statelessProtocol": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", "visibility": "private" } - } + }, + "readonly": false }, "Mcp\\Server\\Builder": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": true, "methods": { @@ -1278,302 +1298,354 @@ }, "properties": { "cachePolicy": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Wire\\CachePolicy", "visibility": "private" }, "container": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\Container\\ContainerInterface", "visibility": "private" }, "discoverer": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", "visibility": "private" }, "discoveryBasePath": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "discoveryCache": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\SimpleCache\\CacheInterface", "visibility": "private" }, "discoveryExcludeDirs": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "discoveryNamePatterns": { + "hasDefault": true, "readonly": false, "static": false, "type": "?array", "visibility": "private" }, "discoveryScanDirs": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "eventDispatcher": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", "visibility": "private" }, "explicitPrompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitResourceTemplates": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitResources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitTools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensionMessages": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensionMethods": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensions": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "gcDivisor": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "gcProbability": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "hasCustomRegistry": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "headerValidation": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "inputRequiredRounds": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "inputRequiredShim": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "inputRequiredTimeout": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "instructions": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "lazyLoading": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "loaders": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "logger": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\Log\\LoggerInterface", "visibility": "private" }, "modernVersions": { + "hasDefault": true, "readonly": false, "static": false, "type": "?array", "visibility": "private" }, "notificationBus": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", "visibility": "private" }, "notificationHandlers": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "paginationLimit": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "parts": { + "hasDefault": true, "readonly": false, "static": false, "type": "?array", "visibility": "private" }, "prompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "protocolVersion": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", "visibility": "private" }, "referenceHandler": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", "visibility": "private" }, "registry": { + "hasDefault": false, "readonly": false, "static": false, "type": "Mcp\\Capability\\RegistryInterface", "visibility": "private" }, "requestHandlers": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "requestStateKey": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "requestStateTtl": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "resourceTemplates": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "resources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "schemaGenerator": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", "visibility": "private" }, "serverCapabilities": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\ServerCapabilities", "visibility": "private" }, "serverInfo": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\Implementation", "visibility": "private" }, "sessionManager": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Session\\SessionManagerInterface", "visibility": "private" }, "sessionStore": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Session\\SessionStoreInterface", "visibility": "private" }, "subscriptionLifetime": { + "hasDefault": true, "readonly": false, "static": false, "type": "float", "visibility": "private" }, "subscriptionManager": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", "visibility": "private" }, "tools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" } - } + }, + "readonly": false }, "Mcp\\Server\\Protocol": { + "abstract": false, + "abstractMethods": [], "constants": { "INTERNAL_ERROR_MESSAGE": { "visibility": "private" @@ -2141,54 +2213,63 @@ }, "properties": { "eventDispatcher": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", "visibility": "private" }, "inputRequiredShim": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\InputRequiredShim", "visibility": "private" }, "logger": { + "hasDefault": false, "readonly": true, "static": false, "type": "Psr\\Log\\LoggerInterface", "visibility": "private" }, "messageFactory": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\JsonRpc\\MessageFactory", "visibility": "private" }, "notificationHandlers": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "private" }, "requestHandlers": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "private" }, "requestStateCodec": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", "visibility": "private" }, "sessionManager": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\Server\\Session\\SessionManagerInterface", "visibility": "private" } - } + }, + "readonly": false } }, "library": "mcp/sdk", diff --git a/tests/Contract/snapshots/mcp-sdk-0.9.0.json b/tests/Contract/snapshots/mcp-sdk-0.9.0.json index 44609c0..8976bff 100644 --- a/tests/Contract/snapshots/mcp-sdk-0.9.0.json +++ b/tests/Contract/snapshots/mcp-sdk-0.9.0.json @@ -1,6 +1,8 @@ { "classes": { "Mcp\\Schema\\Result\\CallToolResult": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -114,32 +116,39 @@ }, "properties": { "content": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "public" }, "isError": { + "hasDefault": false, "readonly": true, "static": false, "type": "bool", "visibility": "public" }, "meta": { + "hasDefault": false, "readonly": true, "static": false, "type": "?array", "visibility": "public" }, "structuredContent": { + "hasDefault": false, "readonly": true, "static": false, "type": "mixed", "visibility": "public" } - } + }, + "readonly": false }, "Mcp\\Schema\\Result\\ListToolsResult": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": false, "methods": { @@ -193,20 +202,25 @@ }, "properties": { "nextCursor": { + "hasDefault": false, "readonly": true, "static": false, "type": "?string", "visibility": "public" }, "tools": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "public" } - } + }, + "readonly": false }, "Mcp\\Server": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": true, "methods": { @@ -274,32 +288,39 @@ }, "properties": { "handshakeVersions": { + "hasDefault": false, "readonly": true, "static": false, "type": "?array", "visibility": "private" }, "logger": { + "hasDefault": false, "readonly": true, "static": false, "type": "Psr\\Log\\LoggerInterface", "visibility": "private" }, "protocol": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\Server\\Protocol", "visibility": "private" }, "statelessProtocol": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\Stateless\\StatelessProtocol", "visibility": "private" } - } + }, + "readonly": false }, "Mcp\\Server\\Builder": { + "abstract": false, + "abstractMethods": [], "constants": [], "final": true, "methods": { @@ -1316,314 +1337,368 @@ }, "properties": { "cachePolicy": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Wire\\CachePolicy", "visibility": "private" }, "container": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\Container\\ContainerInterface", "visibility": "private" }, "discoverer": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Discovery\\DiscovererInterface", "visibility": "private" }, "discoveryBasePath": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "discoveryCache": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\SimpleCache\\CacheInterface", "visibility": "private" }, "discoveryExcludeDirs": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "discoveryNamePatterns": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "discoveryScanDirs": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "eventDispatcher": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", "visibility": "private" }, "explicitPrompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitResourceTemplates": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitResources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "explicitTools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensionMessages": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensionMethods": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "extensions": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "gcDivisor": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "gcProbability": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "hasCustomRegistry": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "headerValidation": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "inputRequiredRounds": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "inputRequiredShim": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "inputRequiredTimeout": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "instructions": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "lazyLoading": { + "hasDefault": true, "readonly": false, "static": false, "type": "bool", "visibility": "private" }, "loaders": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "logger": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Psr\\Log\\LoggerInterface", "visibility": "private" }, "modernVersions": { + "hasDefault": true, "readonly": false, "static": false, "type": "?array", "visibility": "private" }, "notificationBus": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Subscription\\NotificationBusInterface", "visibility": "private" }, "notificationHandlers": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "paginationLimit": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "parts": { + "hasDefault": true, "readonly": false, "static": false, "type": "?array", "visibility": "private" }, "prompts": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "protocolVersion": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\Enum\\ProtocolVersion", "visibility": "private" }, "referenceHandler": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Registry\\ReferenceHandlerInterface", "visibility": "private" }, "registry": { + "hasDefault": false, "readonly": false, "static": false, "type": "Mcp\\Capability\\RegistryInterface", "visibility": "private" }, "requestHandlers": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "requestStateKey": { + "hasDefault": true, "readonly": false, "static": false, "type": "?string", "visibility": "private" }, "requestStateTtl": { + "hasDefault": true, "readonly": false, "static": false, "type": "int", "visibility": "private" }, "resourceTemplates": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "resources": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "schemaGenerator": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Discovery\\SchemaGeneratorInterface", "visibility": "private" }, "schemaValidator": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Capability\\Discovery\\SchemaValidator", "visibility": "private" }, "serverCapabilities": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\ServerCapabilities", "visibility": "private" }, "serverInfo": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Schema\\Implementation", "visibility": "private" }, "sessionLock": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Session\\SessionLockInterface", "visibility": "private" }, "sessionManager": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Session\\SessionManagerInterface", "visibility": "private" }, "sessionStore": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Session\\SessionStoreInterface", "visibility": "private" }, "subscriptionLifetime": { + "hasDefault": true, "readonly": false, "static": false, "type": "float", "visibility": "private" }, "subscriptionManager": { + "hasDefault": true, "readonly": false, "static": false, "type": "?Mcp\\Server\\Resource\\SubscriptionManagerInterface", "visibility": "private" }, "tools": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" } - } + }, + "readonly": false }, "Mcp\\Server\\Protocol": { + "abstract": false, + "abstractMethods": [], "constants": { "ABANDONED_REQUEST_GRACE": { "visibility": "private" @@ -2622,78 +2697,91 @@ }, "properties": { "awaitedRequestIds": { + "hasDefault": false, "readonly": false, "static": false, "type": "WeakMap", "visibility": "private" }, "droppedAwaitedRequestIds": { + "hasDefault": true, "readonly": false, "static": false, "type": "array", "visibility": "private" }, "eventDispatcher": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", "visibility": "private" }, "fiberOutgoingMessages": { + "hasDefault": false, "readonly": false, "static": false, "type": "WeakMap", "visibility": "private" }, "inputRequiredShim": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\InputRequiredShim", "visibility": "private" }, "logger": { + "hasDefault": false, "readonly": true, "static": false, "type": "Psr\\Log\\LoggerInterface", "visibility": "private" }, "messageFactory": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\JsonRpc\\MessageFactory", "visibility": "private" }, "notificationHandlers": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "private" }, "requestHandlers": { + "hasDefault": false, "readonly": true, "static": false, "type": "array", "visibility": "private" }, "requestStateCodec": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\Stateless\\RequestStateCodec", "visibility": "private" }, "sessionLock": { + "hasDefault": false, "readonly": true, "static": false, "type": "?Mcp\\Server\\Session\\SessionLockInterface", "visibility": "private" }, "sessionManager": { + "hasDefault": false, "readonly": true, "static": false, "type": "Mcp\\Server\\Session\\SessionManagerInterface", "visibility": "private" } - } + }, + "readonly": false } }, "library": "mcp/sdk", From a2037a354bbbcbe9067ea6ea45234d999eabd2f3 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 19:43:06 +0000 Subject: [PATCH 38/68] test: trace every acceptance criterion to a test or a stated gap --- tests/Architecture/TraceabilityTest.php | 130 ++++++ tests/Golden/DeriveVectorsTest.php | 1 + tests/Integration/ClientTrackTest.php | 22 + tests/Integration/HardenedHostTest.php | 2 + tests/Integration/McpSdk/CallErrorsTest.php | 4 + tests/Integration/McpSdk/HooksTest.php | 62 +++ .../Integration/McpSdk/ListingGoldenTest.php | 1 + .../Integration/McpSdk/RequestContextTest.php | 1 + tests/Traceability/criteria.php | 433 ++++++++++++++++++ tests/Traceability/gaps.php | 10 + .../Internal/Engine/CallInterceptorTest.php | 9 +- .../Internal/Engine/ClientIdentityTest.php | 1 + .../Internal/Engine/ToolListInjectorTest.php | 23 + tests/Unit/Internal/Handles/MintBackTest.php | 2 + tests/Unit/Internal/Id/KsuidTest.php | 3 + .../Internal/Pipeline/EventProcessorTest.php | 2 +- .../Internal/Pipeline/StringRedactorTest.php | 1 + tests/Unit/Internal/Tags/TagValidatorTest.php | 11 + 18 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 tests/Architecture/TraceabilityTest.php create mode 100644 tests/Traceability/criteria.php create mode 100644 tests/Traceability/gaps.php diff --git a/tests/Architecture/TraceabilityTest.php b/tests/Architecture/TraceabilityTest.php new file mode 100644 index 0000000..c309294 --- /dev/null +++ b/tests/Architecture/TraceabilityTest.php @@ -0,0 +1,130 @@ +-`: a number, a letter and a number, or `g` and a number for a golden body. */ + private const string ID = '/\Aac-0[1-7]-(?:[1-9][0-9]*|[A-Z][1-9][0-9]*|g[1-9])\z/'; + + private const int TITLE_LENGTH = 120; + + public function testEveryCriterionHasATestOrAStatedGap(): void + { + $tested = $this->acGroups(); + $this->assertNotSame([], $tested, 'the scan finds the ac- groups of the tests'); + + $missing = array_values(array_diff(array_keys($this->criteria()), array_keys($tested), array_keys($this->gaps()))); + + $this->assertSame([], $missing, "These criteria have no test and no stated gap:\n" . implode("\n", $missing)); + } + + public function testEveryGroupNamesACriterion(): void + { + $unknown = []; + foreach (array_diff_key($this->acGroups(), $this->criteria()) as $id => $places) { + foreach ($places as $place) { + $unknown[] = $id . ' at ' . $place; + } + } + + $this->assertSame([], $unknown, "These ac- groups name no criterion:\n" . implode("\n", $unknown)); + } + + public function testACriterionWithATestIsNoGap(): void + { + $both = array_keys(array_intersect_key($this->gaps(), $this->acGroups())); + + $this->assertSame([], $both, "These gaps have a test now; remove them from gaps.php:\n" . implode("\n", $both)); + } + + public function testEveryGapIsACriterionWithAReason(): void + { + $gaps = $this->gaps(); + + $this->assertSame([], array_keys(array_diff_key($gaps, $this->criteria())), 'every gap is a criterion'); + foreach ($gaps as $id => $reason) { + $this->assertNotSame('', trim($reason), $id . ' states its reason'); + $this->assertStringNotContainsString("\n", $reason, $id . ': the reason is one line'); + } + } + + public function testEveryCriterionHasAnIdAndAOneLineTitle(): void + { + $criteria = $this->criteria(); + $this->assertNotSame([], $criteria); + + foreach ($criteria as $id => $title) { + $this->assertMatchesRegularExpression(self::ID, $id); + $this->assertNotSame('', trim($title), $id . ' has a title'); + $this->assertStringNotContainsString("\n", $title, $id . ': the title is one line'); + $this->assertLessThanOrEqual(self::TITLE_LENGTH, mb_strlen($title), $id . ': the title has at most 120 characters'); + } + } + + /** + * Every `ac-` group in the PHP files under `tests/`, with the `file:line` of each use. + * + * @return array> + */ + private function acGroups(): array + { + $root = dirname(__DIR__); + $groups = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $this->assertInstanceOf(\SplFileInfo::class, $file); + if ($file->getExtension() !== 'php') { + continue; + } + $source = file_get_contents($file->getPathname()); + $this->assertIsString($source, $file->getPathname()); + preg_match_all(self::GROUP, $source, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[2] as [$id, $offset]) { + $line = substr_count($source, "\n", 0, $offset) + 1; + $groups[$id][] = substr($file->getPathname(), strlen($root) + 1) . ':' . $line; + } + } + ksort($groups); + + return $groups; + } + + /** @return array */ + private function criteria(): array + { + return $this->load('criteria.php'); + } + + /** @return array */ + private function gaps(): array + { + return $this->load('gaps.php'); + } + + /** @return array */ + private function load(string $file): array + { + $list = require __DIR__ . '/../Traceability/' . $file; + $this->assertIsArray($list, $file); + $entries = []; + foreach ($list as $id => $text) { + $this->assertIsString($id, $file); + $this->assertIsString($text, $file); + $entries[$id] = $text; + } + + return $entries; + } +} diff --git a/tests/Golden/DeriveVectorsTest.php b/tests/Golden/DeriveVectorsTest.php index 46c9afe..bb6b404 100644 --- a/tests/Golden/DeriveVectorsTest.php +++ b/tests/Golden/DeriveVectorsTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; #[Group('ac-04-6')] +#[Group('ac-03-22')] final class DeriveVectorsTest extends TestCase { /** diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index f209c88..902b421 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -242,6 +242,28 @@ public function testABuiltBuilderIsLeftAlone(): void $this->assertSame(['echo'], $this->names($this->harness($builder)->listTools()), 'no AgentCat handler was added'); } + /** + * The Builder keeps the server name in a member that AgentCat reads (`$parts`, set by `build()`). When that member + * cannot be read, track() returns the Builder untracked, and the compatibility lines name the member. + */ + #[Group('ac-02-3')] + public function testABuilderWhoseServerInfoCannotBeReadIsReturnedUntracked(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + BuilderMembers::uninitialize('parts')($builder); + + $this->assertSame($builder, $this->client->track($builder, self::PROJECT)); + + $reason = sprintf('mcp/sdk %s internals changed (Error: Typed property Mcp\Server\Builder::$parts must not be accessed before initialization).', McpSdkAdapter::version()); + $this->assertSame([ + 'AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, + 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: ' . $reason . ' ' . McpSdkAdapter::SUPPORT_MATRIX, + ], $this->logger->messages()); + $this->assertNull(TrackedServers::get($builder), 'nothing is tracked'); + $this->assertNull(BuilderAccess::container($builder), 'nothing is installed'); + } + #[Group('ac-01-3')] public function testAnInternalFailureReturnsTheServerAndLogsIt(): void { diff --git a/tests/Integration/HardenedHostTest.php b/tests/Integration/HardenedHostTest.php index 8efff84..a307c6b 100644 --- a/tests/Integration/HardenedHostTest.php +++ b/tests/Integration/HardenedHostTest.php @@ -90,6 +90,8 @@ public static function lists(): iterable #[DataProvider('lists')] #[Group('hardened-host')] + #[Group('ac-03-37')] + #[Group('ac-03-9')] #[MaximumDuration(10000)] public function testTheServerAnswersAndEndsWithOneLinePerMissingFunction(string $functions): void { diff --git a/tests/Integration/McpSdk/CallErrorsTest.php b/tests/Integration/McpSdk/CallErrorsTest.php index c9e01f6..f0a98bb 100644 --- a/tests/Integration/McpSdk/CallErrorsTest.php +++ b/tests/Integration/McpSdk/CallErrorsTest.php @@ -52,6 +52,7 @@ public function testAToolCallExceptionIsAnErrorResultAndTheEventKeepsTheExceptio $this->assertNotSame([], JsonAssert::at($event->error, 'frames'), 'the stashed exception wins over the result text'); } + #[Group('ac-01-80')] public function testAnErrorResultWithoutAnExceptionIsAnErrorEvent(): void { $twin = HttpHarness::result($this->fixture->harness($this->builder())->callTool('refuses', [])); @@ -70,6 +71,8 @@ public function testAnErrorResultWithoutAnExceptionIsAnErrorEvent(): void #[Group('ac-02-24')] #[Group('ac-02-25')] #[Group('ac-05-E5')] + #[Group('ac-01-80')] + #[Group('ac-04-44')] public function testAnUncaughtToolExceptionGivesTheLibrarysErrorAndAnEventWithTheException(): void { $twin = $this->fixture->harness($this->builder())->callTool('boom', []); @@ -120,6 +123,7 @@ public function testInvalidArgumentsGiveTheLibrarysErrorAndAnErrorEvent(): void } #[Group('ac-05-E7')] + #[Group('ac-01-80')] public function testASuccessfulCallHasNoError(): void { $harness = $this->tracked(); diff --git a/tests/Integration/McpSdk/HooksTest.php b/tests/Integration/McpSdk/HooksTest.php index d5b6729..c107956 100644 --- a/tests/Integration/McpSdk/HooksTest.php +++ b/tests/Integration/McpSdk/HooksTest.php @@ -8,6 +8,7 @@ use AgentCat\Identity; use AgentCat\Internal\Constants; use AgentCat\Internal\Id\SessionId; +use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; use AgentCat\Options; use AgentCat\Tests\Support\JsonAssert; @@ -17,12 +18,15 @@ use AgentCat\ToolCall; use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; /** Customer hooks through a real server: what they see, and that they run after the response (D04). */ final class HooksTest extends TestCase { + private const string SESSION = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + private TrackingFixture $fixture; #[\Override] @@ -101,6 +105,64 @@ public function testAThrowingIdentifyLeavesTheCallAndTheEventAnonymous(): void $this->assertContains('Error: identify callback threw - RuntimeException: who?', $this->fixture->lines()); } + /** + * An `Identity` always has a userId, so an identity without one reaches AgentCat as an array or another object. + * + * @return iterable + */ + public static function identitiesWithoutAUserId(): iterable + { + yield 'an array' => [['userName' => 'Ada', 'userData' => ['plan' => 'pro']]]; + yield 'a plain object' => [(object) ['userName' => 'Ada', 'userData' => ['plan' => 'pro']]]; + } + + #[DataProvider('identitiesWithoutAUserId')] + #[Group('ac-01-36')] + public function testAnIdentityWithoutAUserIdLeavesTheEventAnonymous(mixed $identity): void + { + $harness = $this->tracked(Options::fromArray(['identify' => static fn(): mixed => $identity])); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 0, 'text')); + $this->fixture->event(); + $this->assertCount(1, $this->fixture->transport->deliveries); + $body = Json::decode($this->fixture->transport->deliveries[0]->body); + $this->assertInstanceOf(\stdClass::class, $body); + foreach (['actor_id', 'identify_actor_given_id', 'identify_actor_name'] as $key) { + $this->assertObjectNotHasProperty($key, $body); + } + $this->assertSame('{}', $this->json(JsonAssert::at($body, 'identify_data'))); + $this->assertContains(sprintf('WARN: identify returned %s, expected AgentCat\Identity or null; the event stays anonymous', get_debug_type($identity)), $this->fixture->lines()); + } + + /** + * What AgentCat keeps for a tracked server is its project ID, its options and its listing; the rest of its state + * is read-only. The calls it serves leave nothing there, an identity or a session no more than anything else: each + * event has its own call's identity. + */ + #[Group('ac-01-39')] + public function testTheStateOfAServerKeepsNothingOfItsCalls(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $options = new Options(identify: static fn(ToolCall $call): Identity => new Identity('user-' . (is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : ''))); + $state = $this->fixture->track($builder, $options); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + $listed = [$state->registry, $state->outputRegistry, $state->everyPageRead]; + $injection = clone $state->injection; + + $harness->callTool('echo', ['msg' => '1', 'session_id' => 'start']); + $harness->callTool('echo', ['msg' => '2', 'session_id' => self::SESSION]); + + $events = $this->fixture->events(); + $this->assertSame(['user-1', 'user-2'], array_map(static fn(Event $event): ?string => $event->identifyActorGivenId, $events)); + $this->assertSame([TrackingFixture::PROJECT, $options], [$state->projectId, $state->options]); + $this->assertSame($listed, [$state->registry, $state->outputRegistry, $state->everyPageRead], 'the calls changed nothing in the listing'); + $this->assertEquals($injection, $state->injection, 'nor in the injection state'); + } + #[Group('ac-01-30')] public function testAThrowingResolveSessionIdMintsASessionAndAnnouncesNothing(): void { diff --git a/tests/Integration/McpSdk/ListingGoldenTest.php b/tests/Integration/McpSdk/ListingGoldenTest.php index 056f9eb..d2bb099 100644 --- a/tests/Integration/McpSdk/ListingGoldenTest.php +++ b/tests/Integration/McpSdk/ListingGoldenTest.php @@ -71,6 +71,7 @@ public static function configs(): iterable } #[DataProvider('configs')] + #[Group('ac-01-21')] public function testTheInjectedListingMatchesTypeScriptInBothEras(string $key, Options $options): void { $fixture = Golden::json('handles/listing-fixtures.json'); diff --git a/tests/Integration/McpSdk/RequestContextTest.php b/tests/Integration/McpSdk/RequestContextTest.php index 502b159..c69d056 100644 --- a/tests/Integration/McpSdk/RequestContextTest.php +++ b/tests/Integration/McpSdk/RequestContextTest.php @@ -41,6 +41,7 @@ protected function tearDown(): void } #[Group('ac-02-44')] + #[Group('ac-01-81')] public function testClientInfoInMetaBeatsTheHandshakeFieldByField(): void { $harness = $this->tracked(); diff --git a/tests/Traceability/criteria.php b/tests/Traceability/criteria.php new file mode 100644 index 0000000..99baca3 --- /dev/null +++ b/tests/Traceability/criteria.php @@ -0,0 +1,433 @@ + 'track() returns the same server instance it was given.', + 'ac-01-2' => 'track() on an unsupported object throws nothing, returns it, and logs the compatibility or unrecognized-shape line.', + 'ac-01-3' => 'An internal failure in track() returns the input server and logs `Warning: Failed to track server - …`.', + 'ac-01-4' => 'A successful track() logs one `AgentCat setup started` and one `AgentCat setup complete` line with the options.', + 'ac-01-5' => 'Without a projectId and exporters, tracking is still installed and the no-projectId warning is logged.', + 'ac-01-6' => 'Without a projectId, the setup lines show `project (telemetry-only)`.', + 'ac-01-7' => 'A second track() of a tracked server changes nothing: one event, one block, one get_more_tools, the first hooks.', + 'ac-01-8' => 'track() on the low-level server of a tracked high-level server treats it as already tracked.', + 'ac-01-9' => 'Two servers tracked with different identify hooks each stamp their own identity on their events.', + 'ac-01-10' => 'Only the exporters of the first track() receive events.', + 'ac-01-11' => 'Diagnostics that the first track() disables stay off after a later track().', + 'ac-01-12' => 'The apiBaseUrl of the latest track() is used for later sends.', + 'ac-01-13' => 'By default report-missing, tracing and tool-call context are on, and agent tracking is off.', + 'ac-01-14' => 'With tracing off, no session_id or agent_id is listed, no event is published and no result is decorated.', + 'ac-01-15' => 'With tracing off and report-missing on, get_more_tools returns the canned answer and publishes no event.', + 'ac-01-16' => 'With report-missing off, get_more_tools is not listed, and session_id and context are still injected.', + 'ac-01-17' => 'With tool-call context off, no context parameter is listed, session_id still is, and events have no userIntent.', + 'ac-01-18' => 'customContextDescription replaces the context description of every tool except get_more_tools.', + 'ac-01-19' => 'An empty or absent customContextDescription gives the default context description, byte for byte.', + 'ac-01-20' => 'With tool-call context on, the `context` argument of a call becomes the event\'s userIntent.', + 'ac-01-21' => 'With agent tracking on, every listed tool, get_more_tools included, has a required string agent_id.', + 'ac-01-22' => 'With agent tracking on, a supplied agent_id is tagged agentcat_agent_id, with agentcat_agent_id_source supplied.', + 'ac-01-23' => 'With agent tracking on and no agent_id, the call succeeds and the event has no agent tags.', + 'ac-01-24' => 'With agent tracking off, no tool has agent_id and no agent tags appear.', + 'ac-01-25' => 'The agentcat_agent_id tag turns CR and LF into spaces and keeps 200 characters; the request keeps the raw value.', + 'ac-01-26' => 'In hook mode (resolveSessionId), no listed tool has a session_id parameter.', + 'ac-01-27' => 'In hook mode, one hook value derives the same golden ses_ ID on every server instance, tagged hook.', + 'ac-01-28' => 'Two different hook values give two different session IDs.', + 'ac-01-29' => 'A hook that returns null or an empty string, throws or never settles mints a session and adds no session data.', + 'ac-01-30' => 'In hook mode, a tool response never gets an issuance or unrecognized block.', + 'ac-01-31' => 'identify runs once per call with the raw request and the context, and its identity is stamped on the event.', + 'ac-01-32' => 'identify runs on every call (no cache), and each event is stamped.', + 'ac-01-33' => 'identify returning a different identity per call stamps each event with the identity of its own call.', + 'ac-01-34' => 'identify returning null leaves no actor ID or name, and identify_data is `{}`.', + 'ac-01-35' => 'A throwing identify leaves the result unchanged and the event anonymous, and logs `Error: identify callback threw`.', + 'ac-01-36' => 'identify returning an identity without a userId leaves no actor ID or name, and the data is `{}`.', + 'ac-01-37' => 'A slow identify does not delay the tool response, and the event duration measures the handler only.', + 'ac-01-38' => 'Serving tools/list runs no hook and publishes no event.', + 'ac-01-39' => 'The state kept per server is its project ID and options: no identify cache or other data of its calls.', + 'ac-01-40' => 'The keys that eventTags returns are the event\'s customer tags, next to the SDK tags.', + 'ac-01-41' => 'An SDK tag wins over a customer tag of the same name.', + 'ac-01-42' => 'An eventTags key that is not valid is dropped with a `Dropping invalid tag` line; the valid keys stay.', + 'ac-01-43' => 'A tag with a 33-character key, a non-string value, a 201-character value or a newline in its value is dropped.', + 'ac-01-44' => 'Of 60 valid customer tags the first 50 stay, `Dropping 10 tag(s)` is logged, and the SDK tags are added on top.', + 'ac-01-45' => 'A throwing or null eventTags or eventProperties leaves the call working, with SDK tags only and no properties.', + 'ac-01-46' => 'The map that eventProperties returns is the event\'s properties.', + 'ac-01-47' => 'redactSensitiveInformation leaves tags and properties unchanged.', + 'ac-01-48' => 'redactSensitiveInformation gets the strings of parameters, response, userIntent and error, never protected fields.', + 'ac-01-49' => 'redactEvent returning null drops that event with `Event dropped by redactEvent hook`; other events still go out.', + 'ac-01-50' => 'A throwing redactEvent drops that event and logs the error.', + 'ac-01-51' => 'Changes that redactEvent makes to id, sessionId, projectId, eventType or timestamp are undone.', + 'ac-01-52' => 'When redactEvent removes response, the event is sent without a response.', + 'ac-01-53' => 'redactEvent runs after identity, tags, properties and session are resolved, and before redactSensitiveInformation.', + 'ac-01-54' => 'redactEvent applies to the custom event of a tracked server, and not to a custom event for a session ID string.', + 'ac-01-55' => 'The apiBaseUrl option wins over both environment variables.', + 'ac-01-56' => 'AGENTCAT_API_URL wins over MCPCAT_API_URL.', + 'ac-01-57' => 'MCPCAT_API_URL alone is used.', + 'ac-01-58' => 'With no option and no environment variable, events go to https://api.agentcat.com.', + 'ac-01-59' => 'An empty apiBaseUrl option or environment variable is skipped, as if unset.', + 'ac-01-60' => 'With no option and no test-environment signal, diagnostics are on.', + 'ac-01-61' => 'disableDiagnostics: true turns diagnostics off.', + 'ac-01-62' => 'DISABLE_DIAGNOSTICS set to 1, true, yes, on or any other non-empty value turns diagnostics off.', + 'ac-01-63' => 'DISABLE_DIAGNOSTICS set to false, 0, no or off keeps diagnostics on, even in a test environment.', + 'ac-01-64' => 'A whitespace-only DISABLE_DIAGNOSTICS is treated as unset.', + 'ac-01-65' => 'A PHP test-environment signal turns diagnostics off by default.', + 'ac-01-66' => 'publishCustomEvent() with an empty or null projectId throws `projectId is required for publishCustomEvent`.', + 'ac-01-67' => 'publishCustomEvent() with a first argument that is neither an object nor a string throws.', + 'ac-01-68' => 'publishCustomEvent() for an untracked server object throws `Server is not tracked. …`.', + 'ac-01-69' => 'publishCustomEvent() accepts a high-level server whose low-level server is tracked.', + 'ac-01-70' => 'publishCustomEvent() with a session ID string makes that string the event\'s session.', + 'ac-01-71' => 'A sessionId in the event data wins over the session ID string argument.', + 'ac-01-72' => 'The custom event of a tracked server without a sessionId has no session and logs `no sessionId provided`.', + 'ac-01-73' => 'A custom event has the type agentcat:custom, the call time as timestamp, and its message as user_intent.', + 'ac-01-74' => 'A custom event sends only its valid tags, and no SDK tags.', + 'ac-01-75' => 'A custom event with empty properties leaves properties out.', + 'ac-01-76' => 'A custom event with absent or empty event data has the core fields only.', + 'ac-01-77' => 'The custom event of a tracked server has the server, client and SDK fields; one for a session string has none.', + 'ac-01-78' => 'The custom event of a tracked server goes to the tracked project ID, or the SDK documents a deliberate fix.', + 'ac-01-79' => 'A tool call publishes one mcp:tools/call event with the raw request, extra, original result, duration and start.', + 'ac-01-80' => 'is_error is absent on success; an isError result or a throw sets is_error and error, and the throw is rethrown.', + 'ac-01-81' => 'client_name and client_version come from the envelope, _meta or handshake, the first one there, strings only.', + 'ac-01-82' => 'The _meta protocolVersion 2026-07-28 gives the tag agentcat_protocol_version=2026-07-28.', + 'ac-01-83' => 'No mcp:initialize, mcp:tools/list or agentcat:identify event is ever published.', + 'ac-01-84' => 'An event with an empty session has `session_id: null` on the wire.', + 'ac-01-85' => 'Every sent id matches ^evt_[0-9A-Za-z]{27}$, and actor_id equals identify_actor_given_id when that is set.', + 'ac-01-86' => 'sdk_language is a fixed PHP value, and agentcat_version is the installed agentcat/sdk version.', + 'ac-01-87' => 'The SDK writes nothing to STDOUT or STDERR: it logs to /agentcat.log, and silently without a home.', + 'ac-01-88' => 'The PHP constants, tag names and get_more_tools descriptor are byte-identical to those of the TypeScript SDK.', + 'ac-01-89' => 'AgentCatIDPrefixes has the cases Session = ses, Event = evt and Agent = agt.', + 'ac-01-90' => 'composer require agentcat/sdk autoloads the entry point, with no hard dependency on one PHP MCP SDK.', + + // The engine: tracking, tool listing, tool calls, sessions and hooks. + 'ac-02-1' => 'track() on each supported server flavor returns it, logs the setup lines with its sdk and signals, and intercepts.', + 'ac-02-2' => 'track() on a value that is no supported server returns it, logs the unrecognized-shape line and installs nothing.', + 'ac-02-3' => 'track() on a server whose name cannot be read fails safe, with a compatibility line that names the server info.', + 'ac-02-4' => 'A second track() with report-missing off is a no-op: get_more_tools stays listed and each call gives one event.', + 'ac-02-5' => 'A factory that tracks a server per request builds exporters and the queue once and keeps each server\'s state apart.', + 'ac-02-6' => 'With defaults, echo lists msg, session_id and context, all required, with the session_id pattern; get_more_tools last.', + 'ac-02-7' => 'With agent tracking, the order is msg, session_id, agent_id, context, all required, get_more_tools included.', + 'ac-02-8' => 'A wrapped listing keeps its nextCursor and its result _meta unchanged.', + 'ac-02-9' => 'A tool or tools handler registered after track() is injected like the others, and each call gives one event.', + 'ac-02-10' => 'A throwing list handler gives the client the same error, and no event.', + 'ac-02-11' => 'An empty tool list passes through unchanged, logs `No tools found in the original list…`, and sets no registry.', + 'ac-02-12' => 'A tool with an unprocessable schema (true, self-referencing, unencodable) is listed unmodified; others are injected.', + 'ac-02-13' => 'A total listing failure returns the original listing and logs `…serving the original tool list unmodified…`.', + 'ac-02-14' => 'A customer get_more_tools with report-missing on is listed once, shadow-warned once, and answered by AgentCat.', + 'ac-02-15' => 'A listing publishes no event and runs no identify, tags or properties hook.', + 'ac-02-16' => 'A call with a supplied session gets its own arguments only, and its one event records the raw call and the result.', + 'ac-02-17' => 'A strict tool accepts the injected parameters: they are stripped before the library validates the arguments.', + 'ac-02-18' => 'A loose tool sees a customer\'s extra key, and never session_id, agent_id or context.', + 'ac-02-19' => 'A tool that declares its own session_id or context gets its own value; the event\'s session is empty, tag foreign.', + 'ac-02-20' => 'The first tools/call on a fresh instance rebuilds the registries from the list handler, logs it, and strips exactly.', + 'ac-02-21' => 'A failed rebuild serves the call with the heuristic strip, logs `registry rebuild-on-demand failed`, and retries.', + 'ac-02-22' => 'When the runtime allows a timeout, a hanging list handler delays a tools/call by about 5 s at most.', + 'ac-02-23' => 'An isError result gives an isError event whose error is the original result\'s text and whose response is the result.', + 'ac-02-24' => 'An exception that the library turns into an isError result is recorded with its class, message, frames and chain.', + 'ac-02-25' => 'An exception that becomes a JSON-RPC error is recorded in one isError event and rethrown unchanged.', + 'ac-02-26' => 'An unknown tool gives one isError event named after it, and the library\'s own answer.', + 'ac-02-27' => 'A failure in AgentCat\'s post-handler decoration returns the customer\'s result untouched and logs a warning.', + 'ac-02-28' => 'A failure in AgentCat\'s pre-handler setup runs the original handler unstripped, with no event, and logs it.', + 'ac-02-29' => 'After a failed call, the next call on the same instance succeeds normally.', + 'ac-02-30' => 'A call without session_id, or with start, gets the issuance block first, and the event has the minted session.', + 'ac-02-31' => 'An unrecognized session_id gets the unrecognized block first, an empty event session and the tag invalid.', + 'ac-02-32' => 'A valid supplied session, a foreign tool or hook mode adds no text block.', + 'ac-02-33' => 'An isError result still gets the block prepended.', + 'ac-02-34' => 'A result whose content is not a list gets nothing prepended.', + 'ac-02-35' => 'An input_required round passes unchanged, tagged agentcat_mrtr=input_required; a continuation is tagged too.', + 'ac-02-36' => 'A tool with an outputSchema gets an optional mcp_session, first in structuredContent; the event keeps the original.', + 'ac-02-37' => 'The mcp_session mirror holds session, agent and status per state, and is skipped for a customer key or composition.', + 'ac-02-38' => 'Neither event.response nor event.error contains the text block or mcp_session.', + 'ac-02-39' => 'With defaults, the get_more_tools descriptor is exact, plus session_id (and agent_id with agent tracking).', + 'ac-02-40' => 'A get_more_tools call runs no customer code, gets the canned answer, and gives one event with the context as intent.', + 'ac-02-41' => 'With report-missing off, get_more_tools is not listed, and a call of that name goes to the customer or the library.', + 'ac-02-42' => 'With tracing off, get_more_tools is still listed and answered, with no block and no event.', + 'ac-02-43' => 'An HTTP request\'s lowercased headers, method and URL are recorded as strings in the event\'s parameters.', + 'ac-02-44' => 'Client name and version come from the envelope, then _meta, then the handshake; a non-string field becomes null.', + 'ac-02-45' => 'A protocolVersion claim in the envelope or _meta is tagged agentcat_protocol_version; a negotiated version is not.', + 'ac-02-46' => 'A transport session ID never becomes the event\'s session; at most it appears in parameters.extra.', + 'ac-02-47' => 'The event carries the server\'s name and version.', + 'ac-02-48' => 'A sleeping hook does not delay the tool response; under PHP-FPM the response is flushed first.', + 'ac-02-49' => 'identify\'s result is stamped on the event of that call, and no separate identify event exists.', + 'ac-02-50' => 'A throwing hook leaves the call working and the event published; in hook mode the session is minted silently.', + 'ac-02-51' => 'In an async runtime, three interleaved calls each carry only their own session, agent and actor.', + 'ac-02-52' => 'With tracing off, no handle parameter is listed, no event is published and no block is added.', + 'ac-02-53' => 'A real-HTTP matrix of a long-lived and a per-request server passes the end-to-end scenarios of each lane.', + 'ac-02-54' => 'SSE-streamed responses pass the baseline-defaults scenario.', + 'ac-02-55' => 'The cross-SDK health harness receives a PHP server\'s successful and errored tool-call events, sentinel included.', + + // The event pipeline: queue, delivery, shutdown, IDs, wire body and logging. + 'ac-03-1' => 'A tool call sends one JSON POST to /v1/public/events/, and the client gets the result before that POST completes.', + 'ac-03-2' => 'With 10 000 events waiting, a new event drops the oldest with `Event queue full, dropping oldest event`.', + 'ac-03-3' => 'A draining queue has at most 5 events in the pipeline at once.', + 'ac-03-4' => 'An API that always fails gets 4 attempts, 1 s, 2 s and 4 s apart, with the same id, then a failure line.', + 'ac-03-5' => 'An API that accepts the second attempt gets 2 attempts and a `Successfully sent event` line with no payload.', + 'ac-03-6' => 'With a failing API, each custom exporter receives the event exactly once.', + 'ac-03-7' => 'Without a projectId, each exporter receives the event once, and nothing is sent to AgentCat.', + 'ac-03-8' => 'Without a projectId and exporters, track() logs the no-projectId warning.', + 'ac-03-9' => 'A shutdown drain waits at most 5 s, and an event added after it starts is dropped with a log line.', + 'ac-03-10' => 'SIGTERM without a customer handler drains the queue in 5 s at most, then ends the process by the default action.', + 'ac-03-11' => 'SIGTERM with a customer handler drains, then calls that handler, and does not end the process itself.', + 'ac-03-12' => 'The shutdown function delivers the pending events within the drain budget after a normal script end.', + 'ac-03-13' => 'A background-task registrar is not used for telemetry-only events; when it throws, the event is still delivered.', + 'ac-03-14' => 'The tool result does not wait for the identify, tags, properties and session hooks; the event has their results.', + 'ac-03-15' => 'A hook that never settles is left out after 30 s, with `Warning: