diff --git a/CHANGELOG.md b/CHANGELOG.md index ad71790..d935101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `AgentCat::track()` takes the `Mcp\Server` built from a Builder, and a Builder after `build()` or `buildStateless()`, so `track()` can come before or after them. + A server tracked after `build()` gives no event for a call to an unknown tool, a call whose arguments fail the tool's schema, or a call that a `tools/call` handler of your own answers; with a reference handler of your own (`setReferenceHandler()`), track the Builder before `build()`. + +### Changed + +- AgentCat's request handlers come before every handler of the Builder, wherever `track()` is in your code. + A `tools/call` or `tools/list` handler that you add with `addRequestHandler()` before `track()`, or in a compiler pass of your own in Symfony, is now tracked: its listing gets AgentCat's parameters and `get_more_tools`, and each call gives an event. +- With `setReferenceHandler()` after `track()`, the error event of a failing tool now carries the tool's exception. + ## [1.0.0] - 2026-10-22 ### Added diff --git a/README.md b/README.md index 2726265..1a921b6 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Once you have your project ID, set up AgentCat for your MCP library below; the [ ### Plain `mcp/sdk` -Install AgentCat next to `mcp/sdk` (`composer require agentcat/sdk mcp/sdk`), then track the `Mcp\Server\Builder` with one call, before `build()`: +Install AgentCat next to `mcp/sdk` (`composer require agentcat/sdk mcp/sdk`), then track the server with one call. Pass the `Mcp\Server\Builder` or the `Mcp\Server` built from it, before or after your tools: ```php trackedServer($server) instanceof TrackedServer; if (!$library instanceof Library) { // The Server that a tracked Builder built: it is tracked through its Builder, which holds the handlers. $library = Libraries::trackerOf($server); diff --git a/src/McpSdk/Internal/Access/BuilderAccess.php b/src/McpSdk/Internal/Access/BuilderAccess.php index 9ec83f3..34c4a19 100644 --- a/src/McpSdk/Internal/Access/BuilderAccess.php +++ b/src/McpSdk/Internal/Access/BuilderAccess.php @@ -9,18 +9,48 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Schema\Implementation; use Mcp\Server\Builder; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; /** - * Every private member of mcp/sdk that AgentCat reads (D21). Reads only: AgentCat changes the Builder through its - * public setters. Each closure is bound to Builder, so PHPStan checks the member in Builder's scope. The accessors - * are `@phpstan-impure`: they return the Builder's current state, which its setters and build() change. + * Every private member of the mcp/sdk Builder that AgentCat touches (D21). AgentCat writes one: the list of added + * request handlers, to put its own first. Each closure is bound to Builder, so PHPStan checks the member in Builder's + * scope. The accessors are `@phpstan-impure`: they return the Builder's current state, which its setters and build() + * change. * * @internal */ final class BuilderAccess { + /** + * @return array the request handlers added with addRequestHandler() and addRequestHandlers(), in order + * + * @phpstan-impure + */ + #[TouchesPrivateMember(class: Builder::class, member: 'requestHandlers', type: 'array')] + private static function addedRequestHandlers(Builder $builder): array + { + $read = \Closure::bind(static fn(Builder $b): array => $b->requestHandlers, null, Builder::class); + + return $read($builder); + } + + /** + * Puts `$handlers` before every request handler added so far. A handler added later comes after them, as + * addRequestHandler() appends, so AgentCat's handlers come first wherever track() runs. + * + * @param list> $handlers + */ + #[TouchesPrivateMember(class: Builder::class, member: 'requestHandlers', type: 'array')] + public static function prependRequestHandlers(Builder $builder, array $handlers): void + { + $write = \Closure::bind(static function (Builder $b) use ($handlers): void { + $b->requestHandlers = [...$handlers, ...$b->requestHandlers]; + }, null, Builder::class); + $write($builder); + } + /** * @return array|null the handlers build() assembled, AgentCat's included; the entries are not checked here * @@ -34,6 +64,19 @@ public static function requestHandlers(Builder $builder): ?array return $read($builder); } + /** + * @return mixed the registry that build() assembled and gave the server, or null before build() + * + * @phpstan-impure + */ + #[TouchesPrivateMember(class: Builder::class, member: 'parts', type: '?array')] + public static function registry(Builder $builder): mixed + { + $read = \Closure::bind(static fn(Builder $b): mixed => $b->parts === null ? null : $b->parts['registry'], null, Builder::class); + + return $read($builder); + } + /** @phpstan-impure */ #[TouchesPrivateMember(class: Builder::class, member: 'parts', type: '?array')] public static function serverInfo(Builder $builder): ?Implementation @@ -86,6 +129,7 @@ public static function logger(Builder $builder): ?LoggerInterface public static function probe(Builder $builder): void { StrictErrors::run(static function () use ($builder): void { + self::addedRequestHandlers($builder); self::requestHandlers($builder); self::serverInfo($builder); self::referenceHandler($builder); diff --git a/src/McpSdk/Internal/Access/CallToolHandlerAccess.php b/src/McpSdk/Internal/Access/CallToolHandlerAccess.php new file mode 100644 index 0000000..42614ec --- /dev/null +++ b/src/McpSdk/Internal/Access/CallToolHandlerAccess.php @@ -0,0 +1,65 @@ + $h->registry, null, CallToolHandler::class); + + return $read($handler); + } + + #[TouchesPrivateMember(class: CallToolHandler::class, member: 'referenceHandler', type: ReferenceHandlerInterface::class, readonly: true)] + public static function referenceHandler(CallToolHandler $handler): ReferenceHandlerInterface + { + $read = \Closure::bind(static fn(CallToolHandler $h): ReferenceHandlerInterface => $h->referenceHandler, null, CallToolHandler::class); + + return $read($handler); + } + + #[TouchesPrivateMember(class: CallToolHandler::class, member: 'logger', type: LoggerInterface::class, readonly: true)] + public static function logger(CallToolHandler $handler): LoggerInterface + { + $read = \Closure::bind(static fn(CallToolHandler $h): LoggerInterface => $h->logger, null, CallToolHandler::class); + + return $read($handler); + } + + /** @phpstan-impure */ + #[TouchesPrivateMember(class: CallToolHandler::class, member: 'schemaValidator', type: SchemaValidator::class)] + public static function schemaValidator(CallToolHandler $handler): SchemaValidator + { + $read = \Closure::bind(static fn(CallToolHandler $h): SchemaValidator => $h->schemaValidator, null, CallToolHandler::class); + + return $read($handler); + } + + #[TouchesPrivateMember(class: CallToolHandler::class, member: 'schemaValidator', type: SchemaValidator::class)] + public static function setSchemaValidator(CallToolHandler $handler, SchemaValidator $validator): void + { + $write = \Closure::bind(static function (CallToolHandler $h) use ($validator): void { + $h->schemaValidator = $validator; + }, null, CallToolHandler::class); + $write($handler); + } +} diff --git a/src/McpSdk/Internal/Access/RegistryAccess.php b/src/McpSdk/Internal/Access/RegistryAccess.php new file mode 100644 index 0000000..a6c52e3 --- /dev/null +++ b/src/McpSdk/Internal/Access/RegistryAccess.php @@ -0,0 +1,43 @@ + the tools by name, in the order they were registered; the entries are not checked here + * + * @phpstan-impure + */ + #[TouchesPrivateMember(class: Registry::class, member: 'tools', type: 'array')] + public static function tools(Registry $registry): array + { + $read = \Closure::bind(static fn(Registry $r): array => $r->tools, null, Registry::class); + + return $read($registry); + } + + /** @param array $tools */ + #[TouchesPrivateMember(class: Registry::class, member: 'tools', type: 'array')] + public static function setTools(Registry $registry, array $tools): void + { + $write = \Closure::bind(static function (Registry $r) use ($tools): void { + $r->tools = $tools; + }, null, Registry::class); + $write($registry); + } +} diff --git a/src/McpSdk/Internal/BuiltServer.php b/src/McpSdk/Internal/BuiltServer.php new file mode 100644 index 0000000..461e125 --- /dev/null +++ b/src/McpSdk/Internal/BuiltServer.php @@ -0,0 +1,200 @@ +|null the registries of the servers tracked after build() */ + private static ?\WeakMap $tracked = null; + + /** True for a Builder that build() assembled and for a built server: the objects this class tracks. */ + public static function applies(object $server): bool + { + return $server instanceof Server || ($server instanceof Builder && BuilderAccess::isBuilt($server)); + } + + /** + * The registry and the library's `tools/call` handler of a built server, read through its private members. + * + * @return array{Registry, CallToolHandler} + * + * @throws \UnexpectedValueException when the server holds something that AgentCat cannot track + */ + public static function parts(Builder|Server $server): array + { + [$library, $registry] = self::find($server); + if (!$library instanceof CallToolHandler) { + throw new \UnexpectedValueException('the mcp/sdk server has no tools/call handler of its own.'); + } + if (!$registry instanceof Registry) { + throw new \UnexpectedValueException(sprintf('the mcp/sdk server has a registry of its own (%s).', get_debug_type($registry))); + } + $reference = CallToolHandlerAccess::referenceHandler($library); + if (!$reference instanceof ReferenceHandler) { + // AgentCat's tools run inside the reference handler: one of the customer's could run each tool twice, or + // pass AgentCat's handler no request. Before build(), AgentCat runs it once, around the tool. + throw new \UnexpectedValueException(sprintf('the mcp/sdk server has a reference handler of its own (%s): track the Builder before build().', get_debug_type($reference))); + } + + return [$registry, $library]; + } + + /** The name and version of a built server, from its first `initialize` handler. */ + public static function serverInfo(Server $server): ?Implementation + { + foreach (ServerAccess::requestHandlers(ServerAccess::protocol($server)) as $handler) { + if ($handler instanceof InitializeHandler) { + return $handler->configuration?->serverInfo; + } + } + + return null; + } + + /** The tracking state of a Builder or server tracked after build(): its registry holds it. */ + public static function trackedServer(Builder|Server $server): ?TrackedServer + { + [, $registry] = self::find($server); + + return $registry instanceof Registry ? self::$tracked[$registry] ?? null : null; + } + + /** + * Every read and every object comes before the first change, so a failure leaves the server untouched. + * + * @throws \Throwable when a member cannot be read or a tool cannot be advertised + */ + public static function install(Registry $registry, CallToolHandler $library, TrackedServer $server, Publisher $publisher, CallInterceptor $interceptor): void + { + $registry->load(); + $tools = []; + $trees = []; + foreach (RegistryAccess::tools($registry) as $name => $reference) { + if (!is_string($name) || !$reference instanceof ToolReference) { + throw new \UnexpectedValueException(sprintf('the mcp/sdk registry holds a tool AgentCat cannot read (%s).', get_debug_type($reference))); + } + $tools[$name] = $reference; + $trees[] = JsonTree::of($reference->tool); + } + $validator = CallToolHandlerAccess::schemaValidator($library); + $original = new Registry(); + RegistryAccess::setTools($original, $tools); + $stash = new ThrowableStash(); + $handler = (new RegistryCall( + $server, + $interceptor, + $publisher, + // The tools as registered, run as the library would; the library's handler logs their failures. + new CallToolHandler($original, new CapturingReferenceHandler(CallToolHandlerAccess::referenceHandler($library), $stash), new NullLogger(), $validator), + new ListToolsHandler($original), + $stash, + new RequestReader($server->httpContext), + ))->handler(); + $advertised = []; + foreach (ToolListing::apply($server, $trees, true) ?? [] as $tree) { + $tool = self::tool($tree); + $advertised[$tool->name] = $tool; + } + + // Each tool keeps its place; one that a degraded injection did not advertise keeps its own schema. + $replaced = []; + $schemas = []; + foreach ($tools as $name => $reference) { + $tool = $advertised[$name] ?? $reference->tool; + $replaced[$name] = new ToolReference($tool, $handler); + $answered = $name === GetMoreTools::NAME && $server->options->enableReportMissing; // AgentCat answers it + $schemas[] = [$tool->inputSchema, $answered ? null : $reference->tool->inputSchema, array_diff(self::properties($tool), self::properties($reference->tool))]; + } + foreach (array_diff_key($advertised, $tools) as $name => $tool) { // get_more_tools + $replaced[$name] = new ToolReference($tool, $handler); + $schemas[] = [$tool->inputSchema, null, []]; + } + + RegistryAccess::setTools($registry, $replaced); + CallToolHandlerAccess::setSchemaValidator($library, new InjectedSchemaValidator(CallToolHandlerAccess::logger($library), $schemas)); + self::$tracked ??= new \WeakMap(); + self::$tracked[$registry] = $server; + } + + /** + * The library's `tools/call` handler, the first in the server's request handlers, and the registry. + * + * @return array{?CallToolHandler, mixed} + */ + private static function find(Builder|Server $server): array + { + foreach ($server instanceof Server ? ServerAccess::requestHandlers(ServerAccess::protocol($server)) : BuilderAccess::requestHandlers($server) ?? [] as $handler) { + if ($handler instanceof CallToolHandler) { + return [$handler, $server instanceof Server ? CallToolHandlerAccess::registry($handler) : BuilderAccess::registry($server)]; + } + } + + return [null, null]; + } + + /** + * The names of the tool's parameters, read from its schema as the wire carries it: mcp/sdk's types require + * `properties`, but a registered schema can leave it out. + * + * @return list + */ + private static function properties(Tool $tool): array + { + $schema = JsonTree::of($tool->inputSchema); + $properties = $schema instanceof \stdClass ? ($schema->properties ?? null) : null; + + return $properties instanceof \stdClass ? array_keys(get_object_vars($properties)) : []; + } + + /** The tool that AgentCat advertised, read as Tool::fromArray() reads a JSON tool (empty sub-schemas stay `{}`). */ + private static function tool(mixed $tree): Tool + { + $data = json_decode(json_encode($tree, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new \UnexpectedValueException('AgentCat advertised a tool that is not a JSON object.'); + } + /** @phpstan-var ToolData $data Tool::fromArray() checks it */ + return Tool::fromArray($data); + } +} diff --git a/src/McpSdk/Internal/CallToolInterceptor.php b/src/McpSdk/Internal/CallToolInterceptor.php index 1b51bc9..2e96157 100644 --- a/src/McpSdk/Internal/CallToolInterceptor.php +++ b/src/McpSdk/Internal/CallToolInterceptor.php @@ -54,7 +54,7 @@ public function supports(Request $request): bool public function handle(Request $request, SessionInterface $session): Response|Error { $this->publisher->pump(); // stdio servers deliver between requests - $delegate = $this->delegates->after($this, $request); + $delegate = $this->delegates->runner($this, $request, $this->stash); if (!$request instanceof CallToolRequest || !$delegate instanceof RequestHandlerInterface) { return Delegates::notFound($request); } diff --git a/src/McpSdk/Internal/CapturingReferenceHandler.php b/src/McpSdk/Internal/CapturingReferenceHandler.php index 2891d0e..c627189 100644 --- a/src/McpSdk/Internal/CapturingReferenceHandler.php +++ b/src/McpSdk/Internal/CapturingReferenceHandler.php @@ -10,31 +10,25 @@ use Mcp\Capability\Registry\ToolReference; /** - * The inner tap: runs every tool, prompt and resource through the reference handler the Builder would use, and stashes - * a tool's exception for the request it ran for before mcp/sdk catches it. It changes nothing else: the arguments and - * the result pass unchanged, and the exception is rethrown. + * The inner tap: runs a tool through the reference handler of the library's `tools/call` handler, and stashes the + * tool's exception for the request it ran for before mcp/sdk catches it. It changes nothing else: the arguments and the + * result pass unchanged, and the exception is rethrown. * * @internal */ -final class CapturingReferenceHandler implements ReferenceHandlerInterface +final readonly class CapturingReferenceHandler implements ReferenceHandlerInterface { - private ?ReferenceHandlerInterface $resolved = null; - - /** - * @param \Closure(): ReferenceHandlerInterface $inner the handler to run, resolved once at the first call - */ public function __construct( - private readonly \Closure $inner, - private readonly ThrowableStash $stash, + private ReferenceHandlerInterface $inner, + private ThrowableStash $stash, ) {} /** @param array $arguments */ #[\Override] public function handle(ElementReference $reference, array $arguments): mixed { - $inner = $this->resolved ??= ($this->inner)(); try { - return $inner->handle($reference, $arguments); + return $this->inner->handle($reference, $arguments); } catch (\Throwable $e) { $request = $arguments['_request'] ?? null; // CallToolHandler passes the request it was given: AgentCat's if ($reference instanceof ToolReference && is_object($request)) { diff --git a/src/McpSdk/Internal/Delegates.php b/src/McpSdk/Internal/Delegates.php index d130dad..2def215 100644 --- a/src/McpSdk/Internal/Delegates.php +++ b/src/McpSdk/Internal/Delegates.php @@ -4,17 +4,20 @@ namespace AgentCat\McpSdk\Internal; +use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Support\StrictErrors; use AgentCat\McpSdk\Internal\Access\BuilderAccess; +use AgentCat\McpSdk\Internal\Access\CallToolHandlerAccess; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Server\Builder; +use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\RequestHandlerInterface; /** - * The handlers that answer after AgentCat's: the library's own, or a customer's added after track(). They come - * from the request-handler list build() assembled, in which AgentCat's handlers come first. The list is read at the - * first request, when the server is built, and kept. One instance serves every AgentCat handler of a Builder. + * The handlers that answer after AgentCat's: the library's own, or a customer's, added before or after track(). They + * come from the request-handler list build() assembled, in which AgentCat's handlers come first. The list is read at + * the first request, when the server is built, and kept. One instance serves every AgentCat handler of a Builder. * * @internal */ @@ -23,7 +26,45 @@ final class Delegates /** @var list>|null */ private ?array $handlers = null; - public function __construct(private readonly Builder $builder) {} + /** @var \WeakMap> the library's tools/call handler => the one that runs the tools */ + private \WeakMap $runners; + + public function __construct(private readonly Builder $builder) + { + $this->runners = new \WeakMap(); + } + + /** + * The handler that runs a `tools/call` after `$self`: `after()`'s, except that the library's own handler is + * replaced, once, by a copy whose reference handler stashes a failing tool's exception. The copy runs the tool + * through the reference handler that build() gave the library's handler, so a reference handler or a container + * set before or after track() is the one that runs. When the copy cannot be made (`Drift`), the library's own + * handler runs the tools, and the events have no exception. + * + * @return RequestHandlerInterface|null + */ + public function runner(object $self, Request $request, ThrowableStash $stash): ?RequestHandlerInterface + { + $handler = $this->after($self, $request); + if (!$handler instanceof CallToolHandler) { + return $handler; // a customer's handler, or none + } + if (!isset($this->runners[$handler])) { + try { + $this->runners[$handler] = StrictErrors::run(static fn(): CallToolHandler => new CallToolHandler( + CallToolHandlerAccess::registry($handler), + new CapturingReferenceHandler(CallToolHandlerAccess::referenceHandler($handler), $stash), + CallToolHandlerAccess::logger($handler), + CallToolHandlerAccess::schemaValidator($handler), + )); + } catch (\Throwable) { + Drift::report($this->builder); + $this->runners[$handler] = $handler; + } + } + + return $this->runners[$handler]; + } /** * The first handler after `$self` that supports the request. mcp/sdk always has one of its own, so null means @@ -39,7 +80,7 @@ public function after(object $self, Request $request): ?RequestHandlerInterface if ($handler === $self) { $seen = true; } elseif ($seen && $handler->supports($request)) { - return $handler; // the library's own handler, or a customer handler added after track() + return $handler; // the library's own handler, or a customer's } } Drift::report($this->builder); diff --git a/src/McpSdk/Internal/InjectedSchemaValidator.php b/src/McpSdk/Internal/InjectedSchemaValidator.php new file mode 100644 index 0000000..1a54591 --- /dev/null +++ b/src/McpSdk/Internal/InjectedSchemaValidator.php @@ -0,0 +1,62 @@ +, array|null, array}> $injected each injected schema, + * the tool's own schema (null: AgentCat answers the tool) and the names of the arguments AgentCat injected + */ + public function __construct( + LoggerInterface $logger, + private readonly array $injected, + ) { + parent::__construct(logger: $logger); // named: mcp/sdk 0.9 puts a validator first + } + + /** + * @param array|object $schema + * + * @return list + */ + #[\Override] + public function validateAgainstJsonSchema(mixed $data, array|object $schema): array + { + foreach ($this->injected as [$injected, $original, $names]) { + if ($schema !== $injected) { + continue; + } + if ($original === null) { + return []; + } + if (is_array($data)) { + foreach ($names as $name) { + unset($data[$name]); + } + // The library's handler passes no arguments as `{}`, which a JSON Schema of type object accepts. + $data = $data === [] ? new \stdClass() : $data; + } + + return parent::validateAgainstJsonSchema($data, $original); + } + + return parent::validateAgainstJsonSchema($data, $schema); + } +} diff --git a/src/McpSdk/Internal/McpCallSeam.php b/src/McpSdk/Internal/McpCallSeam.php index 74a81dc..26ceb8f 100644 --- a/src/McpSdk/Internal/McpCallSeam.php +++ b/src/McpSdk/Internal/McpCallSeam.php @@ -29,6 +29,9 @@ final class McpCallSeam implements CallSeam /** The request sent to the library by `callStripped()`: the key of the stashed tool exception. */ private ?CallToolRequest $sent = null; + /** The tool's exception that `observe()` took from the stash. */ + private ?\Throwable $thrown = null; + /** * @param RequestHandlerInterface $delegate the handler that answers after AgentCat's * @param \Closure(): (RequestHandlerInterface|null) $listDelegate the handler that lists after AgentCat's @@ -89,11 +92,17 @@ public function callStripped(array $names): Response|Error return $this->delegate->handle($this->sent, $this->session); } + /** The exception of the tool, once `observe()` ran: null when the tool did not throw. */ + public function thrown(): ?\Throwable + { + return $this->thrown; + } + /** @param Response|Error $result */ #[\Override] public function observe(mixed $result): Observed { - $thrown = $this->stash->take($this->sent); + $thrown = $this->thrown = $this->stash->take($this->sent); return $result instanceof Error ? new Observed(protocolError: $result->message, thrown: $thrown) diff --git a/src/McpSdk/Internal/RegistryCall.php b/src/McpSdk/Internal/RegistryCall.php new file mode 100644 index 0000000..dff1249 --- /dev/null +++ b/src/McpSdk/Internal/RegistryCall.php @@ -0,0 +1,70 @@ + $call->run($context->getRequest(), $context->getSession()); + } + + /** @throws \Throwable the tool's exception, or an `\UnexpectedValueException` for a request that is not a `tools/call` */ + private function run(Request $request, SessionInterface $session): mixed + { + $this->publisher->pump(); // stdio servers deliver between requests + if (!$request instanceof CallToolRequest) { + throw new \UnexpectedValueException(sprintf('AgentCat SDK compatibility error: mcp/sdk ran a tool for a %s request.', $request::getMethod())); + } + $lister = $this->lister; + $seam = new McpCallSeam($request, $session, $this->runner, static fn(): ListToolsHandler => $lister, $this->stash, $this->reader); + $answer = $this->interceptor->intercept($this->server, $seam); + if ($answer instanceof Response) { + return $answer->result; + } + + throw $seam->thrown() ?? new \RuntimeException($answer->message); + } +} diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php index 16a1381..7ded6d8 100644 --- a/src/McpSdk/McpSdkAdapter.php +++ b/src/McpSdk/McpSdkAdapter.php @@ -9,43 +9,39 @@ use AgentCat\Internal\Engine\ThrowableStash; use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Engine\TrackedServers; -use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Support\StrictErrors; use AgentCat\McpSdk\Internal\Access\BuilderAccess; use AgentCat\McpSdk\Internal\Access\ServerAccess; +use AgentCat\McpSdk\Internal\BuiltServer; use AgentCat\McpSdk\Internal\CallToolInterceptor; -use AgentCat\McpSdk\Internal\CapturingReferenceHandler; use AgentCat\McpSdk\Internal\Delegates; use AgentCat\McpSdk\Internal\Drift; use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\Internal\RequestReader; -use Mcp\Capability\Registry\Container; -use Mcp\Capability\Registry\ReferenceHandler; -use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Schema\Implementation; use Mcp\Server; use Mcp\Server\Builder; -use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; /** - * Detects and instruments mcp/sdk servers. AgentCat changes the Builder through its public setters only. + * Detects and instruments mcp/sdk servers: a Builder before build() through its request handlers, and a Builder after + * build() or a built server through its tool registry (`BuiltServer`). * * @internal */ final class McpSdkAdapter { - public const string SUPPORT_MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; + public const string SUPPORT_MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder or the Mcp\Server built from it to AgentCat::track()).'; /** The line logged, as a warning, when mcp/sdk internals are not what AgentCat expects: AgentCat is off then. */ public const string COMPATIBILITY_LINE = 'AgentCat SDK Compatibility: ' . self::SUPPORT_MATRIX; - /** @phpstan-assert-if-true Builder $server */ + /** @phpstan-assert-if-true Builder|Server $server */ public static function supports(object $server): bool { - return $server instanceof Builder; + return $server instanceof Builder || $server instanceof Server; } /** @@ -79,20 +75,34 @@ public static function version(): string } /** - * Checks that AgentCat can instrument this Builder: its private members have the expected shape, and it is not - * built yet. + * Checks that AgentCat can instrument this Builder or server: its private members have the expected shape, and, + * after build(), its registry and its `tools/call` handler are mcp/sdk's own. * * @throws \UnexpectedValueException with the compatibility message, after logging the compatibility line */ - public static function inspect(Builder $builder): void + public static function inspect(Builder|Server $server): void { try { - BuilderAccess::probe($builder); + $refused = StrictErrors::run(static function () use ($server): ?\UnexpectedValueException { + if ($server instanceof Builder) { + BuilderAccess::probe($server); + } + if (!BuiltServer::applies($server)) { + return null; + } + try { + BuiltServer::parts($server); + } catch (\UnexpectedValueException $e) { + return $e; // a registry, handlers or a reference handler that AgentCat cannot track + } + + return null; + }); } catch (\Throwable $e) { - throw self::incompatible(sprintf('mcp/sdk %s internals changed (%s).', self::version(), ErrorText::of($e)), $e); + throw self::incompatible($server, sprintf('mcp/sdk %s internals changed (%s).', self::version(), ErrorText::of($e)), $e); } - if (BuilderAccess::isBuilt($builder)) { - throw self::incompatible('the mcp/sdk Builder was already built.'); + if ($refused !== null) { + throw self::incompatible($server, $refused->getMessage(), $refused); } } @@ -108,21 +118,21 @@ public static function logger(Builder $builder): ?LoggerInterface /** * Reads the server name and version when an event is published: build() sets them. When they cannot be read - * (`Drift`), they are unknown, and the drift is logged once per Builder. + * (`Drift`), they are unknown, and the drift is logged once per Builder or server. * * @return \Closure(): array{0: ?string, 1: ?string} */ - public static function serverInfo(Builder $builder): \Closure + public static function serverInfo(Builder|Server $server): \Closure { - return static function () use ($builder): array { + return static function () use ($server): array { try { - return StrictErrors::run(static function () use ($builder): array { - $info = BuilderAccess::serverInfo($builder); + return StrictErrors::run(static function () use ($server): array { + $info = $server instanceof Builder ? BuilderAccess::serverInfo($server) : BuiltServer::serverInfo($server); return $info instanceof Implementation ? [$info->name, $info->version] : [null, null]; }); } catch (\Throwable) { - Drift::report($builder); + Drift::report($server); return [null, null]; } @@ -130,81 +140,72 @@ public static function serverInfo(Builder $builder): \Closure } /** - * The tracking state of a server built from a tracked Builder (ac-01-69): AgentCat's `tools/call` handler, among - * the server's request handlers, holds it. A state found is kept for the server. Never throws. + * The tracking state of a server built from a Builder tracked before build() (ac-01-69): AgentCat's `tools/call` + * handler, among the server's request handlers, holds it. For a Builder or a server tracked after build(), its + * registry holds it (`BuiltServer`). A state found for a server is kept for it. Never throws. * - * When the request handlers cannot be read (`Drift`), the compatibility line is logged once per server, and - * `TrackedServers::incomplete()` turns true. + * When the members of a server cannot be read (`Drift`), the compatibility line is logged once per server, and + * `TrackedServers::incomplete()` turns true. For a Builder, `inspect()` reports it. * - * @return TrackedServer|null null when the object is not an mcp/sdk server, its Builder was not tracked, or its - * request handlers cannot be read + * @return TrackedServer|null null when the object is not a built mcp/sdk server or a built Builder, when it was not + * tracked, or when its members cannot be read */ public static function trackedServer(object $server): ?TrackedServer { - if (!$server instanceof Server) { + if (!$server instanceof Server && !$server instanceof Builder) { return null; } try { - $handlers = StrictErrors::run(static fn(): array => ServerAccess::requestHandlers(ServerAccess::protocol($server))); + $tracked = StrictErrors::run(static function () use ($server): ?TrackedServer { + if ($server instanceof Server) { + foreach (ServerAccess::requestHandlers(ServerAccess::protocol($server)) as $handler) { + if ($handler instanceof CallToolInterceptor) { + return $handler->server(); + } + } + } + + return BuiltServer::trackedServer($server); // null for a Builder before build(): TrackedServers knows it + }); } catch (\Throwable) { + if ($server instanceof Builder) { + return null; // track() goes on to inspect(), which reports it with the Builder's logger + } Drift::report($server); TrackedServers::markIncomplete(); return null; } - foreach ($handlers as $handler) { - if ($handler instanceof CallToolInterceptor) { - $tracked = $handler->server(); - TrackedServers::add($server, $tracked); - - return $tracked; - } + if ($tracked instanceof TrackedServer && $server instanceof Server) { + TrackedServers::add($server, $tracked); } - return null; + return $tracked; } /** - * Wraps the reference handler, then registers AgentCat's request handlers, which run before the library's own. - * Every read comes before the first change, so a read that throws leaves the Builder untouched. + * Before build(), puts AgentCat's request handlers before every handler of the Builder, so they answer first + * wherever track() runs: one write, which a member that cannot be read stops. After build(), instruments the + * registry (`BuiltServer`), whose reads all come before its first change. */ - public static function install(Builder $builder, TrackedServer $server, Publisher $publisher, CallInterceptor $interceptor): void + public static function install(Builder|Server $builder, TrackedServer $server, Publisher $publisher, CallInterceptor $interceptor): void { - $hasContainer = BuilderAccess::container($builder) instanceof ContainerInterface; - $custom = BuilderAccess::referenceHandler($builder); - $delegates = new Delegates($builder); // one per Builder, shared by AgentCat's handlers - $stash = new ThrowableStash(); - if (!$hasContainer) { - // The default build() would create, set here so that AgentCat's handlers and the library's share it. - $builder->setContainer(new Container()); - } - $inner = $custom instanceof ReferenceHandlerInterface - ? static fn(): ReferenceHandlerInterface => $custom - : static fn(): ReferenceHandlerInterface => new ReferenceHandler(self::container($builder)); - $builder->setReferenceHandler(new CapturingReferenceHandler($inner, $stash)); - $list = new ListToolsInterceptor($server, $delegates, $publisher); - $builder->addRequestHandler($list); - $builder->addRequestHandler(new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, $stash, new RequestReader($server->httpContext))); - } + if ($builder instanceof Server || BuiltServer::applies($builder)) { + [$registry, $library] = BuiltServer::parts($builder); + BuiltServer::install($registry, $library, $server, $publisher, $interceptor); - /** - * The container the Builder holds at the first call, which build() also used. When it cannot be read (`Drift`), - * the drift is logged once per Builder and a fresh default container keeps the tools working. - */ - private static function container(Builder $builder): ?ContainerInterface - { - try { - return StrictErrors::run(static fn(): ?ContainerInterface => BuilderAccess::container($builder)); - } catch (\Throwable) { - Drift::report($builder); - - return new Container(); + return; } + $delegates = new Delegates($builder); // one per Builder, shared by AgentCat's handlers + $list = new ListToolsInterceptor($server, $delegates, $publisher); + $call = new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, new ThrowableStash(), new RequestReader($server->httpContext)); + BuilderAccess::prependRequestHandlers($builder, [$list, $call]); } - private static function incompatible(string $reason, ?\Throwable $previous = null): \UnexpectedValueException + /** Logs the compatibility line once per Builder or server (`Drift` may have logged it already). */ + private static function incompatible(Builder|Server $server, string $reason, ?\Throwable $previous = null): \UnexpectedValueException { - Log::warning(self::COMPATIBILITY_LINE); + Drift::report($server); return new \UnexpectedValueException('AgentCat SDK compatibility error: ' . $reason . ' ' . self::SUPPORT_MATRIX, previous: $previous); } diff --git a/src/McpSdk/McpSdkLibrary.php b/src/McpSdk/McpSdkLibrary.php index b89a283..1b2048c 100644 --- a/src/McpSdk/McpSdkLibrary.php +++ b/src/McpSdk/McpSdkLibrary.php @@ -61,19 +61,19 @@ public function logger(object $server): ?LoggerInterface #[\Override] public function inspect(object $server): void { - McpSdkAdapter::inspect($this->builder($server)); + McpSdkAdapter::inspect($this->mcp($server)); } #[\Override] public function serverInfo(object $server): \Closure { - return McpSdkAdapter::serverInfo($this->builder($server)); + return McpSdkAdapter::serverInfo($this->mcp($server)); } #[\Override] public function install(object $server, TrackedServer $state, Publisher $publisher, CallInterceptor $interceptor): void { - McpSdkAdapter::install($this->builder($server), $state, $publisher, $interceptor); + McpSdkAdapter::install($this->mcp($server), $state, $publisher, $interceptor); } #[\Override] @@ -83,8 +83,8 @@ public function trackedServer(object $server): ?TrackedServer } /** `track()` asks `supports()` first, so this never throws there. */ - private function builder(object $server): Builder + private function mcp(object $server): Builder|Server { - return $server instanceof Builder ? $server : throw new \UnexpectedValueException('Not an mcp/sdk Builder: ' . get_debug_type($server)); + return $server instanceof Builder || $server instanceof Server ? $server : throw new \UnexpectedValueException('Not an mcp/sdk Builder or server: ' . get_debug_type($server)); } } diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php index b152414..e841eef 100644 --- a/tests/Contract/ManifestTest.php +++ b/tests/Contract/ManifestTest.php @@ -23,6 +23,8 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void 'Laravel\Mcp\Server\Methods\CallTool::handle(Laravel\Mcp\Transport\JsonRpcRequest $request, Laravel\Mcp\Server\ServerContext $context): Generator|Laravel\Mcp\Transport\JsonRpcResponse', 'Laravel\Mcp\Server\ToolInvoker::toErrorResponse(Throwable $e): Laravel\Mcp\Response', 'Laravel\Mcp\Server\Transport\HttpTransport::$request', + 'Mcp\Capability\Discovery\SchemaValidator::validateAgainstJsonSchema(mixed $data, object|array $schema): array', + 'Mcp\Capability\Registry::$tools', 'Mcp\Schema\Result\CallToolResult::__construct(array $content, bool $isError = false, mixed $structuredContent = null, ?array $meta = null)', 'Mcp\Schema\Result\ListToolsResult::__construct(array $tools, ?string $nextCursor = null)', 'Mcp\Server::$protocol', @@ -30,6 +32,11 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void 'Mcp\Server\Builder::$logger', 'Mcp\Server\Builder::$parts', 'Mcp\Server\Builder::$referenceHandler', + 'Mcp\Server\Builder::$requestHandlers', + 'Mcp\Server\Handler\Request\CallToolHandler::$logger', + 'Mcp\Server\Handler\Request\CallToolHandler::$referenceHandler', + 'Mcp\Server\Handler\Request\CallToolHandler::$registry', + 'Mcp\Server\Handler\Request\CallToolHandler::$schemaValidator', 'Mcp\Server\Protocol::$requestHandlers', 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', 'symfony/mcp-bundle: builder without a configurator', diff --git a/tests/Contract/McpSdk/ExtendedClassesTest.php b/tests/Contract/McpSdk/ExtendedClassesTest.php index 9ebacb8..c484dd6 100644 --- a/tests/Contract/McpSdk/ExtendedClassesTest.php +++ b/tests/Contract/McpSdk/ExtendedClassesTest.php @@ -5,34 +5,54 @@ namespace AgentCat\Tests\Contract\McpSdk; use AgentCat\Tests\Contract\PrivateMemberManifest; +use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ListToolsResult; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; /** * AgentCat answers `tools/list` and `tools/call` with subclasses of the library's result classes, so that a * customer's `ResponseEvent` listeners keep seeing the library's types. A future mcp/sdk that makes a parent final, - * readonly or its `jsonSerialize()` final, or that changes its constructor, breaks them. + * readonly or its `jsonSerialize()` final, or that changes its constructor, breaks them. For a server tracked after + * build(), AgentCat's `InjectedSchemaValidator` extends the library's schema validator and overrides its one method. */ #[Group('contract')] final class ExtendedClassesTest extends TestCase { public function testTheManifestListsEveryLibraryClassAgentCatExtends(): void { - $this->assertSame([CallToolResult::class, ListToolsResult::class], self::mcpClasses(PrivateMemberManifest::extendedClasses())); - $this->assertSame([CallToolResult::class, ListToolsResult::class], self::mcpClasses(array_keys(PrivateMemberManifest::EXTENDED))); + $this->assertSame([SchemaValidator::class, CallToolResult::class, ListToolsResult::class], $this->mcpClasses(PrivateMemberManifest::extendedClasses())); + $this->assertSame([SchemaValidator::class, CallToolResult::class, ListToolsResult::class], $this->mcpClasses(array_keys(PrivateMemberManifest::EXTENDED))); } - /** @return \Iterator one row per extended mcp/sdk class */ + /** @return \Iterator one row per extended mcp/sdk result class */ public static function extendedClasses(): \Iterator { - foreach (self::mcpClasses(array_keys(PrivateMemberManifest::EXTENDED)) as $class) { + foreach ([CallToolResult::class, ListToolsResult::class] as $class) { yield $class => [$class]; } } + public function testTheSchemaValidatorStillHasTheMethodAgentCatOverrides(): void + { + $parent = new \ReflectionClass(SchemaValidator::class); + $method = $parent->getMethod('validateAgainstJsonSchema'); + $constructor = $parent->getConstructor(); + + $this->assertFalse($parent->isFinal(), 'SchemaValidator is final'); + $this->assertFalse($method->isFinal(), 'validateAgainstJsonSchema() is final'); + $this->assertTrue($method->isPublic()); + $this->assertSame(PrivateMemberManifest::EXTENDED[SchemaValidator::class], $this->signature($method) . ': ' . $method->getReturnType()); + $this->assertInstanceOf(\ReflectionMethod::class, $constructor); + $this->assertSame(0, $constructor->getNumberOfRequiredParameters(), 'InjectedSchemaValidator passes the logger only'); + $logger = array_values(array_filter($constructor->getParameters(), static fn(\ReflectionParameter $parameter): bool => $parameter->getName() === 'logger')); + $this->assertCount(1, $logger, 'InjectedSchemaValidator passes it as the named argument logger'); + $this->assertSame(LoggerInterface::class, (string) $logger[0]->getType()); + } + /** @param class-string $class */ #[DataProvider('extendedClasses')] public function testTheParentCanStillBeExtendedWithTheSameConstructor(string $class): void @@ -60,7 +80,7 @@ public function testTheParentCanStillBeExtendedWithTheSameConstructor(string $cl * * @return list */ - private static function mcpClasses(array $classes): array + private function mcpClasses(array $classes): array { return array_values(array_filter($classes, static fn(string $class): bool => str_starts_with($class, 'Mcp\\'))); } diff --git a/tests/Contract/McpSdk/RuntimeDriftTest.php b/tests/Contract/McpSdk/RuntimeDriftTest.php index 4dae94a..f3537bb 100644 --- a/tests/Contract/McpSdk/RuntimeDriftTest.php +++ b/tests/Contract/McpSdk/RuntimeDriftTest.php @@ -17,12 +17,9 @@ use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\Options; -use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\McpSdk\BuilderMembers; use AgentCat\Tests\Support\McpSdk\HttpHarness; -use AgentCat\Tests\Support\McpSdk\NameService; use AgentCat\Tests\Support\McpSdk\RecordingTool; -use AgentCat\Tests\Support\McpSdk\WiredTool; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\Tests\Support\Thrown; @@ -44,12 +41,10 @@ #[Group('contract')] final class RuntimeDriftTest extends TestCase { - private const string COMPATIBILITY = 'AgentCat SDK Compatibility: AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; + private const string COMPATIBILITY = 'AgentCat SDK Compatibility: AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder or the Mcp\Server built from it to AgentCat::track()).'; private MemoryLogger $logger; - private RecordingTransport $transport; - private EventQueue $queue; #[\Override] @@ -57,8 +52,8 @@ protected function setUp(): void { $this->logger = new MemoryLogger(); Log::setLogger($this->logger); - $this->transport = new RecordingTransport(); - $this->queue = new EventQueue(transport: $this->transport); + $transport = new RecordingTransport(); + $this->queue = new EventQueue(transport: $transport); } #[\Override] @@ -235,24 +230,6 @@ public function testACallWithoutAListHandlerAfterAgentCatStripsHeuristically(): $this->assertSame([self::COMPATIBILITY], $this->logger->messages()); } - public function testAnUnreadableContainerFallsBackToTheLibraryDefault(): void - { - $builder = $this->builder()->addTool(WiredTool::class, name: 'wired'); - [, $harness] = $this->tracked($builder); - BuilderMembers::uninitialize('container')($builder); - $harness->initialize(); - $harness->listTools(); - - $first = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); - $second = $harness->callTool('wired', ['name' => 'Ada', 'session_id' => 'start']); - - $this->assertSame('ok', JsonAssert::at(HttpHarness::result($first), 'content', 1, 'text')); - $this->assertSame('wired Ada (' . NameService::class . ')', JsonAssert::at(HttpHarness::result($second), 'content', 1, 'text'), 'autowired like the library default'); - $this->assertTrue($this->queue->flush()); - $this->assertCount(2, $this->transport->deliveries, 'both calls are traced'); - $this->assertSame([self::COMPATIBILITY], $this->logger->messages()); - } - public function testTheHandlerListReadAtTheFirstRequestIsKept(): void { $builder = $this->builder(); diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php index ec05f41..2634b0b 100644 --- a/tests/Contract/PrivateMemberManifest.php +++ b/tests/Contract/PrivateMemberManifest.php @@ -7,6 +7,7 @@ use AgentCat\Internal\Access\TouchesPrivateMember; use Laravel\Mcp\Server\Methods\CallTool; use Laravel\Mcp\Server\ToolInvoker; +use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ListToolsResult; @@ -24,6 +25,7 @@ final class PrivateMemberManifest public const array EXTENDED = [ CallTool::class => 'handle(Laravel\\Mcp\\Transport\\JsonRpcRequest $request, Laravel\\Mcp\\Server\\ServerContext $context): Generator|Laravel\\Mcp\\Transport\\JsonRpcResponse', ToolInvoker::class => 'toErrorResponse(Throwable $e): Laravel\\Mcp\\Response', + SchemaValidator::class => 'validateAgainstJsonSchema(mixed $data, object|array $schema): array', CallToolResult::class => '__construct(array $content, bool $isError = false, mixed $structuredContent = null, ?array $meta = null)', ListToolsResult::class => '__construct(array $tools, ?string $nextCursor = null)', ]; diff --git a/tests/Contract/snapshots/mcp-sdk-0.8.1.json b/tests/Contract/snapshots/mcp-sdk-0.8.1.json index 9d2f35b..116382b 100644 --- a/tests/Contract/snapshots/mcp-sdk-0.8.1.json +++ b/tests/Contract/snapshots/mcp-sdk-0.8.1.json @@ -1,5 +1,795 @@ { "classes": { + "Mcp\\Capability\\Discovery\\SchemaValidator": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "collectSubErrors": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "error", + "type": "Opis\\JsonSchema\\Errors\\ValidationError", + "variadic": false + }, + { + "byReference": true, + "hasDefault": false, + "name": "collectedErrors", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "convertDataForValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "mixed", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "private" + }, + "formatJsonPointerPath": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "pathComponents", + "type": "?array", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "private" + }, + "formatValidationError": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "error", + "type": "Opis\\JsonSchema\\Errors\\ValidationError", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "private" + }, + "getJsonSchemaValidator": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Opis\\JsonSchema\\Validator", + "static": false, + "visibility": "private" + }, + "validateAgainstJsonSchema": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "schema", + "type": "object|array", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "jsonSchemaValidator": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "?Opis\\JsonSchema\\Validator", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Capability\\Registry": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nameValidator", + "type": "Mcp\\Capability\\Tool\\NameValidator", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "loader", + "type": "?Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "calculateNextCursor": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "totalItems", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "currentCursor", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "limit", + "type": "int", + "variadic": false + } + ], + "return": "?string", + "static": false, + "visibility": "private" + }, + "dispatch": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "getPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\PromptReference", + "static": false, + "visibility": "public" + }, + "getPrompts": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "includeTemplates", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceReference|Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "getResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "getResourceTemplates": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getResources": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ToolReference", + "static": false, + "visibility": "public" + }, + "getTools": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "hasPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasPrompts": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResourceTemplates": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResources": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasTools": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "load": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "loadFrom": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "paginateResults": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "items", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "limit", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "registerPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "prompt", + "type": "Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\PromptReference", + "static": false, + "visibility": "public" + }, + "registerResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "resource", + "type": "Mcp\\Schema\\ResourceDefinition", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceReference", + "static": false, + "visibility": "public" + }, + "registerResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "template", + "type": "Mcp\\Schema\\ResourceTemplate", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "registerTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Mcp\\Schema\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ToolReference", + "static": false, + "visibility": "public" + }, + "unregisterPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "eventDispatcher": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "loaded": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loader": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "visibility": "private" + }, + "loading": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "nameValidator": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\Tool\\NameValidator", + "visibility": "private" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + }, + "readonly": false + }, "Mcp\\Schema\\Result\\CallToolResult": { "abstract": false, "abstractMethods": [], @@ -1643,6 +2433,137 @@ }, "readonly": false }, + "Mcp\\Server\\Handler\\Request\\CallToolHandler": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "schemaValidator", + "type": "?Mcp\\Capability\\Discovery\\SchemaValidator", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "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": "public" + }, + "isJsonObject": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "value", + "type": "mixed", + "variadic": false + } + ], + "return": "bool", + "static": true, + "visibility": "private" + }, + "supports": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + } + }, + "properties": { + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "referenceHandler": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "schemaValidator": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\Discovery\\SchemaValidator", + "visibility": "private" + } + }, + "readonly": false + }, "Mcp\\Server\\Protocol": { "abstract": false, "abstractMethods": [], diff --git a/tests/Contract/snapshots/mcp-sdk-0.9.0.json b/tests/Contract/snapshots/mcp-sdk-0.9.0.json index 8976bff..78450f9 100644 --- a/tests/Contract/snapshots/mcp-sdk-0.9.0.json +++ b/tests/Contract/snapshots/mcp-sdk-0.9.0.json @@ -1,5 +1,817 @@ { "classes": { + "Mcp\\Capability\\Discovery\\SchemaValidator": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": false, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "jsonSchemaValidator", + "type": "?Opis\\JsonSchema\\Validator", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "collectSubErrors": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "error", + "type": "Opis\\JsonSchema\\Errors\\ValidationError", + "variadic": false + }, + { + "byReference": true, + "hasDefault": false, + "name": "collectedErrors", + "type": "array", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "convertDataForValidator": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "mixed", + "variadic": false + } + ], + "return": "mixed", + "static": false, + "visibility": "private" + }, + "formatJsonPointerPath": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "pathComponents", + "type": "?array", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "private" + }, + "formatValidationError": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "error", + "type": "Opis\\JsonSchema\\Errors\\ValidationError", + "variadic": false + } + ], + "return": "string", + "static": false, + "visibility": "private" + }, + "getJsonSchemaValidator": { + "abstract": false, + "final": false, + "parameters": [], + "return": "Opis\\JsonSchema\\Validator", + "static": false, + "visibility": "private" + }, + "validateAgainstJsonSchema": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "data", + "type": "mixed", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "schema", + "type": "object|array", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "public" + } + }, + "properties": { + "decodedSchemas": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "jsonSchemaValidator": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "?Opis\\JsonSchema\\Validator", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + } + }, + "readonly": false + }, + "Mcp\\Capability\\Registry": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "eventDispatcher", + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "nameValidator", + "type": "Mcp\\Capability\\Tool\\NameValidator", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "loader", + "type": "?Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "calculateNextCursor": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "totalItems", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "currentCursor", + "type": "?string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "limit", + "type": "int", + "variadic": false + } + ], + "return": "?string", + "static": false, + "visibility": "private" + }, + "dispatch": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "event", + "type": "object", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "private" + }, + "getPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\PromptReference", + "static": false, + "visibility": "public" + }, + "getPrompts": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "includeTemplates", + "type": "bool", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceReference|Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "getResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "getResourceTemplates": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getResources": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "getTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ToolReference", + "static": false, + "visibility": "public" + }, + "getTools": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": true, + "name": "limit", + "type": "?int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "Mcp\\Schema\\Page", + "static": false, + "visibility": "public" + }, + "hasPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasPrompts": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResourceTemplates": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasResources": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "hasTools": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "load": { + "abstract": false, + "final": false, + "parameters": [], + "return": "void", + "static": false, + "visibility": "public" + }, + "loadFrom": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "loader", + "type": "Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "paginateResults": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "items", + "type": "array", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "limit", + "type": "int", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "cursor", + "type": "?string", + "variadic": false + } + ], + "return": "array", + "static": false, + "visibility": "private" + }, + "publishesListChanges": { + "abstract": false, + "final": false, + "parameters": [], + "return": "bool", + "static": false, + "visibility": "public" + }, + "registerPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "prompt", + "type": "Mcp\\Schema\\Prompt", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\PromptReference", + "static": false, + "visibility": "public" + }, + "registerResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "resource", + "type": "Mcp\\Schema\\ResourceDefinition", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceReference", + "static": false, + "visibility": "public" + }, + "registerResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "template", + "type": "Mcp\\Schema\\ResourceTemplate", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "completionProviders", + "type": "array", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ResourceTemplateReference", + "static": false, + "visibility": "public" + }, + "registerTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Mcp\\Schema\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "handler", + "type": "callable|array|string", + "variadic": false + } + ], + "return": "Mcp\\Capability\\Registry\\ToolReference", + "static": false, + "visibility": "public" + }, + "unregisterPrompt": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterResource": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uri", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterResourceTemplate": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "uriTemplate", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + }, + "unregisterTool": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "name", + "type": "string", + "variadic": false + } + ], + "return": "void", + "static": false, + "visibility": "public" + } + }, + "properties": { + "eventDispatcher": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Psr\\EventDispatcher\\EventDispatcherInterface", + "visibility": "private" + }, + "loaded": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "loader": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "?Mcp\\Capability\\Registry\\Loader\\LoaderInterface", + "visibility": "private" + }, + "loading": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "bool", + "visibility": "private" + }, + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "nameValidator": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\Tool\\NameValidator", + "visibility": "private" + }, + "prompts": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resourceTemplates": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "resources": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + }, + "tools": { + "hasDefault": true, + "readonly": false, + "static": false, + "type": "array", + "visibility": "private" + } + }, + "readonly": false + }, "Mcp\\Schema\\Result\\CallToolResult": { "abstract": false, "abstractMethods": [], @@ -1696,6 +2508,176 @@ }, "readonly": false }, + "Mcp\\Server\\Handler\\Request\\CallToolHandler": { + "abstract": false, + "abstractMethods": [], + "constants": [], + "final": true, + "methods": { + "__construct": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "registry", + "type": "Mcp\\Capability\\RegistryInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "referenceHandler", + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "logger", + "type": "Psr\\Log\\LoggerInterface", + "variadic": false + }, + { + "byReference": false, + "hasDefault": true, + "name": "schemaValidator", + "type": "?Mcp\\Capability\\Discovery\\SchemaValidator", + "variadic": false + } + ], + "return": "", + "static": false, + "visibility": "public" + }, + "handle": { + "abstract": false, + "final": false, + "parameters": [ + { + "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": "public" + }, + "isJsonObject": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "value", + "type": "mixed", + "variadic": false + } + ], + "return": "bool", + "static": true, + "visibility": "private" + }, + "summarizeValidationErrors": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "validationErrors", + "type": "array", + "variadic": false + } + ], + "return": "string", + "static": true, + "visibility": "private" + }, + "supports": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "request", + "type": "Mcp\\Schema\\JsonRpc\\Request", + "variadic": false + } + ], + "return": "bool", + "static": false, + "visibility": "public" + }, + "validateStructuredContent": { + "abstract": false, + "final": false, + "parameters": [ + { + "byReference": false, + "hasDefault": false, + "name": "tool", + "type": "Mcp\\Schema\\Tool", + "variadic": false + }, + { + "byReference": false, + "hasDefault": false, + "name": "result", + "type": "Mcp\\Schema\\Result\\CallToolResult", + "variadic": false + } + ], + "return": "?Mcp\\Schema\\Result\\CallToolResult", + "static": false, + "visibility": "private" + } + }, + "properties": { + "logger": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Psr\\Log\\LoggerInterface", + "visibility": "private" + }, + "referenceHandler": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\Registry\\ReferenceHandlerInterface", + "visibility": "private" + }, + "registry": { + "hasDefault": false, + "readonly": true, + "static": false, + "type": "Mcp\\Capability\\RegistryInterface", + "visibility": "private" + }, + "schemaValidator": { + "hasDefault": false, + "readonly": false, + "static": false, + "type": "Mcp\\Capability\\Discovery\\SchemaValidator", + "visibility": "private" + } + }, + "readonly": false + }, "Mcp\\Server\\Protocol": { "abstract": false, "abstractMethods": [], diff --git a/tests/E2E/Fixtures/http-server.php b/tests/E2E/Fixtures/http-server.php index cbc10ec..12d7e7d 100644 --- a/tests/E2E/Fixtures/http-server.php +++ b/tests/E2E/Fixtures/http-server.php @@ -38,7 +38,9 @@ putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); } -AgentCat::track($builder, 'proj_' . str_repeat('e', 27), new Options( +// AGENTCAT_E2E_TRACK=server tracks the built server instead of the Builder (D76): the same tests run on both. +$tracked = getenv('AGENTCAT_E2E_TRACK') === 'server' ? $builder->build() : $builder; +AgentCat::track($tracked, 'proj_' . str_repeat('e', 27), new Options( identify: static function (ToolCall $call) use ($markers): Identity { if (($call->headers['x-e2e-identify-sleep'] ?? '') === '1') { usleep(3_000_000); @@ -51,7 +53,8 @@ )); $factory = new Psr17Factory(); -$response = $builder->build()->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); +$server = $tracked instanceof Mcp\Server ? $tracked : $builder->build(); +$response = $server->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); $body = (string) $response->getBody(); http_response_code($response->getStatusCode()); foreach ($response->getHeaders() as $name => $values) { diff --git a/tests/E2E/Fixtures/stdio-server.php b/tests/E2E/Fixtures/stdio-server.php index 9cad99a..bb0a213 100644 --- a/tests/E2E/Fixtures/stdio-server.php +++ b/tests/E2E/Fixtures/stdio-server.php @@ -22,6 +22,8 @@ }, 'progress', description: 'Reports progress', inputSchema: $schema); $collector = getenv('AGENTCAT_E2E_COLLECTOR'); -AgentCat::track($builder, 'proj_' . str_repeat('s', 27), new Options(apiBaseUrl: is_string($collector) && $collector !== '' ? $collector : null)); +// AGENTCAT_E2E_TRACK=server tracks the built server instead of the Builder (D76): the same tests run on both. +$server = getenv('AGENTCAT_E2E_TRACK') === 'server' ? $builder->build() : $builder; +AgentCat::track($server, 'proj_' . str_repeat('s', 27), new Options(apiBaseUrl: is_string($collector) && $collector !== '' ? $collector : null)); -exit($builder->build()->run(new StdioTransport())); +exit(($server instanceof Server ? $server : $builder->build())->run(new StdioTransport())); diff --git a/tests/E2E/HttpEndToEndOnBuiltServerTest.php b/tests/E2E/HttpEndToEndOnBuiltServerTest.php new file mode 100644 index 0000000..964766a --- /dev/null +++ b/tests/E2E/HttpEndToEndOnBuiltServerTest.php @@ -0,0 +1,15 @@ + 'true', - 'AGENTCAT_API_URL' => self::$fallbackUrl, - 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), // a request without X-E2E-Diagnostics stays local - 'HOME' => self::$dir . '/home', - 'AGENTCAT_E2E_SESSIONS' => self::$dir . '/sessions', - 'AGENTCAT_E2E_MARKERS' => self::$dir . '/markers', - ]); - } catch (\Throwable $e) { - self::tearDownAfterClass(); // PHPUnit skips it when this method fails - - throw $e; - } - } - - #[\Override] - public static function tearDownAfterClass(): void - { - self::$server?->stop(); - self::$server = null; - self::$collector?->stop(); - self::$collector = null; - FileTree::remove(self::$dir); - } - #[\Override] - protected function setUp(): void - { - $this->scenario = $this->collector()->scenario(); - } - - /** One tracked call through the handshake, the session store and the shutdown delivery. */ - #[Group('ac-02-53')] - #[Group('ac-02-43')] - #[Group('ac-04-49')] - #[MaximumDuration(10000)] - public function testAHandshakeCallIsTrackedEndToEnd(): void - { - $client = $this->client(); - $client->initialize(); - $tools = $client->listTools()->tools ?? null; - $this->assertIsList($tools); - $names = array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools); - $this->assertSame(['echo', 'strict', 'boom', GetMoreTools::NAME], $names); - $this->assertSame(['msg', Constants::PARAM_SESSION_ID, Constants::PARAM_CONTEXT], $this->keys(JsonAssert::at($tools, 0, 'inputSchema', 'properties'))); - - $answer = $client->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start'], $this->headers(['X-AgentCat-Health-Run' => 'S'])); - - $event = $this->event(); - $sessionId = JsonAssert::at($event, 'session_id'); - $this->assertIsString($sessionId); - $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $sessionId); - $this->assertSame([$this->issued($sessionId), 'echo: hi'], $this->texts(HttpHarness::result($answer['message']))); - $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); - $this->assertSame('why', JsonAssert::at($event, 'user_intent')); - $this->assertSame('e2e-client', JsonAssert::at($event, 'client_name'), 'the handshake ladder through FileSessionStore'); - $this->assertSame('e2e-http-server', JsonAssert::at($event, 'server_name')); - $this->assertSame('3.1.4', JsonAssert::at($event, 'server_version')); - $this->assertSame('e2e-user', JsonAssert::at($event, 'identify_actor_given_id')); - $this->assertSame('{"plan":"pro"}', $this->json(JsonAssert::at($event, 'identify_data'))); - $this->assertSame('minted', JsonAssert::at($event, 'tags', Constants::TAG_SESSION_SOURCE)); - $this->assertSame( - '{"msg":"hi","context":"why","session_id":"start"}', - $this->json(JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments')), - ); - $extra = JsonAssert::at($event, 'parameters', 'extra'); - $this->assertSame($client->sessionId(), JsonAssert::at($extra, 'sessionId')); - // ac-02-43, ac-04-49: the HTTP request, as TypeScript v1 records it (D34) - $requestInfo = JsonAssert::at($extra, 'requestInfo'); - $this->assertInstanceOf(\stdClass::class, $requestInfo); - $this->assertSame('S', JsonAssert::at($requestInfo, 'headers', 'x-agentcat-health-run')); - $this->assertSame($this->server()->baseUrl() . '/', JsonAssert::at($requestInfo, 'url')); - $this->assertObjectNotHasProperty('method', $requestInfo); - } - - #[Group('ac-02-20')] - #[MaximumDuration(10000)] - public function testEveryRequestRebuildsTheInjectionRegistries(): void - { - $client = $this->client(); - $client->initialize(); - $client->listTools(); - $before = $this->rebuildLines(); - - $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers()); - - $this->event(); - $this->assertSame($before + 1, $this->rebuildLines(), 'the tools/list of the earlier request ran in another PHP request'); - } - - #[Group('ac-02-17')] - #[MaximumDuration(10000)] - public function testAStrictToolAcceptsTheInjectedParameters(): void - { - $client = $this->client(); - $client->initialize(); - - $answer = $client->callTool('strict', ['msg' => 'hi', 'session_id' => 'start', 'context' => 'why'], $this->headers()); - - $event = $this->event(); - $sessionId = JsonAssert::at($event, 'session_id'); - $this->assertIsString($sessionId); - $this->assertSame([$this->issued($sessionId), 'strict: hi'], $this->texts(HttpHarness::result($answer['message']))); - $this->assertObjectNotHasProperty('is_error', $event); - } - - /** - * The tool result does not wait for the hooks. The fixture's `identify` sleeps 3 s and then writes the marker; it - * runs in the shutdown delivery, after the response. The 2 s bound leaves room for a slow runner; the marker proves - * the order. - */ - #[Group('ac-01-37')] - #[Group('ac-02-48')] - #[Group('ac-03-14')] - #[MaximumDuration(15000)] - public function testTheResponseDoesNotWaitForTheHooks(): void - { - $client = $this->client(); - $client->initialize(); - $sentAt = microtime(true); - - $answer = $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers(['X-E2E-Identify-Sleep' => '1'])); - - $this->assertLessThan(2.0, $answer['receivedAt'] - $sentAt, 'the response arrived before the hook ran'); - HttpHarness::result($answer['message']); - $event = $this->event(self::SLEEPING_HOOK_DEADLINE_SECONDS); - $this->assertSame('e2e-user', JsonAssert::at($event, 'identify_actor_given_id')); - $marker = file_get_contents(self::$dir . '/markers/identify'); - $this->assertIsString($marker); - $this->assertIsNumeric($marker); - $this->assertGreaterThan($answer['receivedAt'], (float) $marker, 'identify ran after the client had its response'); - } - - #[MaximumDuration(10000)] - public function testAModernEraCallIsTrackedWithoutAHandshake(): void - { - $client = $this->client(); - - $answer = $client->callToolModern('echo', ['msg' => 'm'], ['name' => 'modern-client', 'version' => '2.0.0'], $this->headers()); - - $event = $this->event(); - $sessionId = JsonAssert::at($event, 'session_id'); - $this->assertIsString($sessionId); - $this->assertSame([$this->issued($sessionId), 'echo: m'], $this->texts(HttpHarness::result($answer['message']))); - $this->assertSame('modern-client', JsonAssert::at($event, 'client_name')); - $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, JsonAssert::at($event, 'tags', Constants::TAG_PROTOCOL_VERSION)); - $extra = JsonAssert::at($event, 'parameters', 'extra'); - $this->assertInstanceOf(\stdClass::class, $extra); - $this->assertObjectNotHasProperty('sessionId', $extra); - } - - #[Group('ac-05-E5')] - #[Group('ac-02-25')] - #[MaximumDuration(10000)] - public function testAnUncaughtToolExceptionGivesTheLibrarysErrorAndAnEventWithTheException(): void - { - $client = $this->client(); - $client->initialize(); - - $answer = $client->callTool('boom', ['session_id' => 'start'], $this->headers()); - - $this->assertSame('{"code":-32603,"message":"Error while executing tool"}', $this->json(JsonAssert::at($answer['message'], 'error'))); - $event = $this->event(); - $this->assertTrue(JsonAssert::at($event, 'is_error')); - $this->assertSame('RuntimeException', JsonAssert::at($event, 'error', 'type')); - $this->assertSame('e2e boom', JsonAssert::at($event, 'error', 'message')); - $frames = JsonAssert::at($event, 'error', 'frames'); - $this->assertIsList($frames); - $files = array_map(static fn(mixed $frame): mixed => JsonAssert::at($frame, 'filename'), $frames); - $this->assertNotSame([], array_filter($files, static fn(mixed $file): bool => is_string($file) && str_ends_with($file, 'http-server.php')), $this->json($files)); - } - - #[Group('ac-02-40')] - #[MaximumDuration(10000)] - public function testGetMoreToolsIsAnsweredByAgentCat(): void + protected static function tracked(): string { - $client = $this->client(); - $client->initialize(); - - $answer = $client->callTool(GetMoreTools::NAME, ['context' => 'need X'], $this->headers()); - - $event = $this->event(); - $sessionId = JsonAssert::at($event, 'session_id'); - $this->assertIsString($sessionId); - $this->assertSame([$this->issued($sessionId), $this->cannedText()], $this->texts(HttpHarness::result($answer['message']))); - $this->assertSame(GetMoreTools::NAME, JsonAssert::at($event, 'resource_name')); - } - - /** - * The event carries the headers as the SAPI gave them. Repeated Accept lines differ by PHP version: `php -S` on - * PHP 8.3 keeps the first line, on PHP 8.4 and 8.5 it joins them with `, `. The fixture records the value the SAPI - * gave PHP, and the event must carry exactly that. - */ - #[MaximumDuration(10000)] - public function testOddHeadersReachTheEventAsStrings(): void - { - $client = $this->client(); - $client->initialize(); - $cookie = 'big=' . str_repeat('c', 8_996); - - $answer = $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers([ - 'X-Odd' => "caf\xE9", - 'Accept' => ['application/json', 'text/event-stream'], - 'Cookie' => $cookie, - 'X-E2E-Record-Accept' => '1', - ])); - - $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer['message']), 'content', 1, 'text')); - $headers = JsonAssert::at($this->event(), 'parameters', 'extra', 'requestInfo', 'headers'); - $this->assertSame("caf\u{FFFD}", JsonAssert::at($headers, 'x-odd')); - $this->assertSame(9_000, strlen($cookie)); - $this->assertSame($cookie, JsonAssert::at($headers, 'cookie')); - $accept = file_get_contents(self::$dir . '/markers/accept'); - $this->assertIsString($accept); - $this->assertStringStartsWith('application/json', $accept); - $this->assertSame($accept, JsonAssert::at($headers, 'accept')); - } - - /** - * D47: under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, after its response. - * Each POST is tied to its request by the session in its `Successfully sent event` line: each call mints its own - * session. - */ - #[Group('ac-06-62')] - #[MaximumDuration(15000)] - public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void - { - $this->scenario = $this->collector()->scenario([], ['status' => 202, 'delayMs' => self::SLOW_EVENT_MS, 'body' => '{"status":"accepted"}']); - $diagnostics = $this->collector()->scenario(); - $client = $this->client(); - $client->initialize(); - - $answeredAt = []; // when the client had each answer, by the session that the call minted - foreach (['one', 'two'] as $msg) { - $answer = $client->callTool('echo', ['msg' => $msg, 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); - $result = HttpHarness::result($answer['message']); - $this->assertSame('echo: ' . $msg, JsonAssert::at($result, 'content', 1, 'text')); - $answeredAt[$this->issuedSessionId($result)] = $answer['receivedAt']; - } - - $this->assertCount(2, $answeredAt, 'each call minted its own session'); - $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); - $this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request'); - $this->assertCount(2, $this->collector()->requests($diagnostics), 'and no other: the 2 s window is for long-running processes only'); - foreach ($answeredAt as $sessionId => $receivedAt) { - $own = array_values(array_filter($sent, static fn(\stdClass $request): bool => str_contains(MockCollector::body($request), ' | session ' . $sessionId . ' | '))); - $this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId); - $this->assertSame('/v1/logs', $own[0]->path); - $this->assertSame(1, substr_count(MockCollector::body($own[0]), 'Successfully sent event '), 'one line each'); - $start = $own[0]->start; - $this->assertIsFloat($start); - $this->assertGreaterThan($receivedAt, $start, 'the diagnostics POST of ' . $sessionId . ' ran after its response'); - } - } - - /** A call without `X-E2E-Collector` falls back to `AGENTCAT_API_URL`, a closed local port: never the default API. */ - #[MaximumDuration(10000)] - public function testWithoutACollectorHeaderTheEventGoesToTheLocalFallback(): void - { - $client = $this->client(); - $client->initialize(); - $offset = strlen($this->log()); - - HttpHarness::result($client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])['message']); - - $port = parse_url(self::$fallbackUrl, PHP_URL_PORT); - $this->assertIsInt($port); - $failure = $this->failedDelivery($offset); - $this->assertStringContainsString('127.0.0.1 port ' . $port, $failure, 'the delivery went to AGENTCAT_API_URL'); - $this->assertStringNotContainsString('agentcat.com', substr($this->log(), $offset)); - } - - private function client(): McpHttpClient - { - return new McpHttpClient($this->server()->baseUrl() . '/'); - } - - /** - * The headers of a tool call: this test's collector scenario, plus `$extra`. - * - * @param array> $extra - * - * @return array> - */ - private function headers(array $extra = []): array - { - return ['X-E2E-Collector' => $this->scenario, ...$extra]; - } - - /** Waits for the one event of this test's scenario and returns its decoded body. */ - private function event(float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS): \stdClass - { - $deadline = microtime(true) + $deadlineSeconds; - do { - $requests = $this->collector()->requests($this->scenario); - if ($requests !== []) { - break; - } - usleep(20_000); - } while (microtime(true) < $deadline); - $this->assertCount(1, $requests, 'one POST to the collector'); - $this->assertSame('POST', $requests[0]->method); - $body = $requests[0]->body; - $this->assertIsString($body); - $event = Json::decode($body); - $this->assertInstanceOf(\stdClass::class, $event, $body); - - return $event; - } - - /** - * Waits until `$count` diagnostics requests of the scenario carry a `Successfully sent event` line, or the deadline - * passes, and returns those requests. - * - * @return list<\stdClass> - */ - private function diagnosticsWithASentEvent(string $scenario, int $count): array - { - return MockCollector::withASentEvent($this->collector()->waitFor($scenario, static fn(array $requests): bool => count(MockCollector::withASentEvent($requests)) >= $count, self::DIAGNOSTICS_DEADLINE_SECONDS)); - } - - /** How often the server's agentcat.log says that a request rebuilt the registries. */ - private function rebuildLines(): int - { - return substr_count($this->log(), self::REBUILT); - } - - /** Waits for the first failed delivery that the server's agentcat.log reports after byte `$offset`. */ - private function failedDelivery(int $offset): string - { - $deadline = microtime(true) + self::LOG_DEADLINE_SECONDS; - do { - $written = substr($this->log(), $offset); - if (preg_match(self::FAILED_DELIVERY, $written, $match) === 1) { - return $match[0]; - } - usleep(20_000); - } while (microtime(true) < $deadline); - - self::fail('No failed delivery in agentcat.log: ' . $written); - } - - /** The server's agentcat.log so far. */ - private function log(): string - { - $log = file_get_contents(self::$dir . '/home/agentcat.log'); - $this->assertIsString($log); - - return $log; - } - - /** The `ses_` ID of the issued block at the start of an answer. */ - private function issuedSessionId(\stdClass $result): string - { - $text = JsonAssert::at($result, 'content', 0, 'text'); - $this->assertIsString($text); - $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $text, $match), $text); - - return $match[1]; - } - - private function issued(string $sessionId): string - { - return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); - } - - private function cannedText(): string - { - $text = JsonAssert::at(Golden::json('handles/strings.json'), 'GET_MORE_TOOLS_RESULT', 'content', 0, 'text'); - $this->assertIsString($text); - - return $text; - } - - /** @return list the `text` of every content block */ - private function texts(\stdClass $result): array - { - $content = $result->content ?? null; - $this->assertIsList($content); - - return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); - } - - /** @return list */ - private function keys(mixed $object): array - { - $this->assertInstanceOf(\stdClass::class, $object); - - return array_map(strval(...), array_keys(get_object_vars($object))); - } - - private function json(mixed $value): string - { - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); - } - - private function collector(): MockCollector - { - $this->assertInstanceOf(MockCollector::class, self::$collector); - - return self::$collector; - } - - private function server(): BuiltInServer - { - $this->assertInstanceOf(BuiltInServer::class, self::$server); - - return self::$server; + return 'builder'; } } diff --git a/tests/E2E/HttpEndToEndTestCase.php b/tests/E2E/HttpEndToEndTestCase.php new file mode 100644 index 0000000..d127c00 --- /dev/null +++ b/tests/E2E/HttpEndToEndTestCase.php @@ -0,0 +1,489 @@ + 'true', + 'AGENTCAT_API_URL' => self::$fallbackUrl, + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), // a request without X-E2E-Diagnostics stays local + 'HOME' => self::$dir . '/home', + 'AGENTCAT_E2E_SESSIONS' => self::$dir . '/sessions', + 'AGENTCAT_E2E_MARKERS' => self::$dir . '/markers', + 'AGENTCAT_E2E_TRACK' => static::tracked(), + ]); + } catch (\Throwable $e) { + self::tearDownAfterClass(); // PHPUnit skips it when this method fails + + throw $e; + } + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$server?->stop(); + self::$server = null; + self::$collector?->stop(); + self::$collector = null; + FileTree::remove(self::$dir); + } + + #[\Override] + protected function setUp(): void + { + $this->scenario = $this->collector()->scenario(); + } + + /** One tracked call through the handshake, the session store and the shutdown delivery. */ + #[Group('ac-02-53')] + #[Group('ac-02-43')] + #[Group('ac-04-49')] + #[MaximumDuration(10000)] + public function testAHandshakeCallIsTrackedEndToEnd(): void + { + $client = $this->client(); + $client->initialize(); + $tools = $client->listTools()->tools ?? null; + $this->assertIsList($tools); + $names = array_map(static fn(mixed $tool): mixed => JsonAssert::at($tool, 'name'), $tools); + $this->assertSame(['echo', 'strict', 'boom', GetMoreTools::NAME], $names); + $this->assertSame(['msg', Constants::PARAM_SESSION_ID, Constants::PARAM_CONTEXT], $this->keys(JsonAssert::at($tools, 0, 'inputSchema', 'properties'))); + + $answer = $client->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => 'start'], $this->headers(['X-AgentCat-Health-Run' => 'S'])); + + $event = $this->event(); + $sessionId = JsonAssert::at($event, 'session_id'); + $this->assertIsString($sessionId); + $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $sessionId); + $this->assertSame([$this->issued($sessionId), 'echo: hi'], $this->texts(HttpHarness::result($answer['message']))); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertSame('why', JsonAssert::at($event, 'user_intent')); + $this->assertSame('e2e-client', JsonAssert::at($event, 'client_name'), 'the handshake ladder through FileSessionStore'); + $this->assertSame('e2e-http-server', JsonAssert::at($event, 'server_name')); + $this->assertSame('3.1.4', JsonAssert::at($event, 'server_version')); + $this->assertSame('e2e-user', JsonAssert::at($event, 'identify_actor_given_id')); + $this->assertSame('{"plan":"pro"}', $this->json(JsonAssert::at($event, 'identify_data'))); + $this->assertSame('minted', JsonAssert::at($event, 'tags', Constants::TAG_SESSION_SOURCE)); + $this->assertSame( + '{"msg":"hi","context":"why","session_id":"start"}', + $this->json(JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments')), + ); + $extra = JsonAssert::at($event, 'parameters', 'extra'); + $this->assertSame($client->sessionId(), JsonAssert::at($extra, 'sessionId')); + // ac-02-43, ac-04-49: the HTTP request, as TypeScript v1 records it (D34) + $requestInfo = JsonAssert::at($extra, 'requestInfo'); + $this->assertInstanceOf(\stdClass::class, $requestInfo); + $this->assertSame('S', JsonAssert::at($requestInfo, 'headers', 'x-agentcat-health-run')); + $this->assertSame($this->server()->baseUrl() . '/', JsonAssert::at($requestInfo, 'url')); + $this->assertObjectNotHasProperty('method', $requestInfo); + } + + #[Group('ac-02-20')] + #[MaximumDuration(10000)] + public function testEveryRequestRebuildsTheInjectionRegistries(): void + { + $client = $this->client(); + $client->initialize(); + $client->listTools(); + $before = $this->rebuildLines(); + + $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers()); + + $this->event(); + // A server tracked after build() fills its registries at track(), in every request: it never rebuilds them. + $this->assertSame($before + (static::tracked() === 'server' ? 0 : 1), $this->rebuildLines(), 'the tools/list of the earlier request ran in another PHP request'); + } + + #[Group('ac-02-17')] + #[MaximumDuration(10000)] + public function testAStrictToolAcceptsTheInjectedParameters(): void + { + $client = $this->client(); + $client->initialize(); + + $answer = $client->callTool('strict', ['msg' => 'hi', 'session_id' => 'start', 'context' => 'why'], $this->headers()); + + $event = $this->event(); + $sessionId = JsonAssert::at($event, 'session_id'); + $this->assertIsString($sessionId); + $this->assertSame([$this->issued($sessionId), 'strict: hi'], $this->texts(HttpHarness::result($answer['message']))); + $this->assertObjectNotHasProperty('is_error', $event); + } + + /** + * The tool result does not wait for the hooks. The fixture's `identify` sleeps 3 s and then writes the marker; it + * runs in the shutdown delivery, after the response. The 2 s bound leaves room for a slow runner; the marker proves + * the order. + */ + #[Group('ac-01-37')] + #[Group('ac-02-48')] + #[Group('ac-03-14')] + #[MaximumDuration(15000)] + public function testTheResponseDoesNotWaitForTheHooks(): void + { + $client = $this->client(); + $client->initialize(); + $sentAt = microtime(true); + + $answer = $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers(['X-E2E-Identify-Sleep' => '1'])); + + $this->assertLessThan(2.0, $answer['receivedAt'] - $sentAt, 'the response arrived before the hook ran'); + HttpHarness::result($answer['message']); + $event = $this->event(self::SLEEPING_HOOK_DEADLINE_SECONDS); + $this->assertSame('e2e-user', JsonAssert::at($event, 'identify_actor_given_id')); + $marker = file_get_contents(self::$dir . '/markers/identify'); + $this->assertIsString($marker); + $this->assertIsNumeric($marker); + $this->assertGreaterThan($answer['receivedAt'], (float) $marker, 'identify ran after the client had its response'); + } + + #[MaximumDuration(10000)] + public function testAModernEraCallIsTrackedWithoutAHandshake(): void + { + $client = $this->client(); + + $answer = $client->callToolModern('echo', ['msg' => 'm'], ['name' => 'modern-client', 'version' => '2.0.0'], $this->headers()); + + $event = $this->event(); + $sessionId = JsonAssert::at($event, 'session_id'); + $this->assertIsString($sessionId); + $this->assertSame([$this->issued($sessionId), 'echo: m'], $this->texts(HttpHarness::result($answer['message']))); + $this->assertSame('modern-client', JsonAssert::at($event, 'client_name')); + $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, JsonAssert::at($event, 'tags', Constants::TAG_PROTOCOL_VERSION)); + $extra = JsonAssert::at($event, 'parameters', 'extra'); + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertObjectNotHasProperty('sessionId', $extra); + } + + #[Group('ac-05-E5')] + #[Group('ac-02-25')] + #[MaximumDuration(10000)] + public function testAnUncaughtToolExceptionGivesTheLibrarysErrorAndAnEventWithTheException(): void + { + $client = $this->client(); + $client->initialize(); + + $answer = $client->callTool('boom', ['session_id' => 'start'], $this->headers()); + + $this->assertSame('{"code":-32603,"message":"Error while executing tool"}', $this->json(JsonAssert::at($answer['message'], 'error'))); + $event = $this->event(); + $this->assertTrue(JsonAssert::at($event, 'is_error')); + $this->assertSame('RuntimeException', JsonAssert::at($event, 'error', 'type')); + $this->assertSame('e2e boom', JsonAssert::at($event, 'error', 'message')); + $frames = JsonAssert::at($event, 'error', 'frames'); + $this->assertIsList($frames); + $files = array_map(static fn(mixed $frame): mixed => JsonAssert::at($frame, 'filename'), $frames); + $this->assertNotSame([], array_filter($files, static fn(mixed $file): bool => is_string($file) && str_ends_with($file, 'http-server.php')), $this->json($files)); + } + + #[Group('ac-02-40')] + #[MaximumDuration(10000)] + public function testGetMoreToolsIsAnsweredByAgentCat(): void + { + $client = $this->client(); + $client->initialize(); + + $answer = $client->callTool(GetMoreTools::NAME, ['context' => 'need X'], $this->headers()); + + $event = $this->event(); + $sessionId = JsonAssert::at($event, 'session_id'); + $this->assertIsString($sessionId); + $this->assertSame([$this->issued($sessionId), $this->cannedText()], $this->texts(HttpHarness::result($answer['message']))); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at($event, 'resource_name')); + } + + /** + * The event carries the headers as the SAPI gave them. Repeated Accept lines differ by PHP version: `php -S` on + * PHP 8.3 keeps the first line, on PHP 8.4 and 8.5 it joins them with `, `. The fixture records the value the SAPI + * gave PHP, and the event must carry exactly that. + */ + #[MaximumDuration(10000)] + public function testOddHeadersReachTheEventAsStrings(): void + { + $client = $this->client(); + $client->initialize(); + $cookie = 'big=' . str_repeat('c', 8_996); + + $answer = $client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'], $this->headers([ + 'X-Odd' => "caf\xE9", + 'Accept' => ['application/json', 'text/event-stream'], + 'Cookie' => $cookie, + 'X-E2E-Record-Accept' => '1', + ])); + + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer['message']), 'content', 1, 'text')); + $headers = JsonAssert::at($this->event(), 'parameters', 'extra', 'requestInfo', 'headers'); + $this->assertSame("caf\u{FFFD}", JsonAssert::at($headers, 'x-odd')); + $this->assertSame(9_000, strlen($cookie)); + $this->assertSame($cookie, JsonAssert::at($headers, 'cookie')); + $accept = file_get_contents(self::$dir . '/markers/accept'); + $this->assertIsString($accept); + $this->assertStringStartsWith('application/json', $accept); + $this->assertSame($accept, JsonAssert::at($headers, 'accept')); + } + + /** + * D47: under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, after its response. + * Each POST is tied to its request by the session in its `Successfully sent event` line: each call mints its own + * session. + */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void + { + $this->scenario = $this->collector()->scenario([], ['status' => 202, 'delayMs' => self::SLOW_EVENT_MS, 'body' => '{"status":"accepted"}']); + $diagnostics = $this->collector()->scenario(); + $client = $this->client(); + $client->initialize(); + + $answeredAt = []; // when the client had each answer, by the session that the call minted + foreach (['one', 'two'] as $msg) { + $answer = $client->callTool('echo', ['msg' => $msg, 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); + $result = HttpHarness::result($answer['message']); + $this->assertSame('echo: ' . $msg, JsonAssert::at($result, 'content', 1, 'text')); + $answeredAt[$this->issuedSessionId($result)] = $answer['receivedAt']; + } + + $this->assertCount(2, $answeredAt, 'each call minted its own session'); + $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); + $this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request'); + $this->assertCount(2, $this->collector()->requests($diagnostics), 'and no other: the 2 s window is for long-running processes only'); + foreach ($answeredAt as $sessionId => $receivedAt) { + $own = array_values(array_filter($sent, static fn(\stdClass $request): bool => str_contains(MockCollector::body($request), ' | session ' . $sessionId . ' | '))); + $this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId); + $this->assertSame('/v1/logs', $own[0]->path); + $this->assertSame(1, substr_count(MockCollector::body($own[0]), 'Successfully sent event '), 'one line each'); + $start = $own[0]->start; + $this->assertIsFloat($start); + $this->assertGreaterThan($receivedAt, $start, 'the diagnostics POST of ' . $sessionId . ' ran after its response'); + } + } + + /** A call without `X-E2E-Collector` falls back to `AGENTCAT_API_URL`, a closed local port: never the default API. */ + #[MaximumDuration(10000)] + public function testWithoutACollectorHeaderTheEventGoesToTheLocalFallback(): void + { + $client = $this->client(); + $client->initialize(); + $offset = strlen($this->log()); + + HttpHarness::result($client->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])['message']); + + $port = parse_url(self::$fallbackUrl, PHP_URL_PORT); + $this->assertIsInt($port); + $failure = $this->failedDelivery($offset); + $this->assertStringContainsString('127.0.0.1 port ' . $port, $failure, 'the delivery went to AGENTCAT_API_URL'); + $this->assertStringNotContainsString('agentcat.com', substr($this->log(), $offset)); + } + + private function client(): McpHttpClient + { + return new McpHttpClient($this->server()->baseUrl() . '/'); + } + + /** + * The headers of a tool call: this test's collector scenario, plus `$extra`. + * + * @param array> $extra + * + * @return array> + */ + private function headers(array $extra = []): array + { + return ['X-E2E-Collector' => $this->scenario, ...$extra]; + } + + /** Waits for the one event of this test's scenario and returns its decoded body. */ + private function event(float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS): \stdClass + { + $deadline = microtime(true) + $deadlineSeconds; + do { + $requests = $this->collector()->requests($this->scenario); + if ($requests !== []) { + break; + } + usleep(20_000); + } while (microtime(true) < $deadline); + $this->assertCount(1, $requests, 'one POST to the collector'); + $this->assertSame('POST', $requests[0]->method); + $body = $requests[0]->body; + $this->assertIsString($body); + $event = Json::decode($body); + $this->assertInstanceOf(\stdClass::class, $event, $body); + + return $event; + } + + /** + * Waits until `$count` diagnostics requests of the scenario carry a `Successfully sent event` line, or the deadline + * passes, and returns those requests. + * + * @return list<\stdClass> + */ + private function diagnosticsWithASentEvent(string $scenario, int $count): array + { + return MockCollector::withASentEvent($this->collector()->waitFor($scenario, static fn(array $requests): bool => count(MockCollector::withASentEvent($requests)) >= $count, self::DIAGNOSTICS_DEADLINE_SECONDS)); + } + + /** How often the server's agentcat.log says that a request rebuilt the registries. */ + private function rebuildLines(): int + { + return substr_count($this->log(), self::REBUILT); + } + + /** Waits for the first failed delivery that the server's agentcat.log reports after byte `$offset`. */ + private function failedDelivery(int $offset): string + { + $deadline = microtime(true) + self::LOG_DEADLINE_SECONDS; + do { + $written = substr($this->log(), $offset); + if (preg_match(self::FAILED_DELIVERY, $written, $match) === 1) { + return $match[0]; + } + usleep(20_000); + } while (microtime(true) < $deadline); + + self::fail('No failed delivery in agentcat.log: ' . $written); + } + + /** The server's agentcat.log so far. */ + private function log(): string + { + $log = file_get_contents(self::$dir . '/home/agentcat.log'); + $this->assertIsString($log); + + return $log; + } + + /** The `ses_` ID of the issued block at the start of an answer. */ + private function issuedSessionId(\stdClass $result): string + { + $text = JsonAssert::at($result, 'content', 0, 'text'); + $this->assertIsString($text); + $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $text, $match), $text); + + return $match[1]; + } + + private function issued(string $sessionId): string + { + return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); + } + + private function cannedText(): string + { + $text = JsonAssert::at(Golden::json('handles/strings.json'), 'GET_MORE_TOOLS_RESULT', 'content', 0, 'text'); + $this->assertIsString($text); + + return $text; + } + + /** @return list the `text` of every content block */ + private function texts(\stdClass $result): array + { + $content = $result->content ?? null; + $this->assertIsList($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } + + private function server(): BuiltInServer + { + $this->assertInstanceOf(BuiltInServer::class, self::$server); + + return self::$server; + } +} diff --git a/tests/E2E/StdioEndToEndOnBuiltServerTest.php b/tests/E2E/StdioEndToEndOnBuiltServerTest.php new file mode 100644 index 0000000..97a543c --- /dev/null +++ b/tests/E2E/StdioEndToEndOnBuiltServerTest.php @@ -0,0 +1,15 @@ +stop(); - self::$collector = null; - FileTree::remove(self::$dir); - } - - #[\Override] - protected function tearDown(): void - { - $this->client?->process()->stop(0); - $this->client = null; - } - - /** One stdio session through the handshake; both events reach the collector by the time stdin closes. */ - #[Group('ac-02-53')] - #[MaximumDuration(15000)] - public function testAHandshakeSessionIsTrackedAndDeliveredAtEof(): void - { - $client = $this->start(); - $this->handshake($client); - $tools = $this->resultOf($client, 'tools/list', [])->tools ?? null; - $this->assertIsList($tools); - $this->assertSame('echo', JsonAssert::at($tools, 0, 'name')); - - $first = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); - $sessionId = $this->issuedSessionId($first); - $this->assertSame([$this->issued($sessionId), 'echo: one'], $this->texts($first)); - $second = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'two', 'session_id' => $sessionId]]); - $this->assertSame(['echo: two'], $this->texts($second), 'the issued session_id is recognized: no new block'); - - $this->assertExitsAtEof($client); - $events = $this->events(2); - foreach ($events as $event) { - $this->assertSame('e2e-stdio-server', JsonAssert::at($event, 'server_name')); - $this->assertSame('2.7.1', JsonAssert::at($event, 'server_version')); - $this->assertSame('stdio-client', JsonAssert::at($event, 'client_name')); - $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); - $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); - } - $this->assertEqualsCanonicalizing(['one', 'two'], array_map( - static fn(\stdClass $event): mixed => JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments', 'msg'), - $events, - )); - } - - /** No SDK byte on STDOUT or STDERR: the log lines go to `$HOME/agentcat.log`. */ - #[Group('ac-03-37')] - #[Group('ac-01-87')] - #[MaximumDuration(15000)] - public function testStdoutCarriesOnlyJsonRpcAndTheSdkLogsToItsFile(): void - { - $client = $this->start(); - $this->handshake($client); - $this->resultOf($client, 'tools/list', []); - $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]); - $this->resultOf($client, 'tools/call', ['name' => 'progress', 'arguments' => ['msg' => 'hi', 'session_id' => 'start'], '_meta' => ['progressToken' => 'p0']]); - - $this->assertExitsAtEof($client); - $this->events(2); - $this->assertStdoutIsOnlyJsonRpc($client); - $this->assertSame('', $client->process()->getErrorOutput()); - $log = $this->log(); - $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT_ID . ' | sdk mcp-sdk/builder | signals ', $log); - $this->assertStringContainsString('AgentCat setup complete | project ' . self::PROJECT_ID . ' | tracing=true context=true reportMissing=true exporters=0', $log); - } - - /** The tool's progress notification suspends the handler's fiber in the middle of AgentCat's interceptor. */ - #[MaximumDuration(15000)] - public function testAFiberSuspendedInsideTheInterceptorIsAnsweredAndTrackedOnce(): void - { - $client = $this->start(); - $this->handshake($client); - $before = count($client->stdoutLines()); - - $result = $this->resultOf($client, 'tools/call', ['name' => 'progress', 'arguments' => ['msg' => 'x', 'session_id' => 'start'], '_meta' => ['progressToken' => 'p1']]); - - $sessionId = $this->issuedSessionId($result); - $this->assertSame([$this->issued($sessionId), 'done: x'], $this->texts($result)); - $messages = array_map(Json::decode(...), array_slice($client->stdoutLines(), $before)); - $this->assertCount(2, $messages, 'the notification, then the answer'); - $this->assertSame('notifications/progress', JsonAssert::at($messages[0], 'method')); - $this->assertSame('p1', JsonAssert::at($messages[0], 'params', 'progressToken')); - $this->assertSame($this->nextId - 1, JsonAssert::at($messages[1], 'id')); - - $this->assertExitsAtEof($client); - $event = $this->event(); - $this->assertSame('progress', JsonAssert::at($event, 'resource_name')); - $this->assertSame('done: x', JsonAssert::at($event, 'response', 'content', 0, 'text')); - $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); - } - - /** The pump at handler entry delivers an earlier call's event while stdin stays open. */ - #[MaximumDuration(15000)] - public function testAnEventIsDeliveredBetweenRequestsWhileStdinStaysOpen(): void - { - $client = $this->start(); - $this->handshake($client); - $sessionId = $this->issuedSessionId($this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call 1', 'session_id' => 'start']])); - - $calls = 1; - $delivered = null; - while (!$delivered instanceof \stdClass && $calls < self::MAX_CALLS_BEFORE_DELIVERY) { - ++$calls; - $answer = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call ' . $calls, 'session_id' => $sessionId]]); - $this->assertSame(['echo: call ' . $calls], $this->texts($answer)); - $delivered = $this->eventWithMessage('call 1', self::BETWEEN_CALLS_SECONDS); - } - - $this->assertInstanceOf(\stdClass::class, $delivered, sprintf('the first event reached the collector within %d calls', $calls)); - $this->assertTrue($client->process()->isRunning(), 'stdin is still open'); - $this->assertSame($sessionId, JsonAssert::at($delivered, 'session_id')); - $this->assertExitsAtEof($client); - $this->assertCount($calls, $this->events($calls), 'every call is delivered by the end'); - } - - /** - * Stdin closes while the collector holds every delivery. The pump at each handler entry starts at most 5 - * deliveries, so 2 of the 7 events still wait when the 5 s shutdown budget ends. - */ - #[Group('ac-03-9')] - #[MaximumDuration(15000)] - public function testStdinClosingWhileTheCollectorIsSlowEndsWithinTheShutdownBudget(): void - { - $client = $this->start(['status' => 202, 'delayMs' => 8000]); - $this->handshake($client); - $sessionId = $this->issuedSessionId($this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call 1', 'session_id' => 'start']])); - for ($call = 2; $call <= 7; ++$call) { - $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call ' . $call, 'session_id' => $sessionId]]); - } - - $this->assertExitsAtEof($client, self::SLOW_COLLECTOR_EXIT_SECONDS); - $log = $this->log(); - $this->assertSame(1, substr_count($log, 'Shutting down with '), $log); - $this->assertMatchesRegularExpression('/\] Shutting down with 2 events still in queue$/m', $log); - $this->assertStdoutIsOnlyJsonRpc($client); - $this->assertSame('', $client->process()->getErrorOutput()); - } - - /** SIGTERM with stdin open: the drain delivers the event, then the default disposition ends the process. */ - #[Group('ac-03-10')] - #[RequiresPhpExtension('pcntl')] - #[RequiresPhpExtension('posix')] - #[MaximumDuration(15000)] - public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void - { - $client = $this->start(); - $this->handshake($client); - $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]); - $this->assertSame([], $this->collector()->requests($this->scenario), 'nothing pumped the event yet'); - - $sentAt = microtime(true); - $client->signal(self::SIGTERM); - $code = $client->waitForExit(self::SIGTERM_EXIT_SECONDS); - - $this->assertNotNull($code, sprintf('the process ended within %.1f s', self::SIGTERM_EXIT_SECONDS)); - $this->assertTrue($client->process()->hasBeenSignaled(), sprintf('exit code %d after %.3f s', $code, microtime(true) - $sentAt)); - $this->assertSame(self::SIGTERM, $client->process()->getTermSignal()); - $event = $this->event(); - $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); - $this->assertStdoutIsOnlyJsonRpc($client); - } - - /** - * ac-06-62 in a real process: stdin EOF posts the diagnostics buffer exactly once, with the setup beacons and the - * per-event line logged during the EOF drain. - */ - #[Group('ac-06-62')] - #[MaximumDuration(15000)] - public function testStdinEofSendsTheDiagnosticsOnce(): void - { - $diagnostics = $this->collector()->scenario(); - $startedAt = microtime(true); - $client = $this->start(env: ['AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $diagnostics]); - $this->handshake($client); - $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); - - $this->assertExitsAtEof($client); - - // The server lived less than the 2 s window, so no handler-entry pump and no shutdown pass could post the - // buffer early: the EOF flush is the only POST. Counted after the exit, so a late duplicate is seen too. - $lifetime = microtime(true) - $startedAt; - $this->assertLessThan(Diagnostics::BATCH_FLUSH_SECONDS, $lifetime, 'the server ended inside the 2 s window'); - $requests = $this->collector()->requests($diagnostics); - $this->assertCount(1, $requests, sprintf('one diagnostics POST; the server lived %.3f s', $lifetime)); - $this->assertSame('/v1/logs', $requests[0]->path); - $body = MockCollector::body($requests[0]); - $this->assertStringContainsString('"stringValue":"php"', $body); - $this->assertStringContainsString('AgentCat setup complete | project proj_', $body); - $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); - } - - /** 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::hasModernEra()) { - self::markTestSkipped('mcp/sdk 0.8.1 serves only the handshake era over stdio'); - } - $client = $this->start(); - - $result = $this->resultOf($client, 'tools/call', HttpHarness::modernParams( - ['name' => 'echo', 'arguments' => ['msg' => 'm']], - ['name' => 'modern-client', 'version' => '2.0.0'], - )); - - $sessionId = $this->issuedSessionId($result); - $this->assertSame([$this->issued($sessionId), 'echo: m'], $this->texts($result)); - $this->assertExitsAtEof($client); - $event = $this->event(); - $this->assertSame('modern-client', JsonAssert::at($event, 'client_name')); - $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, JsonAssert::at($event, 'tags', Constants::TAG_PROTOCOL_VERSION)); - $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); - } - - /** - * Starts this test's server with its own collector scenario and HOME. `AGENTCAT_API_URL` and - * `AGENTCAT_DIAGNOSTICS_ENDPOINT` are closed local ports, so an event without the scenario can never reach the - * AgentCat API, and diagnostics, which stay off by the inherited `AGENTCAT_DISABLE_DIAGNOSTICS=true`, can never reach - * AgentCat's collector. - * - * @param array{status: int, delayMs?: int, body?: string} $default the collector's answer to every event - * @param array $env more environment variables of the server, applied last - */ - private function start(array $default = ['status' => 202, 'body' => '{"status":"accepted"}'], array $env = []): StdioMcpClient - { - $this->scenario = $this->collector()->scenario([], $default); - $this->home = self::$dir . '/home-' . bin2hex(random_bytes(6)); - $this->assertTrue(mkdir($this->home, 0o700)); - $this->client = StdioMcpClient::start(__DIR__ . '/Fixtures/stdio-server.php', [ - 'AGENTCAT_ENABLED' => 'true', - 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), - 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), - 'AGENTCAT_E2E_COLLECTOR' => $this->scenario, - 'HOME' => $this->home, - ...$env, - ]); - - return $this->client; - } - - /** `initialize` and `notifications/initialized`, as an MCP host opens a stdio session. */ - private function handshake(StdioMcpClient $client): void + protected static function tracked(): string { - $result = $this->resultOf($client, 'initialize', [ - 'protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, - 'capabilities' => new \stdClass(), - 'clientInfo' => ['name' => 'stdio-client', 'version' => '1.0.0'], - ]); - $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($result, 'protocolVersion')); - $client->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); - } - - /** - * Sends one request and returns the `result` of its answer. - * - * @param array $params - */ - private function resultOf(StdioMcpClient $client, string $method, array $params): \stdClass - { - $id = $this->nextId++; - $client->send(['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params]); - - return HttpHarness::result($client->receive($id)); - } - - /** Closes stdin and waits for exit code 0. */ - private function assertExitsAtEof(StdioMcpClient $client, float $timeoutSeconds = self::EOF_EXIT_SECONDS): void - { - $closedAt = microtime(true); - $client->closeInput(); - $code = $client->waitForExit($timeoutSeconds); - $this->assertSame(0, $code, sprintf('exit within %.1f s of stdin EOF (%.3f s); stderr: %s', $timeoutSeconds, microtime(true) - $closedAt, $client->process()->getErrorOutput())); - } - - /** Every byte on stdout belongs to a JSON-RPC message, one per line. */ - private function assertStdoutIsOnlyJsonRpc(StdioMcpClient $client): void - { - $lines = $client->stdoutLines(); - $this->assertNotSame([], $lines); - $this->assertSame(implode("\n", $lines) . "\n", $client->process()->getOutput(), 'stdout holds whole lines only'); - foreach ($lines as $line) { - $this->assertJson($line); - $message = Json::decode($line); - $this->assertInstanceOf(\stdClass::class, $message, $line); - $this->assertSame('2.0', $message->jsonrpc ?? null, $line); - } - } - - /** - * Waits until the collector holds `$count` events of this test's scenario and returns their decoded bodies. - * - * @return list<\stdClass> - */ - private function events(int $count): array - { - $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; - do { - $requests = $this->collector()->requests($this->scenario); - if (count($requests) >= $count) { - break; - } - usleep(20_000); - } while (microtime(true) < $deadline); - $this->assertCount($count, $requests, 'POSTs to the collector'); - - return array_map(function (\stdClass $request): \stdClass { - $this->assertSame('POST', $request->method); - $body = $request->body; - $this->assertIsString($body); - $event = Json::decode($body); - $this->assertInstanceOf(\stdClass::class, $event, $body); - - return $event; - }, $requests); - } - - /** Waits for the one event of this test's scenario and returns its decoded body. */ - private function event(): \stdClass - { - $event = $this->events(1)[0] ?? null; - $this->assertInstanceOf(\stdClass::class, $event); - - return $event; - } - - /** The collector's event whose call had `msg` `$msg`, waiting up to `$deadlineSeconds`; null if none came. */ - private function eventWithMessage(string $msg, float $deadlineSeconds): ?\stdClass - { - $deadline = microtime(true) + $deadlineSeconds; - do { - foreach ($this->collector()->requests($this->scenario) as $request) { - $event = is_string($request->body ?? null) ? Json::decode($request->body) : null; - if ($event instanceof \stdClass && JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments', 'msg') === $msg) { - return $event; - } - } - usleep(20_000); - } while (microtime(true) < $deadline); - - return null; - } - - /** The server's agentcat.log. */ - private function log(): string - { - $log = file_get_contents($this->home . '/agentcat.log'); - $this->assertIsString($log); - - return $log; - } - - /** The `ses_` ID of the issued block at the start of an answer. */ - private function issuedSessionId(\stdClass $result): string - { - $text = JsonAssert::at($result, 'content', 0, 'text'); - $this->assertIsString($text); - $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $text, $match), $text); - - return $match[1]; - } - - private function issued(string $sessionId): string - { - return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); - } - - /** @return list the `text` of every content block */ - private function texts(\stdClass $result): array - { - $content = $result->content ?? null; - $this->assertIsList($content); - - return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); - } - - private function collector(): MockCollector - { - $this->assertInstanceOf(MockCollector::class, self::$collector); - - return self::$collector; + return 'builder'; } } diff --git a/tests/E2E/StdioEndToEndTestCase.php b/tests/E2E/StdioEndToEndTestCase.php new file mode 100644 index 0000000..18f4616 --- /dev/null +++ b/tests/E2E/StdioEndToEndTestCase.php @@ -0,0 +1,469 @@ +stop(); + self::$collector = null; + FileTree::remove(self::$dir); + } + + #[\Override] + protected function tearDown(): void + { + $this->client?->process()->stop(0); + $this->client = null; + } + + /** One stdio session through the handshake; both events reach the collector by the time stdin closes. */ + #[Group('ac-02-53')] + #[MaximumDuration(15000)] + public function testAHandshakeSessionIsTrackedAndDeliveredAtEof(): void + { + $client = $this->start(); + $this->handshake($client); + $tools = $this->resultOf($client, 'tools/list', [])->tools ?? null; + $this->assertIsList($tools); + $this->assertSame('echo', JsonAssert::at($tools, 0, 'name')); + + $first = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); + $sessionId = $this->issuedSessionId($first); + $this->assertSame([$this->issued($sessionId), 'echo: one'], $this->texts($first)); + $second = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'two', 'session_id' => $sessionId]]); + $this->assertSame(['echo: two'], $this->texts($second), 'the issued session_id is recognized: no new block'); + + $this->assertExitsAtEof($client); + $events = $this->events(2); + foreach ($events as $event) { + $this->assertSame('e2e-stdio-server', JsonAssert::at($event, 'server_name')); + $this->assertSame('2.7.1', JsonAssert::at($event, 'server_version')); + $this->assertSame('stdio-client', JsonAssert::at($event, 'client_name')); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + } + $this->assertEqualsCanonicalizing(['one', 'two'], array_map( + static fn(\stdClass $event): mixed => JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments', 'msg'), + $events, + )); + } + + /** No SDK byte on STDOUT or STDERR: the log lines go to `$HOME/agentcat.log`. */ + #[Group('ac-03-37')] + #[Group('ac-01-87')] + #[MaximumDuration(15000)] + public function testStdoutCarriesOnlyJsonRpcAndTheSdkLogsToItsFile(): void + { + $client = $this->start(); + $this->handshake($client); + $this->resultOf($client, 'tools/list', []); + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]); + $this->resultOf($client, 'tools/call', ['name' => 'progress', 'arguments' => ['msg' => 'hi', 'session_id' => 'start'], '_meta' => ['progressToken' => 'p0']]); + + $this->assertExitsAtEof($client); + $this->events(2); + $this->assertStdoutIsOnlyJsonRpc($client); + $this->assertSame('', $client->process()->getErrorOutput()); + $log = $this->log(); + $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT_ID . ' | sdk mcp-sdk/' . (static::tracked() === 'server' ? 'server' : 'builder') . ' | signals ', $log); + $this->assertStringContainsString('AgentCat setup complete | project ' . self::PROJECT_ID . ' | tracing=true context=true reportMissing=true exporters=0', $log); + } + + /** The tool's progress notification suspends the handler's fiber in the middle of AgentCat's interceptor. */ + #[MaximumDuration(15000)] + public function testAFiberSuspendedInsideTheInterceptorIsAnsweredAndTrackedOnce(): void + { + $client = $this->start(); + $this->handshake($client); + $before = count($client->stdoutLines()); + + $result = $this->resultOf($client, 'tools/call', ['name' => 'progress', 'arguments' => ['msg' => 'x', 'session_id' => 'start'], '_meta' => ['progressToken' => 'p1']]); + + $sessionId = $this->issuedSessionId($result); + $this->assertSame([$this->issued($sessionId), 'done: x'], $this->texts($result)); + $messages = array_map(Json::decode(...), array_slice($client->stdoutLines(), $before)); + $this->assertCount(2, $messages, 'the notification, then the answer'); + $this->assertSame('notifications/progress', JsonAssert::at($messages[0], 'method')); + $this->assertSame('p1', JsonAssert::at($messages[0], 'params', 'progressToken')); + $this->assertSame($this->nextId - 1, JsonAssert::at($messages[1], 'id')); + + $this->assertExitsAtEof($client); + $event = $this->event(); + $this->assertSame('progress', JsonAssert::at($event, 'resource_name')); + $this->assertSame('done: x', JsonAssert::at($event, 'response', 'content', 0, 'text')); + $this->assertSame($sessionId, JsonAssert::at($event, 'session_id')); + } + + /** The pump at handler entry delivers an earlier call's event while stdin stays open. */ + #[MaximumDuration(15000)] + public function testAnEventIsDeliveredBetweenRequestsWhileStdinStaysOpen(): void + { + $client = $this->start(); + $this->handshake($client); + $sessionId = $this->issuedSessionId($this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call 1', 'session_id' => 'start']])); + + $calls = 1; + $delivered = null; + while (!$delivered instanceof \stdClass && $calls < self::MAX_CALLS_BEFORE_DELIVERY) { + ++$calls; + $answer = $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call ' . $calls, 'session_id' => $sessionId]]); + $this->assertSame(['echo: call ' . $calls], $this->texts($answer)); + $delivered = $this->eventWithMessage('call 1', self::BETWEEN_CALLS_SECONDS); + } + + $this->assertInstanceOf(\stdClass::class, $delivered, sprintf('the first event reached the collector within %d calls', $calls)); + $this->assertTrue($client->process()->isRunning(), 'stdin is still open'); + $this->assertSame($sessionId, JsonAssert::at($delivered, 'session_id')); + $this->assertExitsAtEof($client); + $this->assertCount($calls, $this->events($calls), 'every call is delivered by the end'); + } + + /** + * Stdin closes while the collector holds every delivery. The pump at each handler entry starts at most 5 + * deliveries, so 2 of the 7 events still wait when the 5 s shutdown budget ends. + */ + #[Group('ac-03-9')] + #[MaximumDuration(15000)] + public function testStdinClosingWhileTheCollectorIsSlowEndsWithinTheShutdownBudget(): void + { + $client = $this->start(['status' => 202, 'delayMs' => 8000]); + $this->handshake($client); + $sessionId = $this->issuedSessionId($this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call 1', 'session_id' => 'start']])); + for ($call = 2; $call <= 7; ++$call) { + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'call ' . $call, 'session_id' => $sessionId]]); + } + + $this->assertExitsAtEof($client, self::SLOW_COLLECTOR_EXIT_SECONDS); + $log = $this->log(); + $this->assertSame(1, substr_count($log, 'Shutting down with '), $log); + $this->assertMatchesRegularExpression('/\] Shutting down with 2 events still in queue$/m', $log); + $this->assertStdoutIsOnlyJsonRpc($client); + $this->assertSame('', $client->process()->getErrorOutput()); + } + + /** SIGTERM with stdin open: the drain delivers the event, then the default disposition ends the process. */ + #[Group('ac-03-10')] + #[RequiresPhpExtension('pcntl')] + #[RequiresPhpExtension('posix')] + #[MaximumDuration(15000)] + public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void + { + $client = $this->start(); + $this->handshake($client); + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => 'start']]); + $this->assertSame([], $this->collector()->requests($this->scenario), 'nothing pumped the event yet'); + + $sentAt = microtime(true); + $client->signal(self::SIGTERM); + $code = $client->waitForExit(self::SIGTERM_EXIT_SECONDS); + + $this->assertNotNull($code, sprintf('the process ended within %.1f s', self::SIGTERM_EXIT_SECONDS)); + $this->assertTrue($client->process()->hasBeenSignaled(), sprintf('exit code %d after %.3f s', $code, microtime(true) - $sentAt)); + $this->assertSame(self::SIGTERM, $client->process()->getTermSignal()); + $event = $this->event(); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + $this->assertStdoutIsOnlyJsonRpc($client); + } + + /** + * ac-06-62 in a real process: stdin EOF posts the diagnostics buffer exactly once, with the setup beacons and the + * per-event line logged during the EOF drain. + */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testStdinEofSendsTheDiagnosticsOnce(): void + { + $diagnostics = $this->collector()->scenario(); + $startedAt = microtime(true); + $client = $this->start(env: ['AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $diagnostics]); + $this->handshake($client); + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); + + $this->assertExitsAtEof($client); + + // The server lived less than the 2 s window, so no handler-entry pump and no shutdown pass could post the + // buffer early: the EOF flush is the only POST. Counted after the exit, so a late duplicate is seen too. + $lifetime = microtime(true) - $startedAt; + $this->assertLessThan(Diagnostics::BATCH_FLUSH_SECONDS, $lifetime, 'the server ended inside the 2 s window'); + $requests = $this->collector()->requests($diagnostics); + $this->assertCount(1, $requests, sprintf('one diagnostics POST; the server lived %.3f s', $lifetime)); + $this->assertSame('/v1/logs', $requests[0]->path); + $body = MockCollector::body($requests[0]); + $this->assertStringContainsString('"stringValue":"php"', $body); + $this->assertStringContainsString('AgentCat setup complete | project proj_', $body); + $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); + } + + /** 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::hasModernEra()) { + self::markTestSkipped('mcp/sdk 0.8.1 serves only the handshake era over stdio'); + } + $client = $this->start(); + + $result = $this->resultOf($client, 'tools/call', HttpHarness::modernParams( + ['name' => 'echo', 'arguments' => ['msg' => 'm']], + ['name' => 'modern-client', 'version' => '2.0.0'], + )); + + $sessionId = $this->issuedSessionId($result); + $this->assertSame([$this->issued($sessionId), 'echo: m'], $this->texts($result)); + $this->assertExitsAtEof($client); + $event = $this->event(); + $this->assertSame('modern-client', JsonAssert::at($event, 'client_name')); + $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, JsonAssert::at($event, 'tags', Constants::TAG_PROTOCOL_VERSION)); + $this->assertSame('echo', JsonAssert::at($event, 'resource_name')); + } + + /** + * Starts this test's server with its own collector scenario and HOME. `AGENTCAT_API_URL` and + * `AGENTCAT_DIAGNOSTICS_ENDPOINT` are closed local ports, so an event without the scenario can never reach the + * AgentCat API, and diagnostics, which stay off by the inherited `AGENTCAT_DISABLE_DIAGNOSTICS=true`, can never reach + * AgentCat's collector. + * + * @param array{status: int, delayMs?: int, body?: string} $default the collector's answer to every event + * @param array $env more environment variables of the server, applied last + */ + private function start(array $default = ['status' => 202, 'body' => '{"status":"accepted"}'], array $env = []): StdioMcpClient + { + $this->scenario = $this->collector()->scenario([], $default); + $this->home = self::$dir . '/home-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->home, 0o700)); + $this->client = StdioMcpClient::start(__DIR__ . '/Fixtures/stdio-server.php', [ + 'AGENTCAT_ENABLED' => 'true', + 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'AGENTCAT_E2E_COLLECTOR' => $this->scenario, + 'HOME' => $this->home, + 'AGENTCAT_E2E_TRACK' => static::tracked(), + ...$env, + ]); + + return $this->client; + } + + /** `initialize` and `notifications/initialized`, as an MCP host opens a stdio session. */ + private function handshake(StdioMcpClient $client): void + { + $result = $this->resultOf($client, 'initialize', [ + 'protocolVersion' => self::HANDSHAKE_PROTOCOL_VERSION, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'stdio-client', 'version' => '1.0.0'], + ]); + $this->assertSame(self::HANDSHAKE_PROTOCOL_VERSION, JsonAssert::at($result, 'protocolVersion')); + $client->send(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']); + } + + /** + * Sends one request and returns the `result` of its answer. + * + * @param array $params + */ + private function resultOf(StdioMcpClient $client, string $method, array $params): \stdClass + { + $id = $this->nextId++; + $client->send(['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params]); + + return HttpHarness::result($client->receive($id)); + } + + /** Closes stdin and waits for exit code 0. */ + private function assertExitsAtEof(StdioMcpClient $client, float $timeoutSeconds = self::EOF_EXIT_SECONDS): void + { + $closedAt = microtime(true); + $client->closeInput(); + $code = $client->waitForExit($timeoutSeconds); + $this->assertSame(0, $code, sprintf('exit within %.1f s of stdin EOF (%.3f s); stderr: %s', $timeoutSeconds, microtime(true) - $closedAt, $client->process()->getErrorOutput())); + } + + /** Every byte on stdout belongs to a JSON-RPC message, one per line. */ + private function assertStdoutIsOnlyJsonRpc(StdioMcpClient $client): void + { + $lines = $client->stdoutLines(); + $this->assertNotSame([], $lines); + $this->assertSame(implode("\n", $lines) . "\n", $client->process()->getOutput(), 'stdout holds whole lines only'); + foreach ($lines as $line) { + $this->assertJson($line); + $message = Json::decode($line); + $this->assertInstanceOf(\stdClass::class, $message, $line); + $this->assertSame('2.0', $message->jsonrpc ?? null, $line); + } + } + + /** + * Waits until the collector holds `$count` events of this test's scenario and returns their decoded bodies. + * + * @return list<\stdClass> + */ + private function events(int $count): array + { + $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; + do { + $requests = $this->collector()->requests($this->scenario); + if (count($requests) >= $count) { + break; + } + usleep(20_000); + } while (microtime(true) < $deadline); + $this->assertCount($count, $requests, 'POSTs to the collector'); + + return array_map(function (\stdClass $request): \stdClass { + $this->assertSame('POST', $request->method); + $body = $request->body; + $this->assertIsString($body); + $event = Json::decode($body); + $this->assertInstanceOf(\stdClass::class, $event, $body); + + return $event; + }, $requests); + } + + /** Waits for the one event of this test's scenario and returns its decoded body. */ + private function event(): \stdClass + { + $event = $this->events(1)[0] ?? null; + $this->assertInstanceOf(\stdClass::class, $event); + + return $event; + } + + /** The collector's event whose call had `msg` `$msg`, waiting up to `$deadlineSeconds`; null if none came. */ + private function eventWithMessage(string $msg, float $deadlineSeconds): ?\stdClass + { + $deadline = microtime(true) + $deadlineSeconds; + do { + foreach ($this->collector()->requests($this->scenario) as $request) { + $event = is_string($request->body ?? null) ? Json::decode($request->body) : null; + if ($event instanceof \stdClass && JsonAssert::at($event, 'parameters', 'request', 'params', 'arguments', 'msg') === $msg) { + return $event; + } + } + usleep(20_000); + } while (microtime(true) < $deadline); + + return null; + } + + /** The server's agentcat.log. */ + private function log(): string + { + $log = file_get_contents($this->home . '/agentcat.log'); + $this->assertIsString($log); + + return $log; + } + + /** The `ses_` ID of the issued block at the start of an answer. */ + private function issuedSessionId(\stdClass $result): string + { + $text = JsonAssert::at($result, 'content', 0, 'text'); + $this->assertIsString($text); + $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $text, $match), $text); + + return $match[1]; + } + + private function issued(string $sessionId): string + { + return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); + } + + /** @return list the `text` of every content block */ + private function texts(\stdClass $result): array + { + $content = $result->content ?? null; + $this->assertIsList($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index bf770c9..e172534 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -33,12 +33,17 @@ use AgentCat\Tests\Support\McpSdk\HttpHarness; use AgentCat\Tests\Support\McpSdk\Tools; use AgentCat\Tests\Support\McpSdk\TrackingFixture; +use AgentCat\Tests\Support\Members; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingExporter; use AgentCat\Tests\Support\RecordingHttpChannel; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; +use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Server; use Mcp\Server\Builder; use PHPUnit\Framework\Attributes\DataProvider; @@ -199,7 +204,6 @@ public function testTheCompleteLineShowsTheOptions(): void public static function unsupportedServers(): iterable { yield 'any object' => [static fn(): object => new \stdClass(), '(none)']; - yield 'a built mcp/sdk server whose Builder is not tracked' => [static fn(): object => Server::builder()->build(), 'mcpSdkServer']; } /** @param \Closure(): object $make */ @@ -226,20 +230,142 @@ public function testTheSetupLineNamesTheLibraryOfTheServer(): void } #[Group('ac-01-2')] - public function testABuiltBuilderIsLeftAlone(): void + /** @return iterable */ + public static function builtObjects(): iterable + { + yield 'the Builder after build()' => [true]; + yield 'the built server' => [false]; + } + + /** track() after build(): the server lists AgentCat's parameters and gives one event per call. */ + #[DataProvider('builtObjects')] + public function testAServerTrackedAfterBuildIsTracked(bool $trackTheBuilder): void { $builder = TrackingFixture::builder(); Tools::echo($builder); $server = $builder->build(); + $tracked = $trackTheBuilder ? $builder : $server; - $this->assertSame($builder, $this->client->track($builder, self::PROJECT)); + $this->assertSame($tracked, $this->client->track($tracked, self::PROJECT)); $this->assertSame([ - 'AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, - 'Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: the mcp/sdk Builder was already built. ' . McpSdkAdapter::SUPPORT_MATRIX, + 'AgentCat setup started | project ' . self::PROJECT . ' | sdk ' . ($trackTheBuilder ? 'mcp-sdk/builder | signals mcpSdkBuilder,built' : 'mcp-sdk/server | signals mcpSdkServer'), + 'AgentCat setup complete | project ' . self::PROJECT . ' | tracing=true context=true reportMissing=true exporters=0', ], $this->logger->messages()); - $this->assertSame(['echo'], $this->names($this->initialized($server)->listTools())); - $this->assertSame(['echo'], $this->names($this->harness($builder)->listTools()), 'no AgentCat handler was added'); + $harness = $this->initialized($server); + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($harness->listTools())); + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'context' => 'why'])), 'content', 1, 'text')); + $this->assertTrue($this->queue->flush()); + $this->assertCount(1, $this->exporter->events); + $this->assertSame(TrackedServers::get($tracked), Libraries::trackedServer($trackTheBuilder ? $server : $builder), 'the other object is the same tracked server'); + } + + /** + * After build(), AgentCat's tool handler would run inside the customer's reference handler, which could run each + * tool twice: such a server is tracked only before build(), and its tools answer as before. + */ + public function testAServerWithAReferenceHandlerOfItsOwnIsNotTrackedAfterBuild(): void + { + $builder = TrackingFixture::builder()->setReferenceHandler(new readonly class implements ReferenceHandlerInterface { + /** @param array $arguments */ + #[\Override] + public function handle(ElementReference $reference, array $arguments): mixed + { + return (new ReferenceHandler())->handle($reference, $arguments); + } + }); + Tools::echo($builder); + $server = $builder->build(); + + $this->assertSame($server, $this->client->track($server, self::PROJECT)); + + $messages = $this->logger->messages(); + $this->assertSame('AgentCat SDK Compatibility: ' . McpSdkAdapter::SUPPORT_MATRIX, $messages[0] ?? null); + $this->assertStringStartsWith('Warning: Failed to track server - UnexpectedValueException: AgentCat SDK compatibility error: the mcp/sdk server has a reference handler of its own (', $messages[1] ?? ''); + $this->assertStringContainsString('): track the Builder before build(). ' . McpSdkAdapter::SUPPORT_MATRIX, $messages[1] ?? ''); + $this->assertCount(2, $messages); + $harness = $this->initialized($server); + $this->assertSame(['echo'], $this->names($harness->listTools())); + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($harness->callTool('echo', ['msg' => 'hi'])), 'content', 0, 'text')); + } + + /** A registry entry that is not a tool: AgentCat changes nothing, and the server answers untracked. */ + public function testAServerWhoseRegistryCannotBeReadIsReturnedUntracked(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $server = $builder->build(); + $parts = BuilderMembers::reader('parts')($builder); + $this->assertIsArray($parts); + $registry = $parts['registry'] ?? null; + $this->assertInstanceOf(Registry::class, $registry); + $registry->load(); + $tools = Members::reader(Registry::class, 'tools')($registry); + $this->assertIsArray($tools); + Members::writer(Registry::class, 'tools')($registry, $tools + ['broken' => 'not a tool']); + + $this->assertSame($server, $this->client->track($server, self::PROJECT)); + + $this->assertSame([ + 'AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/server | signals mcpSdkServer', + 'Warning: Failed to track server - UnexpectedValueException: the mcp/sdk registry holds a tool AgentCat cannot read (string).', + ], $this->logger->messages()); + $this->assertNull(Libraries::trackedServer($server), 'nothing is tracked'); + Members::writer(Registry::class, 'tools')($registry, $tools); // the member is back + $harness = $this->initialized($server); + $this->assertSame(['echo'], $this->names($harness->listTools())); + } + + /** A Builder tracked after buildStateless(): the StatelessProtocol shares its handlers and registry (D76). */ + public function testABuilderTrackedAfterBuildStatelessIsTracked(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $protocol = $builder->buildStateless(); + + $this->client->track($builder, self::PROJECT); + + $call = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => HttpHarness::modernParams(['name' => 'echo', 'arguments' => ['msg' => 'hi', 'context' => 'why']], ['name' => 'modern-client', 'version' => '2.0.0'])]; + $answer = json_decode($protocol->handle(json_encode($call, JSON_THROW_ON_ERROR), HttpHarness::modernHeaders('tools/call', 'echo'))->toJson(), flags: JSON_THROW_ON_ERROR); + $this->assertSame('echo: hi', JsonAssert::at($answer, 'result', 'content', 1, 'text'), 'after the mint-back text'); + $this->assertTrue($this->queue->flush()); + $this->assertCount(1, $this->exporter->events); + $this->assertSame('why', $this->exporter->events[0]->userIntent ?? null); + } + + /** Each server tracked after build() keeps its tracking state when another one is tracked: its Builder finds it. */ + public function testTwoServersTrackedAfterBuildStayTracked(): void + { + $builder = TrackingFixture::builder(); + $first = $builder->build(); + $second = TrackingFixture::builder()->build(); + + $this->client->track($first, self::PROJECT); + $this->client->track($second, self::PROJECT); + $this->client->track($builder, self::PROJECT); + + $this->assertSame(['[SESSION DEBUG] track() - Server already being tracked, skipping initialization'], array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_starts_with($line, '[SESSION DEBUG]')))); + $this->assertSame(Libraries::trackedServer($first), Libraries::trackedServer($builder)); + $this->assertNotSame(Libraries::trackedServer($first), Libraries::trackedServer($second)); + } + + /** Tracking the Builder and the server built from it, in any order, tracks one server once. */ + #[DataProvider('builtObjects')] + public function testTrackingTheOtherObjectAgainChangesNothing(bool $builderFirst): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $server = $builder->build(); + + $this->client->track($builderFirst ? $builder : $server, self::PROJECT); + $this->client->track($builderFirst ? $server : $builder, self::PROJECT); + + $this->assertContains('[SESSION DEBUG] track() - Server already being tracked, skipping initialization', $this->logger->messages()); + $harness = $this->initialized($server); + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($harness->listTools())); + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why']); + $this->assertTrue($this->queue->flush()); + $this->assertCount(1, $this->exporter->events); } /** When `Builder::$parts` cannot be read, track() returns the Builder untracked, and the lines name the member. */ @@ -279,7 +405,7 @@ public function testAFailureWhileInstallingLeavesTheBuilderUntouched(): void Tools::echo($builder); // The member becomes unreadable after the probe, while AgentCat logs its start line: install() then fails. $remove = static function () use ($builder): void { - BuilderMembers::uninitialize('referenceHandler')($builder); + BuilderMembers::uninitialize('requestHandlers')($builder); }; $logger = new class ($remove) extends AbstractLogger { /** @var list */ @@ -304,10 +430,10 @@ public function log($level, string|\Stringable $message, array $context = []): v $this->assertSame([ 'AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', - 'Warning: Failed to track server - Error: Typed property Mcp\Server\Builder::$referenceHandler must not be accessed before initialization', + 'Warning: Failed to track server - Error: Typed property Mcp\Server\Builder::$requestHandlers must not be accessed before initialization', ], $logger->lines); - $this->assertNull(BuilderAccess::container($builder), 'AgentCat set no container'); - $harness = $this->harness($builder); // build() reads the missing member with ??, which tolerates it + BuilderMembers::writer('requestHandlers')($builder, []); // the member is back + $harness = $this->harness($builder); $this->assertSame(['echo'], $this->names($harness->listTools())); $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($harness->callTool('echo', ['msg' => 'hi'])), 'content', 0, 'text')); $this->assertTrue($this->queue->flush()); @@ -652,7 +778,7 @@ public function testTheBuildersLoggerReceivesTheCompatibilityLine(): void Log::reset(); $builderLogger = new MemoryLogger(); $builder = TrackingFixture::builder()->setLogger($builderLogger); - $builder->build(); + BuilderMembers::uninitialize('parts')($builder); $this->client->track($builder, self::PROJECT); diff --git a/tests/Integration/McpSdk/CallErrorsAfterBuildTest.php b/tests/Integration/McpSdk/CallErrorsAfterBuildTest.php new file mode 100644 index 0000000..90582ac --- /dev/null +++ b/tests/Integration/McpSdk/CallErrorsAfterBuildTest.php @@ -0,0 +1,17 @@ +fixture = new TrackingFixture(); - } - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - #[Group('ac-02-23')] - #[Group('ac-04-36')] - #[Group('ac-05-E5')] - public function testAToolCallExceptionIsAnErrorResultAndTheEventKeepsTheException(): void - { - $twin = HttpHarness::result($this->fixture->harness($this->builder())->callTool('fails', [])); - $harness = $this->tracked(); - - $result = HttpHarness::result($harness->callTool('fails', ['session_id' => 'start'])); - - $event = $this->fixture->event(); - $this->assertTrue(JsonAssert::at($result, 'isError')); - $this->assertSame([$this->issued($event->sessionId), 'no luck'], $this->texts($result)); - $this->assertSameAsTheTwinAfterTheIssuedBlock($twin, $result); - $this->assertTrue($event->isError); - $this->assertSame(\Mcp\Exception\ToolCallException::class, JsonAssert::at($event->error, 'type')); - $this->assertSame('no luck', JsonAssert::at($event->error, 'message')); - $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', [])); - $harness = $this->tracked(); - - $result = HttpHarness::result($harness->callTool('refuses', ['session_id' => 'start'])); - - $event = $this->fixture->event(); - $this->assertSame([$this->issued($event->sessionId), 'not allowed'], $this->texts($result)); - $this->assertSameAsTheTwinAfterTheIssuedBlock($twin, $result); - $this->assertTrue($event->isError); - $this->assertSame('{"message":"not allowed","platform":"php"}', $this->json($event->error), 'from the result text: nothing was thrown'); - $this->assertSame($this->json($twin), $this->json($event->response)); - } - - #[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', []); - $harness = $this->tracked(); - - $answer = $harness->callTool('boom', ['session_id' => 'start']); - - $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $answer->error); - $this->assertEquals($twin->error, $answer->error); - $this->assertObjectNotHasProperty('result', $answer, 'no mint-back on a JSON-RPC error'); - $event = $this->fixture->event(); - $this->assertTrue($event->isError); - $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); - $this->assertSame('tool boom', JsonAssert::at($event->error, 'message')); - $this->assertSame('LogicException', JsonAssert::at($event->error, 'chained_errors', 0, 'type')); - $this->assertNull($event->response); - } - - #[Group('ac-02-26')] - #[Group('ac-05-E6')] - public function testAnUnknownToolGivesTheLibrarysErrorAndAnErrorEvent(): void - { - $harness = $this->tracked(); - - $answer = $harness->callTool('nope', ['session_id' => 'start']); - - $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'Tool not found: "nope".'], $answer->error); - $event = $this->fixture->event(); - $this->assertSame('nope', $event->resourceName); - $this->assertTrue($event->isError); - $this->assertSame('{"message":"Tool not found: \"nope\".","platform":"php"}', $this->json($event->error)); - } - - #[Group('ac-05-E6')] - public function testInvalidArgumentsGiveTheLibrarysErrorAndAnErrorEvent(): void - { - $harness = $this->tracked(); - - $answer = $harness->callTool('echo', ['context' => 'why', 'session_id' => 'start']); - - $this->assertSame(Error::INVALID_PARAMS, JsonAssert::at($answer, 'error', 'code')); - $message = JsonAssert::at($answer, 'error', 'message'); - $this->assertIsString($message); - $this->assertStringStartsWith("Invalid parameters for tool 'echo'", $message); - $event = $this->fixture->event(); - $this->assertTrue($event->isError); - $this->assertSame($message, JsonAssert::at($event->error, 'message')); - } - - #[Group('ac-05-E7')] - #[Group('ac-01-80')] - public function testASuccessfulCallHasNoError(): void - { - $harness = $this->tracked(); - - $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); - - $event = $this->fixture->event(); - $this->assertNull($event->isError); - $this->assertNull($event->error); - } - - #[Group('ac-02-29')] - public function testACallAfterAFailedOneIsNormal(): void - { - $harness = $this->tracked(); - - $harness->callTool('boom', ['session_id' => 'start']); - $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])); - - $failed = $this->fixture->event(0, of: 2); - $normal = $this->fixture->event(1, of: 2); - $this->assertTrue($failed->isError); - $this->assertSame([$this->issued($normal->sessionId), 'echo: hi'], $this->texts($result)); - $this->assertNull($normal->isError); - $this->assertNull($normal->error); - $this->assertSame('minted', $normal->tags[Constants::TAG_SESSION_SOURCE] ?? null); - } - - /** A tracked server with `echo`, `fails` and `boom` that has listed its tools. */ - private function tracked(): HttpHarness - { - $builder = $this->builder(); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - return $harness; - } - - private function builder(): Builder - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - Tools::fails($builder); - Tools::boom($builder); - Tools::refuses($builder); - - return $builder; - } - - private function issued(string $sessionId): string - { - return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); - } - - /** The tracked answer is the untracked one with the issued block first. */ - private function assertSameAsTheTwinAfterTheIssuedBlock(\stdClass $twin, \stdClass $result): void - { - $content = $result->content ?? null; - $this->assertIsArray($content); - $this->assertSame($this->json($twin->content ?? null), $this->json(array_slice($content, 1))); - $this->assertSame($twin->isError ?? null, $result->isError ?? null); - } - - /** @return list */ - private function texts(\stdClass $result): array - { - $content = $result->content ?? null; - $this->assertIsList($content); - - return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); - } - - private function json(mixed $value): string + protected static function entry(): Entry { - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/CallErrorsTestCase.php b/tests/Integration/McpSdk/CallErrorsTestCase.php new file mode 100644 index 0000000..2b96759 --- /dev/null +++ b/tests/Integration/McpSdk/CallErrorsTestCase.php @@ -0,0 +1,258 @@ +afterBuild()) { + self::markTestSkipped('Tracked after build(): ' . $why); + } + } + + private TrackingFixture $fixture; + + #[\Override] + protected function setUp(): void + { + $this->fixture = new TrackingFixture(static::entry()); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-02-23')] + #[Group('ac-04-36')] + #[Group('ac-05-E5')] + public function testAToolCallExceptionIsAnErrorResultAndTheEventKeepsTheException(): void + { + $twin = HttpHarness::result($this->fixture->harness($this->builder())->callTool('fails', [])); + $harness = $this->tracked(); + + $result = HttpHarness::result($harness->callTool('fails', ['session_id' => 'start'])); + + $event = $this->fixture->event(); + $this->assertTrue(JsonAssert::at($result, 'isError')); + $this->assertSame([$this->issued($event->sessionId), 'no luck'], $this->texts($result)); + $this->assertSameAsTheTwinAfterTheIssuedBlock($twin, $result); + $this->assertTrue($event->isError); + $this->assertSame(\Mcp\Exception\ToolCallException::class, JsonAssert::at($event->error, 'type')); + $this->assertSame('no luck', JsonAssert::at($event->error, 'message')); + $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', [])); + $harness = $this->tracked(); + + $result = HttpHarness::result($harness->callTool('refuses', ['session_id' => 'start'])); + + $event = $this->fixture->event(); + $this->assertSame([$this->issued($event->sessionId), 'not allowed'], $this->texts($result)); + $this->assertSameAsTheTwinAfterTheIssuedBlock($twin, $result); + $this->assertTrue($event->isError); + $this->assertSame('{"message":"not allowed","platform":"php"}', $this->json($event->error), 'from the result text: nothing was thrown'); + $this->assertSame($this->json($twin), $this->json($event->response)); + } + + #[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', []); + $harness = $this->tracked(); + + $answer = $harness->callTool('boom', ['session_id' => 'start']); + + $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $answer->error); + $this->assertEquals($twin->error, $answer->error); + $this->assertObjectNotHasProperty('result', $answer, 'no mint-back on a JSON-RPC error'); + $event = $this->fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('RuntimeException', JsonAssert::at($event->error, 'type')); + $this->assertSame('tool boom', JsonAssert::at($event->error, 'message')); + $this->assertSame('LogicException', JsonAssert::at($event->error, 'chained_errors', 0, 'type')); + $this->assertNull($event->response); + } + + #[Group('ac-02-26')] + #[Group('ac-05-E6')] + public function testAnUnknownToolGivesTheLibrarysErrorAndAnErrorEvent(): void + { + $this->beforeBuildOnly('a call to an unknown tool'); + $harness = $this->tracked(); + + $answer = $harness->callTool('nope', ['session_id' => 'start']); + + $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'Tool not found: "nope".'], $answer->error); + $event = $this->fixture->event(); + $this->assertSame('nope', $event->resourceName); + $this->assertTrue($event->isError); + $this->assertSame('{"message":"Tool not found: \"nope\".","platform":"php"}', $this->json($event->error)); + } + + #[Group('ac-05-E6')] + public function testInvalidArgumentsGiveTheLibrarysErrorAndAnErrorEvent(): void + { + $twin = $this->fixture->harness($this->builder())->callTool('echo', ['msg' => 7]); + $harness = $this->tracked(); + + $answer = $harness->callTool('echo', ['msg' => 7, 'context' => 'why', 'session_id' => 'start']); + + $this->assertSame(Error::INVALID_PARAMS, JsonAssert::at($answer, 'error', 'code')); + $message = JsonAssert::at($answer, 'error', 'message'); + $this->assertIsString($message); + $this->assertStringStartsWith("Invalid parameters for tool 'echo'", $message); + $this->assertEquals($twin->error, $answer->error, 'the error the server gives without AgentCat'); + if (static::entry()->afterBuild()) { + $this->assertSame(0, $this->fixture->queue->stats()['queueLength'], 'the library answers before any tool runs (D77)'); + + return; + } + $event = $this->fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame($message, JsonAssert::at($event->error, 'message')); + } + + #[Group('ac-05-E7')] + #[Group('ac-01-80')] + public function testASuccessfulCallHasNoError(): void + { + $harness = $this->tracked(); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $event = $this->fixture->event(); + $this->assertNull($event->isError); + $this->assertNull($event->error); + } + + #[Group('ac-02-29')] + public function testACallAfterAFailedOneIsNormal(): void + { + $harness = $this->tracked(); + + $harness->callTool('boom', ['session_id' => 'start']); + $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])); + + $failed = $this->fixture->event(0, of: 2); + $normal = $this->fixture->event(1, of: 2); + $this->assertTrue($failed->isError); + $this->assertSame([$this->issued($normal->sessionId), 'echo: hi'], $this->texts($result)); + $this->assertNull($normal->isError); + $this->assertNull($normal->error); + $this->assertSame('minted', $normal->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + + /** A tracked server with `echo`, `fails` and `boom` that has listed its tools. */ + private function tracked(): HttpHarness + { + $builder = $this->builder(); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + return $harness; + } + + /** The customer's PSR-3 logger gets the library's one error line for a failing tool, with the tool's exception. */ + public function testTheBuildersLoggerGetsTheToolsExceptionOnce(): void + { + $logger = new class extends AbstractLogger { + /** @var list */ + public array $records = []; + + /** @param mixed[] $context */ + #[\Override] + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message, 'context' => $context]; + } + }; + $builder = $this->builder()->setLogger($logger); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('boom', ['session_id' => 'start']); + + $errors = array_values(array_filter($logger->records, static fn(array $record): bool => $record['level'] === LogLevel::ERROR)); + $this->assertSame(['Unhandled error during tool execution'], array_column($errors, 'message')); + $exception = $errors[0]['context']['exception'] ?? null; + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertSame('tool boom', $exception->getMessage()); + } + + private function builder(): Builder + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + Tools::fails($builder); + Tools::boom($builder); + Tools::refuses($builder); + + return $builder; + } + + private function issued(string $sessionId): string + { + return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); + } + + /** The tracked answer is the untracked one with the issued block first. */ + private function assertSameAsTheTwinAfterTheIssuedBlock(\stdClass $twin, \stdClass $result): void + { + $content = $result->content ?? null; + $this->assertIsArray($content); + $this->assertSame($this->json($twin->content ?? null), $this->json(array_slice($content, 1))); + $this->assertSame($twin->isError ?? null, $result->isError ?? null); + } + + /** @return list */ + private function texts(\stdClass $result): array + { + $content = $result->content ?? null; + $this->assertIsList($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Integration/McpSdk/CallToolAfterBuildTest.php b/tests/Integration/McpSdk/CallToolAfterBuildTest.php new file mode 100644 index 0000000..8ff7076 --- /dev/null +++ b/tests/Integration/McpSdk/CallToolAfterBuildTest.php @@ -0,0 +1,17 @@ +fixture = new TrackingFixture(); - } - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - #[Group('ac-02-16')] - #[Group('ac-04-29')] - #[Group('ac-02-47')] - public function testTheToolGetsItsOwnArgumentsAndTheEventRecordsTheCall(): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); - - $this->assertSame([['msg' => 'hi']], $echo->calls); - $this->assertSame('{"content":[{"type":"text","text":"echo: hi"}],"isError":false}', $this->json(HttpHarness::result($answer))); - $event = $this->fixture->event(); - $this->assertSame('echo', $event->resourceName); - $this->assertSame(Constants::TOOLS_CALL_EVENT_TYPE, $event->eventType); - $this->assertSame( - '{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","context":"why","session_id":"' . self::SESSION . '"}}}', - $this->json(JsonAssert::at($event->parameters, 'request')), - ); - $this->assertSame('why', $event->userIntent); - $this->assertSame(self::SESSION, $event->sessionId); - $this->assertSame('supplied', $this->tag($event, Constants::TAG_SESSION_SOURCE)); - $this->assertSame(['harness-server', '1.2.3'], [$event->serverName, $event->serverVersion]); - $this->assertSame(['harness-client', '1.0.0'], [$event->clientName, $event->clientVersion]); - $this->assertSame(TrackingFixture::PROJECT, $event->projectId); - $this->assertNull($event->isError); - $this->assertNull($event->error); - } - - #[Group('ac-02-17')] - public function testAToolThatAllowsNoExtraPropertiesStillAcceptsTheInjectedOnes(): void - { - $builder = TrackingFixture::builder(); - $strict = Tools::strict($builder); - $this->fixture->track($builder, new Options(enableAgentTracking: true)); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $answer = $harness->callTool('strict', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION, 'agent_id' => 'a1']); - - $this->assertFalse(JsonAssert::at(HttpHarness::result($answer), 'isError')); - $this->assertSame([['msg' => 'hi']], $strict->calls); - } - - #[Group('ac-02-18')] - public function testArgumentsAgentCatDidNotInjectReachTheTool(): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $harness->callTool('echo', ['msg' => 'hi', 'extra_key' => 'x', 'context' => 'why', 'session_id' => 'start']); - - $this->assertSame([['msg' => 'hi', 'extra_key' => 'x']], $echo->calls); - } - - #[Group('ac-02-19')] - #[Group('ac-04-13')] - public function testAToolThatDeclaresSessionIdKeepsIt(): void - { - $builder = TrackingFixture::builder(); - $own = Tools::own($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $harness->callTool('own', ['session_id' => 'mine', 'context' => 'why']); - - $this->assertSame([['session_id' => 'mine']], $own->calls); - $event = $this->fixture->event(); - $this->assertSame('', $event->sessionId); - $this->assertSame('foreign', $this->tag($event, Constants::TAG_SESSION_SOURCE)); - } - - #[Group('ac-02-20')] - public function testAServerThatNeverListedRebuildsItsRegistriesAndStripsExactly(): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $own = Tools::own($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - - $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); - $rebuilt = array_filter($this->fixture->lines(), static fn(string $line): bool => $line === 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'); - $this->assertCount(1, $rebuilt, 'the first call rebuilds, the second finds the registries'); - $this->assertCount(2, $this->fixture->events()); - } - - #[Group('ac-02-21')] - public function testAFailedRebuildStripsHeuristicallyAndIsTriedAgain(): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $this->fixture->track($builder); - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static function (): never { - throw new \RuntimeException('list failed'); - })); - $harness = $this->fixture->harness($builder); - - $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']); - $harness->callTool('echo', ['msg' => 'again']); - - $this->assertSame([['msg' => 'hi'], ['msg' => 'again']], $echo->calls); - $warning = 'Warning: registry rebuild-on-demand failed - RuntimeException: list failed'; - $this->assertSame([$warning, $warning], $this->fixture->lines()); - $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->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. */ - #[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->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'both tools were listed: no rebuild'); - } - - /** - * 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')] - 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->assertSame([self::REBUILT], $this->fixture->lines()); - } - - /** - * 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->assertSame([self::REBUILT], $this->fixture->lines()); - } - - /** - * 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')] - #[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->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}> */ - public static function sessionlessArguments(): iterable - { - yield 'no session_id' => [['msg' => 'hi']]; - yield 'start' => [['msg' => 'hi', 'session_id' => 'start']]; - yield 'START with spaces' => [['msg' => 'hi', 'session_id' => ' START ']]; - } - - /** @param array $arguments */ - #[DataProvider('sessionlessArguments')] - #[Group('ac-02-30')] - #[Group('ac-04-35')] - public function testANewSessionIsIssuedInTheFirstContentBlock(array $arguments): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('echo', $arguments)); - - $event = $this->fixture->event(); - $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $event->sessionId); - $this->assertSame([$this->issued($event->sessionId), 'echo: hi'], $this->texts($result)); - $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE)); - $this->assertSame([['msg' => 'hi']], $echo->calls); - } - - #[Group('ac-02-31')] - #[Group('ac-04-37')] - public function testAnUnrecognizedSessionIsReportedInTheFirstContentBlock(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'not-a-real-id'])); - - $this->assertSame([Constants::MINT_BACK_HEADER_UNRECOGNIZED . "\n" . Constants::MINT_BACK_UNRECOGNIZED_BODY, 'echo: hi'], $this->texts($result)); - $event = $this->fixture->event(); - $this->assertSame('', $event->sessionId); - $this->assertSame('invalid', $this->tag($event, Constants::TAG_SESSION_SOURCE)); - } - - #[Group('ac-02-36')] - #[Group('ac-02-38')] - #[Group('ac-04-39')] - public function testStructuredOutputStartsWithTheIssuedSession(): void - { - $builder = TrackingFixture::builder(); - Tools::structured($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); - - $event = $this->fixture->event(); - $structured = JsonAssert::at($result, 'structuredContent'); - $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys($structured)); - $this->assertSame( - $this->json(['session_id' => $event->sessionId, 'status' => 'issued']), - $this->json(JsonAssert::at($structured, Constants::MCP_SESSION_KEY)), - ); - $this->assertSame('{"echoed":"x"}', $this->json(JsonAssert::at($event->response, 'structuredContent')), 'the mirror is wire-only'); - } - - #[Group('ac-02-37')] - #[Group('ac-04-39')] - public function testStructuredOutputEchoesTheAgentAndTheActiveSession(): void - { - $builder = TrackingFixture::builder(); - Tools::structured($builder); - $this->fixture->track($builder, new Options(enableAgentTracking: true)); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('structured', ['session_id' => self::SESSION, 'agent_id' => 'a1'])); - - $this->assertSame( - $this->json(['session_id' => self::SESSION, 'agent_id' => 'a1', 'status' => 'active']), - $this->json(JsonAssert::at($result, 'structuredContent', Constants::MCP_SESSION_KEY)), - ); - $this->assertSame(['x'], $this->texts($result), 'a supplied session announces nothing'); - $this->assertSame('a1', $this->tag($this->fixture->event(), Constants::TAG_AGENT_ID)); - } - - /** - * mcp/sdk cannot tell `{}` from `[]`, so an empty structuredContent is an empty object to the mirror and to the - * event. On 0.9.0 and main, mcp/sdk itself rejects `[]` against an object outputSchema first. - */ - public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void - { - $builder = TrackingFixture::builder(); - Tools::structured($builder, []); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); - - $event = $this->fixture->event(); - if (McpSdkVersion::hasModernEra()) { - $this->assertTrue(JsonAssert::at($result, 'isError')); - $texts = $this->texts($result); - $this->assertCount(2, $texts); - $this->assertSame($this->issued($event->sessionId), $texts[0]); - $this->assertIsString($texts[1]); - $this->assertStringStartsWith("Invalid structured output for tool 'structured'", $texts[1]); - $this->assertObjectNotHasProperty('structuredContent', $result); - $this->assertTrue($event->isError); - - return; - } - $structured = JsonAssert::at($result, 'structuredContent'); - $this->assertSame([Constants::MCP_SESSION_KEY], $this->keys($structured)); - $this->assertSame('{}', $this->json(JsonAssert::at($event->response, 'structuredContent'))); - $this->assertNull($event->isError); - } - - #[Group('ac-02-40')] - #[Group('ac-04-46')] - public function testGetMoreToolsIsAnsweredByAgentCat(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $customer = new CustomerHandler(CallToolRequest::class, static fn(): CallToolResult => new CallToolResult([new TextContent('customer')])); - $builder->addRequestHandler($customer); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'need X'])); - - $event = $this->fixture->event(); - $this->assertSame([$this->issued($event->sessionId), $this->cannedText()], $this->texts($result)); - $this->assertSame(0, $customer->calls, 'no customer code runs'); - $this->assertSame(GetMoreTools::NAME, $event->resourceName); - $this->assertSame('need X', $event->userIntent); - $this->assertContains('Missing tool reported (context length: 6)', $this->fixture->lines()); - } - - #[Group('ac-02-41')] - #[Group('ac-04-48')] - public function testWithReportMissingOffGetMoreToolsIsAnUnknownTool(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder, new Options(enableReportMissing: false)); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $answer = $harness->callTool(GetMoreTools::NAME, ['context' => 'need X']); - - $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'Tool not found: "get_more_tools".'], $answer->error); - $event = $this->fixture->event(); - $this->assertTrue($event->isError); - $this->assertSame('Tool not found: "get_more_tools".', JsonAssert::at($event->error, 'message')); - } - - #[Group('ac-02-42')] - #[Group('ac-01-15')] - public function testWithTracingOffNothingIsPublishedOrStripped(): void - { - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder); - $this->fixture->track($builder, new Options(enableTracing: false)); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $more = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'need X'])); - $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why']); - - $this->assertSame([$this->cannedText()], $this->texts($more)); - $this->assertSame([['msg' => 'hi', 'context' => 'why']], $echo->calls, 'B06 parity'); - $this->assertSame([], $this->fixture->events()); - } - - #[Group('ac-01-20')] - #[Group('ac-01-23')] - public function testAgentTrackingWithoutAnAgentIdAddsNoAgentTags(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder, new Options(enableAgentTracking: true)); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); - - $this->assertSame(['echo: hi'], $this->texts(HttpHarness::result($answer))); - $event = $this->fixture->event(); - $this->assertSame('why', $event->userIntent); - $this->assertSame([Constants::TAG_SESSION_SOURCE => 'supplied'], $event->tags); - } - - /** Integer-like keys and nested empty objects reach the tool as mcp/sdk decodes them. */ - public function testTheToolGetsTheSameArrayAsWithoutAgentCat(): void - { - $properties = ['0' => ['type' => 'string'], '123' => ['type' => 'string'], 'nested' => ['type' => 'object']]; - $arguments = ['msg' => 'm', '0' => 'zero', '123' => 'x', 'nested' => ['a' => new \stdClass()]]; - $twinBuilder = TrackingFixture::builder(); - $twin = Tools::echo($twinBuilder, $properties); - $this->fixture->harness($twinBuilder)->callTool('echo', $arguments); - $builder = TrackingFixture::builder(); - $echo = Tools::echo($builder, $properties); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $harness->callTool('echo', $arguments + ['session_id' => 'start']); - - $this->assertCount(1, $twin->calls); - $this->assertSame($twin->calls, $echo->calls); - $this->assertSame(['msg', '0', '123', 'nested', 'session_id'], $this->keys(JsonAssert::at($this->fixture->event()->parameters, 'request', 'params', 'arguments'))); - } - - /** A reference handler set after track() replaces AgentCat's; only the stashed exception is lost. */ - public function testAReferenceHandlerSetAfterTrackStillGivesEvents(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - Tools::boom($builder); - $this->fixture->track($builder); - $theirs = $this->countingReferenceHandler(); - $builder->setReferenceHandler($theirs); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $echo = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); - $boom = $harness->callTool('boom', ['session_id' => self::SESSION]); - - $this->assertSame(['echo: hi'], $this->texts(HttpHarness::result($echo))); - $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $boom->error); - $this->assertCount(2, $theirs); - $this->assertSame('echo', $this->fixture->event(0, of: 2)->resourceName); - $this->assertSame('{"message":"Error while executing tool","platform":"php"}', $this->json($this->fixture->event(1, of: 2)->error)); - } - - public function testAReferenceHandlerSetBeforeTrackRunsTheToolsAndKeepsTheException(): void - { - $builder = TrackingFixture::builder(); - Tools::boom($builder); - $theirs = $this->countingReferenceHandler(); - $builder->setReferenceHandler($theirs); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $boom = $harness->callTool('boom', ['session_id' => self::SESSION]); - - $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $boom->error); - $this->assertCount(1, $theirs); - $this->assertSame('RuntimeException', JsonAssert::at($this->fixture->event()->error, 'type')); - } - - /** A tools/call handler added after track() answers, and AgentCat records its answer. */ - public function testACustomerCallHandlerAddedAfterTrackIsRecorded(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder); - /** @var list $received */ - $received = []; - $customer = new CustomerHandler(CallToolRequest::class, static function (Request $request) use (&$received): CallToolResult { - $received[] = $request; - - return new CallToolResult([new TextContent('mine')]); - }); - $builder->addRequestHandler($customer); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], ['progressToken' => 'p1'])); - - $this->assertSame(['mine'], $this->texts($result)); - $this->assertSame(1, $customer->calls); - $request = $received[0] ?? null; - $this->assertInstanceOf(CallToolRequest::class, $request); - $this->assertSame(['msg' => 'hi'], $request->arguments, 'the stripped copy'); - $this->assertSame(['progressToken' => 'p1'], $request->getMeta()); - $this->assertSame(JsonAssert::at($this->fixture->event()->parameters, 'extra', 'requestId'), $request->getId()); - $this->assertSame('{"content":[{"type":"text","text":"mine"}],"isError":false}', $this->json($this->fixture->event()->response)); - } - - /** @return iterable */ - public static function containerMoments(): iterable - { - yield 'set before track()' => [true]; - yield 'set after track()' => [false]; - } - - /** The customer's container builds the tool handlers, whenever it was set. */ - #[DataProvider('containerMoments')] - public function testTheCustomersContainerResolvesTheToolHandlers(bool $beforeTrack): void - { - $builder = TrackingFixture::builder()->addTool(GreetingTool::class, name: 'greet'); - $container = new Container(); - $container->set(GreetingTool::class, new GreetingTool('hello')); - if ($beforeTrack) { - $builder->setContainer($container); - } - $this->fixture->track($builder); - if (!$beforeTrack) { - $builder->setContainer($container); - } - $harness = $this->fixture->harness($builder); - - $result = HttpHarness::result($harness->callTool('greet', ['name' => 'Ada', 'session_id' => self::SESSION])); - - $this->assertSame(['hello, Ada'], $this->texts($result)); - $this->assertSame('greet', $this->fixture->event()->resourceName); - } - - /** Without a customer container, AgentCat and mcp/sdk share one, so a class has one instance as before. */ - public function testAToolHandlerThatIsAlsoACompletionProviderHasOneInstance(): void - { - $builder = TrackingFixture::builder() - ->addTool(NameService::class, name: 'name') - ->addPrompt( - static fn(#[CompletionProvider(provider: NameService::class)] string $name): array => [['role' => 'user', 'content' => $name]], - name: 'greeting', - ); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - - $tool = $this->texts(HttpHarness::result($harness->callTool('name', ['name' => 'Ada', 'session_id' => self::SESSION]))); - $completion = HttpHarness::result($harness->request('completion/complete', [ - 'ref' => ['type' => 'ref/prompt', 'name' => 'greeting'], - 'argument' => ['name' => 'name', 'value' => 'A'], - ])); - - $this->assertCount(1, $tool); - $this->assertSame($tool, JsonAssert::at($completion, 'completion', 'values')); - } - - /** Each event of a long-lived server keeps its own handles and identity. */ - public function testInterleavedCallsKeepTheirOwnValues(): void - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder, new Options( - enableAgentTracking: true, - identify: static fn(ToolCall $call): Identity => new Identity(is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : ''), - )); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $harness->callTool('echo', ['msg' => 'u1', 'agent_id' => 'a1', 'session_id' => 'start']); - $harness->callTool('echo', ['msg' => 'u2', 'agent_id' => 'a2', 'session_id' => self::SESSION]); - - $first = $this->fixture->event(0, of: 2); - $second = $this->fixture->event(1, of: 2); - $this->assertSame(['a1', 'minted', 'u1'], [$this->tag($first, Constants::TAG_AGENT_ID), $this->tag($first, Constants::TAG_SESSION_SOURCE), $first->identifyActorGivenId]); - $this->assertSame(['a2', 'supplied', 'u2'], [$this->tag($second, Constants::TAG_AGENT_ID), $this->tag($second, Constants::TAG_SESSION_SOURCE), $second->identifyActorGivenId]); - $this->assertNotSame(self::SESSION, $first->sessionId); - $this->assertSame(self::SESSION, $second->sessionId); - } - - public function testACallStartsTheDeliveryOfTheEventBeforeIt(): void - { - $this->fixture->transport->complete = false; - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $harness->callTool('echo', ['msg' => 'one']); - $before = count($this->fixture->transport->deliveries); - $harness->callTool('echo', ['msg' => 'two']); - - $this->assertCount(1, $this->fixture->transport->deliveries); - $this->assertSame(0, $before, 'the first call only queues its event'); - $this->assertSame(['queueLength' => 1, 'activeRequests' => 1], $this->fixture->queue->stats()); - } - - /** A reference handler that runs mcp/sdk's own and counts its calls. */ - private function countingReferenceHandler(): ReferenceHandlerInterface&\Countable - { - return new class implements ReferenceHandlerInterface, \Countable { - /** @var int<0, max> */ - private int $calls = 0; - - private readonly ReferenceHandler $inner; - - public function __construct() - { - $this->inner = new ReferenceHandler(); - } - - #[\Override] - public function handle(ElementReference $reference, array $arguments): mixed - { - ++$this->calls; - - return $this->inner->handle($reference, $arguments); - } - - #[\Override] - public function count(): int - { - return $this->calls; - } - }; - } - - private function tag(Event $event, string $key): ?string - { - return $event->tags[$key] ?? null; - } - - private function issued(string $sessionId): string - { - return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); - } - - private function cannedText(): string - { - $text = JsonAssert::at(Golden::json('handles/strings.json'), 'GET_MORE_TOOLS_RESULT', 'content', 0, 'text'); - $this->assertIsString($text); - - return $text; - } - - /** @return list the `text` of every content block */ - private function texts(\stdClass $result): array - { - $content = $result->content ?? null; - $this->assertIsList($content); - - return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); - } - - /** @return list */ - private function keys(mixed $object): array - { - $this->assertInstanceOf(\stdClass::class, $object); - - return array_map(strval(...), array_keys(get_object_vars($object))); - } - - private function json(mixed $value): string + protected static function entry(): Entry { - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/CallToolTestCase.php b/tests/Integration/McpSdk/CallToolTestCase.php new file mode 100644 index 0000000..b60717b --- /dev/null +++ b/tests/Integration/McpSdk/CallToolTestCase.php @@ -0,0 +1,885 @@ +fixture = new TrackingFixture(static::entry()); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + /** + * Skips a test of what only a Builder tracked before build() does. After build(), the library's handlers answer + * first: a `tools/call` or `tools/list` handler of the customer's, or a call to an unknown tool, never reaches + * AgentCat, and a reference handler of the customer's keeps the server untracked (D77). + */ + private function beforeBuildOnly(string $why): void + { + if (static::entry()->afterBuild()) { + self::markTestSkipped('Tracked after build(): ' . $why); + } + } + + /** + * The SDK log a test expects. A server tracked after build() fills its registries at track(), so a call before + * any `tools/list` never rebuilds them. + * + * @param list $lines + * + * @return list + */ + private function lines(array $lines): array + { + return static::entry()->afterBuild() ? array_values(array_diff($lines, [self::REBUILT])) : $lines; + } + + #[Group('ac-02-16')] + #[Group('ac-04-29')] + #[Group('ac-02-47')] + public function testTheToolGetsItsOwnArgumentsAndTheEventRecordsTheCall(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame([['msg' => 'hi']], $echo->calls); + $this->assertSame('{"content":[{"type":"text","text":"echo: hi"}],"isError":false}', $this->json(HttpHarness::result($answer))); + $event = $this->fixture->event(); + $this->assertSame('echo', $event->resourceName); + $this->assertSame(Constants::TOOLS_CALL_EVENT_TYPE, $event->eventType); + $this->assertSame( + '{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","context":"why","session_id":"' . self::SESSION . '"}}}', + $this->json(JsonAssert::at($event->parameters, 'request')), + ); + $this->assertSame('why', $event->userIntent); + $this->assertSame(self::SESSION, $event->sessionId); + $this->assertSame('supplied', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertSame(['harness-server', '1.2.3'], [$event->serverName, $event->serverVersion]); + $this->assertSame(['harness-client', '1.0.0'], [$event->clientName, $event->clientVersion]); + $this->assertSame(TrackingFixture::PROJECT, $event->projectId); + $this->assertNull($event->isError); + $this->assertNull($event->error); + } + + #[Group('ac-02-17')] + public function testAToolThatAllowsNoExtraPropertiesStillAcceptsTheInjectedOnes(): void + { + $builder = TrackingFixture::builder(); + $strict = Tools::strict($builder); + $this->fixture->track($builder, new Options(enableAgentTracking: true)); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $answer = $harness->callTool('strict', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION, 'agent_id' => 'a1']); + + $this->assertFalse(JsonAssert::at(HttpHarness::result($answer), 'isError')); + $this->assertSame([['msg' => 'hi']], $strict->calls); + } + + #[Group('ac-02-18')] + public function testArgumentsAgentCatDidNotInjectReachTheTool(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'hi', 'extra_key' => 'x', 'context' => 'why', 'session_id' => 'start']); + + $this->assertSame([['msg' => 'hi', 'extra_key' => 'x']], $echo->calls); + } + + #[Group('ac-02-19')] + #[Group('ac-04-13')] + public function testAToolThatDeclaresSessionIdKeepsIt(): void + { + $builder = TrackingFixture::builder(); + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('own', ['session_id' => 'mine', 'context' => 'why']); + + $this->assertSame([['session_id' => 'mine']], $own->calls); + $event = $this->fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('foreign', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + } + + #[Group('ac-02-20')] + public function testAServerThatNeverListedRebuildsItsRegistriesAndStripsExactly(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $own = Tools::own($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + + $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); + $rebuilt = array_filter($this->fixture->lines(), static fn(string $line): bool => $line === self::REBUILT); + $this->assertCount(static::entry()->afterBuild() ? 0 : 1, $rebuilt, 'the first call rebuilds, the second finds the registries'); + $this->assertCount(2, $this->fixture->events()); + } + + #[Group('ac-02-21')] + public function testAFailedRebuildStripsHeuristicallyAndIsTriedAgain(): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static function (): never { + throw new \RuntimeException('list failed'); + })); + $harness = $this->fixture->harness($builder); + + $harness->callTool('echo', ['msg' => 'hi', 'agent_id' => 'a1', 'session_id' => 'start']); + $harness->callTool('echo', ['msg' => 'again']); + + $this->assertSame([['msg' => 'hi'], ['msg' => 'again']], $echo->calls); + $warning = 'Warning: registry rebuild-on-demand failed - RuntimeException: list failed'; + $this->assertSame([$warning, $warning], $this->fixture->lines()); + $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->assertSame($this->lines([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. */ + #[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->assertSame([ListingLines::ownSessionId('own')], $this->fixture->lines(), 'both tools were listed: no rebuild'); + } + + /** + * 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')] + public function testAListHandlerThatIgnoresTheCursorEndsTheRebuild(): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $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->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** + * 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 + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $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->assertSame([self::REBUILT], $this->fixture->lines()); + } + + /** AgentCat answers get_more_tools, so a customer's schema of that name checks nothing, before or after build(). */ + public function testAShadowedCustomerGetMoreToolsIsAnsweredWithoutItsSchema(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $customer = new RecordingTool(); + $schema = ['type' => 'object', 'properties' => ['ticket' => ['type' => 'string']], 'required' => ['ticket']]; + $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); + $harness->listTools(); + + $answer = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'need X', 'session_id' => 'start'])); + + $this->assertContains($this->cannedText(), $this->texts($answer), 'no ticket, which the customer\'s schema requires'); + $this->assertSame([], $customer->calls); + } + + /** + * 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')] + #[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->assertSame($this->lines(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($this->lines([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); + // After build(), get_more_tools is the registry's last tool: here a third page of its own (D77). + $this->assertSame(static::entry()->afterBuild() ? ['own', 'structured'] : ['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}> */ + public static function sessionlessArguments(): iterable + { + yield 'no session_id' => [['msg' => 'hi']]; + yield 'start' => [['msg' => 'hi', 'session_id' => 'start']]; + yield 'START with spaces' => [['msg' => 'hi', 'session_id' => ' START ']]; + } + + /** @param array $arguments */ + #[DataProvider('sessionlessArguments')] + #[Group('ac-02-30')] + #[Group('ac-04-35')] + public function testANewSessionIsIssuedInTheFirstContentBlock(array $arguments): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('echo', $arguments)); + + $event = $this->fixture->event(); + $this->assertMatchesRegularExpression('/\Ases_[0-9A-Za-z]{27}\z/', $event->sessionId); + $this->assertSame([$this->issued($event->sessionId), 'echo: hi'], $this->texts($result)); + $this->assertSame('minted', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + $this->assertSame([['msg' => 'hi']], $echo->calls); + } + + #[Group('ac-02-31')] + #[Group('ac-04-37')] + public function testAnUnrecognizedSessionIsReportedInTheFirstContentBlock(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'not-a-real-id'])); + + $this->assertSame([Constants::MINT_BACK_HEADER_UNRECOGNIZED . "\n" . Constants::MINT_BACK_UNRECOGNIZED_BODY, 'echo: hi'], $this->texts($result)); + $event = $this->fixture->event(); + $this->assertSame('', $event->sessionId); + $this->assertSame('invalid', $this->tag($event, Constants::TAG_SESSION_SOURCE)); + } + + #[Group('ac-02-36')] + #[Group('ac-02-38')] + #[Group('ac-04-39')] + public function testStructuredOutputStartsWithTheIssuedSession(): void + { + $builder = TrackingFixture::builder(); + Tools::structured($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $event = $this->fixture->event(); + $structured = JsonAssert::at($result, 'structuredContent'); + $this->assertSame([Constants::MCP_SESSION_KEY, 'echoed'], $this->keys($structured)); + $this->assertSame( + $this->json(['session_id' => $event->sessionId, 'status' => 'issued']), + $this->json(JsonAssert::at($structured, Constants::MCP_SESSION_KEY)), + ); + $this->assertSame('{"echoed":"x"}', $this->json(JsonAssert::at($event->response, 'structuredContent')), 'the mirror is wire-only'); + } + + #[Group('ac-02-37')] + #[Group('ac-04-39')] + public function testStructuredOutputEchoesTheAgentAndTheActiveSession(): void + { + $builder = TrackingFixture::builder(); + Tools::structured($builder); + $this->fixture->track($builder, new Options(enableAgentTracking: true)); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => self::SESSION, 'agent_id' => 'a1'])); + + $this->assertSame( + $this->json(['session_id' => self::SESSION, 'agent_id' => 'a1', 'status' => 'active']), + $this->json(JsonAssert::at($result, 'structuredContent', Constants::MCP_SESSION_KEY)), + ); + $this->assertSame(['x'], $this->texts($result), 'a supplied session announces nothing'); + $this->assertSame('a1', $this->tag($this->fixture->event(), Constants::TAG_AGENT_ID)); + } + + /** + * mcp/sdk cannot tell `{}` from `[]`, so an empty structuredContent is an empty object to the mirror and to the + * event. On 0.9.0 and main, mcp/sdk itself rejects `[]` against an object outputSchema first. + */ + public function testAnEmptyStructuredContentCountsAsAnEmptyObject(): void + { + $builder = TrackingFixture::builder(); + Tools::structured($builder, []); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('structured', ['session_id' => 'start'])); + + $event = $this->fixture->event(); + if (McpSdkVersion::hasModernEra()) { + $this->assertTrue(JsonAssert::at($result, 'isError')); + $texts = $this->texts($result); + $this->assertCount(2, $texts); + $this->assertSame($this->issued($event->sessionId), $texts[0]); + $this->assertIsString($texts[1]); + $this->assertStringStartsWith("Invalid structured output for tool 'structured'", $texts[1]); + $this->assertObjectNotHasProperty('structuredContent', $result); + $this->assertTrue($event->isError); + + return; + } + $structured = JsonAssert::at($result, 'structuredContent'); + $this->assertSame([Constants::MCP_SESSION_KEY], $this->keys($structured)); + $this->assertSame('{}', $this->json(JsonAssert::at($event->response, 'structuredContent'))); + $this->assertNull($event->isError); + } + + #[Group('ac-02-40')] + #[Group('ac-04-46')] + public function testGetMoreToolsIsAnsweredByAgentCat(): void + { + $this->beforeBuildOnly('a tools/call handler of the customer\'s'); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $customer = new CustomerHandler(CallToolRequest::class, static fn(): CallToolResult => new CallToolResult([new TextContent('customer')])); + $builder->addRequestHandler($customer); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'need X'])); + + $event = $this->fixture->event(); + $this->assertSame([$this->issued($event->sessionId), $this->cannedText()], $this->texts($result)); + $this->assertSame(0, $customer->calls, 'no customer code runs'); + $this->assertSame(GetMoreTools::NAME, $event->resourceName); + $this->assertSame('need X', $event->userIntent); + $this->assertContains('Missing tool reported (context length: 6)', $this->fixture->lines()); + } + + #[Group('ac-02-41')] + #[Group('ac-04-48')] + public function testWithReportMissingOffGetMoreToolsIsAnUnknownTool(): void + { + $this->beforeBuildOnly('a call to an unknown tool'); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder, new Options(enableReportMissing: false)); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $answer = $harness->callTool(GetMoreTools::NAME, ['context' => 'need X']); + + $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'Tool not found: "get_more_tools".'], $answer->error); + $event = $this->fixture->event(); + $this->assertTrue($event->isError); + $this->assertSame('Tool not found: "get_more_tools".', JsonAssert::at($event->error, 'message')); + } + + #[Group('ac-02-42')] + #[Group('ac-01-15')] + public function testWithTracingOffNothingIsPublishedOrStripped(): void + { + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder); + $this->fixture->track($builder, new Options(enableTracing: false)); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $more = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'need X'])); + $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why']); + + $this->assertSame([$this->cannedText()], $this->texts($more)); + $this->assertSame([['msg' => 'hi', 'context' => 'why']], $echo->calls, 'B06 parity'); + $this->assertSame([], $this->fixture->events()); + } + + #[Group('ac-01-20')] + #[Group('ac-01-23')] + public function testAgentTrackingWithoutAnAgentIdAddsNoAgentTags(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder, new Options(enableAgentTracking: true)); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame(['echo: hi'], $this->texts(HttpHarness::result($answer))); + $event = $this->fixture->event(); + $this->assertSame('why', $event->userIntent); + $this->assertSame([Constants::TAG_SESSION_SOURCE => 'supplied'], $event->tags); + } + + /** Integer-like keys and nested empty objects reach the tool as mcp/sdk decodes them. */ + public function testTheToolGetsTheSameArrayAsWithoutAgentCat(): void + { + $properties = ['0' => ['type' => 'string'], '123' => ['type' => 'string'], 'nested' => ['type' => 'object']]; + $arguments = ['msg' => 'm', '0' => 'zero', '123' => 'x', 'nested' => ['a' => new \stdClass()]]; + $twinBuilder = TrackingFixture::builder(); + $twin = Tools::echo($twinBuilder, $properties); + $this->fixture->harness($twinBuilder)->callTool('echo', $arguments); + $builder = TrackingFixture::builder(); + $echo = Tools::echo($builder, $properties); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('echo', $arguments + ['session_id' => 'start']); + + $this->assertCount(1, $twin->calls); + $this->assertSame($twin->calls, $echo->calls); + $this->assertSame(['msg', '0', '123', 'nested', 'session_id'], $this->keys(JsonAssert::at($this->fixture->event()->parameters, 'request', 'params', 'arguments'))); + } + + /** A reference handler set after track() runs the tools, and AgentCat still sees the exception of a failing tool. */ + public function testAReferenceHandlerSetAfterTrackRunsTheToolsAndKeepsTheException(): void + { + $this->beforeBuildOnly('a reference handler of the customer\'s'); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + Tools::boom($builder); + $this->fixture->track($builder); + $theirs = $this->countingReferenceHandler(); + $builder->setReferenceHandler($theirs); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $echo = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $boom = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertSame(['echo: hi'], $this->texts(HttpHarness::result($echo))); + $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $boom->error); + $this->assertCount(2, $theirs); + $this->assertSame('echo', $this->fixture->event(0, of: 2)->resourceName); + $this->assertSame('RuntimeException', JsonAssert::at($this->fixture->event(1, of: 2)->error, 'type')); + } + + public function testAReferenceHandlerSetBeforeTrackRunsTheToolsAndKeepsTheException(): void + { + $this->beforeBuildOnly('a reference handler of the customer\'s'); + $builder = TrackingFixture::builder(); + Tools::boom($builder); + $theirs = $this->countingReferenceHandler(); + $builder->setReferenceHandler($theirs); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $boom = $harness->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertEquals((object) ['code' => Error::INTERNAL_ERROR, 'message' => 'Error while executing tool'], $boom->error); + $this->assertCount(1, $theirs); + $this->assertSame('RuntimeException', JsonAssert::at($this->fixture->event()->error, 'type')); + } + + /** @return iterable */ + public static function handlerMoments(): iterable + { + yield 'added before track()' => [true]; + yield 'added after track()' => [false]; + } + + /** A tools/call handler of the customer answers, and AgentCat records its answer, wherever track() runs. */ + #[DataProvider('handlerMoments')] + public function testACustomerCallHandlerIsRecorded(bool $beforeTrack): void + { + $this->beforeBuildOnly('a tools/call handler of the customer\'s'); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + /** @var list $received */ + $received = []; + $customer = new CustomerHandler(CallToolRequest::class, static function (Request $request) use (&$received): CallToolResult { + $received[] = $request; + + return new CallToolResult([new TextContent('mine')]); + }); + if ($beforeTrack) { + $builder->addRequestHandler($customer); + } + $this->fixture->track($builder); + if (!$beforeTrack) { + $builder->addRequestHandler($customer); + } + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $result = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION], ['progressToken' => 'p1'])); + + $this->assertSame(['mine'], $this->texts($result)); + $this->assertSame(1, $customer->calls); + $request = $received[0] ?? null; + $this->assertInstanceOf(CallToolRequest::class, $request); + $this->assertSame(['msg' => 'hi'], $request->arguments, 'the stripped copy'); + $this->assertSame(['progressToken' => 'p1'], $request->getMeta()); + $this->assertSame(JsonAssert::at($this->fixture->event()->parameters, 'extra', 'requestId'), $request->getId()); + $this->assertSame('{"content":[{"type":"text","text":"mine"}],"isError":false}', $this->json($this->fixture->event()->response)); + } + + /** @return iterable */ + public static function containerMoments(): iterable + { + yield 'set before track()' => [true]; + yield 'set after track()' => [false]; + } + + /** The customer's container builds the tool handlers, whenever it was set. */ + #[DataProvider('containerMoments')] + public function testTheCustomersContainerResolvesTheToolHandlers(bool $beforeTrack): void + { + $builder = TrackingFixture::builder()->addTool(GreetingTool::class, name: 'greet'); + $container = new Container(); + $container->set(GreetingTool::class, new GreetingTool('hello')); + if ($beforeTrack) { + $builder->setContainer($container); + } + $this->fixture->track($builder); + if (!$beforeTrack) { + $builder->setContainer($container); + } + $harness = $this->fixture->harness($builder); + + $result = HttpHarness::result($harness->callTool('greet', ['name' => 'Ada', 'session_id' => self::SESSION])); + + $this->assertSame(['hello, Ada'], $this->texts($result)); + $this->assertSame('greet', $this->fixture->event()->resourceName); + } + + /** Without a customer container, AgentCat and mcp/sdk share one, so a class has one instance as before. */ + public function testAToolHandlerThatIsAlsoACompletionProviderHasOneInstance(): void + { + $builder = TrackingFixture::builder() + ->addTool(NameService::class, name: 'name') + ->addPrompt( + static fn(#[CompletionProvider(provider: NameService::class)] string $name): array => [['role' => 'user', 'content' => $name]], + name: 'greeting', + ); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + + $tool = $this->texts(HttpHarness::result($harness->callTool('name', ['name' => 'Ada', 'session_id' => self::SESSION]))); + $completion = HttpHarness::result($harness->request('completion/complete', [ + 'ref' => ['type' => 'ref/prompt', 'name' => 'greeting'], + 'argument' => ['name' => 'name', 'value' => 'A'], + ])); + + $this->assertCount(1, $tool); + $this->assertSame($tool, JsonAssert::at($completion, 'completion', 'values')); + } + + /** Each event of a long-lived server keeps its own handles and identity. */ + public function testInterleavedCallsKeepTheirOwnValues(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder, new Options( + enableAgentTracking: true, + identify: static fn(ToolCall $call): Identity => new Identity(is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : ''), + )); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'u1', 'agent_id' => 'a1', 'session_id' => 'start']); + $harness->callTool('echo', ['msg' => 'u2', 'agent_id' => 'a2', 'session_id' => self::SESSION]); + + $first = $this->fixture->event(0, of: 2); + $second = $this->fixture->event(1, of: 2); + $this->assertSame(['a1', 'minted', 'u1'], [$this->tag($first, Constants::TAG_AGENT_ID), $this->tag($first, Constants::TAG_SESSION_SOURCE), $first->identifyActorGivenId]); + $this->assertSame(['a2', 'supplied', 'u2'], [$this->tag($second, Constants::TAG_AGENT_ID), $this->tag($second, Constants::TAG_SESSION_SOURCE), $second->identifyActorGivenId]); + $this->assertNotSame(self::SESSION, $first->sessionId); + $this->assertSame(self::SESSION, $second->sessionId); + } + + public function testACallStartsTheDeliveryOfTheEventBeforeIt(): void + { + $this->fixture->transport->complete = false; + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $harness->callTool('echo', ['msg' => 'one']); + $before = count($this->fixture->transport->deliveries); + $harness->callTool('echo', ['msg' => 'two']); + + $this->assertCount(1, $this->fixture->transport->deliveries); + $this->assertSame(0, $before, 'the first call only queues its event'); + $this->assertSame(['queueLength' => 1, 'activeRequests' => 1], $this->fixture->queue->stats()); + } + + /** A reference handler that runs mcp/sdk's own and counts its calls. */ + private function countingReferenceHandler(): ReferenceHandlerInterface&\Countable + { + return new class implements ReferenceHandlerInterface, \Countable { + /** @var int<0, max> */ + private int $calls = 0; + + private readonly ReferenceHandler $inner; + + public function __construct() + { + $this->inner = new ReferenceHandler(); + } + + #[\Override] + public function handle(ElementReference $reference, array $arguments): mixed + { + ++$this->calls; + + return $this->inner->handle($reference, $arguments); + } + + #[\Override] + public function count(): int + { + return $this->calls; + } + }; + } + + private function tag(Event $event, string $key): ?string + { + return $event->tags[$key] ?? null; + } + + private function issued(string $sessionId): string + { + return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); + } + + private function cannedText(): string + { + $text = JsonAssert::at(Golden::json('handles/strings.json'), 'GET_MORE_TOOLS_RESULT', 'content', 0, 'text'); + $this->assertIsString($text); + + return $text; + } + + /** @return list the `text` of every content block */ + private function texts(\stdClass $result): array + { + $content = $result->content ?? null; + $this->assertIsList($content); + + return array_map(static fn(mixed $block): mixed => JsonAssert::at($block, 'text'), $content); + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Integration/McpSdk/DelegatesTest.php b/tests/Integration/McpSdk/DelegatesTest.php index 4cd669e..7682f4c 100644 --- a/tests/Integration/McpSdk/DelegatesTest.php +++ b/tests/Integration/McpSdk/DelegatesTest.php @@ -15,6 +15,7 @@ use AgentCat\McpSdk\Internal\ListToolsInterceptor; use AgentCat\McpSdk\Internal\RequestReader; use AgentCat\Options; +use AgentCat\Tests\Support\McpSdk\BuilderMembers; use AgentCat\Tests\Support\McpSdk\CustomerHandler; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingTransport; @@ -23,6 +24,7 @@ use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; use Mcp\Server; +use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\ListToolsHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; @@ -31,7 +33,7 @@ final class DelegatesTest extends TestCase { - private const string COMPATIBILITY = 'AgentCat SDK Compatibility: AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; + private const string COMPATIBILITY = 'AgentCat SDK Compatibility: AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder or the Mcp\Server built from it to AgentCat::track()).'; private MemoryLogger $logger; @@ -132,6 +134,67 @@ public function testTheCallInterceptorStepsAsideWhenNoHandlerFollowsIt(): void $this->assertSame([self::COMPATIBILITY], $this->logger->messages()); } + public function testTheRunnerOfTheLibrarysCallHandlerIsOneCopyThatStashesTheToolsException(): void + { + $self = $this->caller(); + $builder = Server::builder()->addRequestHandler($self)->addTool(static function (): never { + throw new \RuntimeException('tool boom'); + }, 'boom'); + $builder->build(); + $delegates = new Delegates($builder); + $stash = new ThrowableStash(); + $request = (new CallToolRequest('boom', []))->withId(1); + + $runner = $delegates->runner($self, $request, $stash); + $library = $delegates->after($self, $request); + + $this->assertInstanceOf(CallToolHandler::class, $runner); + $this->assertInstanceOf(CallToolHandler::class, $library); + $this->assertNotSame($library, $runner, 'a copy of the library\'s handler'); + $this->assertSame($runner, $delegates->runner($self, $request, $stash), 'made once'); + $answer = $runner->handle($request, new Session(new InMemorySessionStore())); + $this->assertInstanceOf(Error::class, $answer); + $this->assertSame('Error while executing tool', $answer->message, 'the library\'s answer'); + $thrown = $stash->take($request); + $this->assertInstanceOf(\RuntimeException::class, $thrown); + $this->assertSame('tool boom', $thrown->getMessage()); + $this->assertSame([], $this->logger->messages()); + } + + public function testTheRunnerOfACustomersCallHandlerIsTheHandler(): void + { + $self = $this->caller(); + $customer = new CustomerHandler(CallToolRequest::class, static fn(): ListToolsResult => new ListToolsResult([])); + $builder = Server::builder()->addRequestHandler($self)->addRequestHandler($customer); + $builder->build(); + + $this->assertSame($customer, (new Delegates($builder))->runner($self, (new CallToolRequest('echo', []))->withId(1), new ThrowableStash())); + } + + /** A library handler whose members cannot be read runs the tools itself; the drift is reported once. */ + public function testTheLibrarysCallHandlerRunsTheToolsWhenItCannotBeCopied(): void + { + $self = $this->caller(); + $builder = Server::builder()->addRequestHandler($self); + $builder->build(); + $broken = (new \ReflectionClass(CallToolHandler::class))->newInstanceWithoutConstructor(); // no members at all + $parts = BuilderMembers::reader('parts')($builder); + $this->assertIsArray($parts); + $parts['requestHandlers'] = [$self, $broken]; + BuilderMembers::writer('parts')($builder, $parts); + $delegates = new Delegates($builder); + $request = (new CallToolRequest('echo', []))->withId(1); + + $this->assertSame($broken, $delegates->runner($self, $request, new ThrowableStash())); + $this->assertSame($broken, $delegates->runner($self, $request, new ThrowableStash())); + $this->assertSame([self::COMPATIBILITY], $this->logger->messages()); + } + + private function caller(): CustomerHandler + { + return new CustomerHandler(CallToolRequest::class, static fn(): ListToolsResult => new ListToolsResult([])); + } + private function lister(): CustomerHandler { return new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([])); diff --git a/tests/Integration/McpSdk/HooksAfterBuildTest.php b/tests/Integration/McpSdk/HooksAfterBuildTest.php new file mode 100644 index 0000000..e7271aa --- /dev/null +++ b/tests/Integration/McpSdk/HooksAfterBuildTest.php @@ -0,0 +1,17 @@ +fixture = new TrackingFixture(); - } - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - #[Group('ac-01-31')] - #[Group('ac-01-32')] - #[Group('ac-01-33')] - #[Group('ac-02-49')] - public function testIdentifySeesTheRawCallOncePerCall(): void - { - /** @var list $seen */ - $seen = []; - $harness = $this->tracked(new Options(identify: static function (ToolCall $call) use (&$seen): Identity { - $seen[] = $call; - - return new Identity('user-' . (is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : '')); - })); - - foreach (['1', '2', '3'] as $msg) { - $harness->callTool('echo', ['msg' => $msg, 'context' => 'why', 'session_id' => 'start']); - } - - $events = $this->fixture->events(); - $this->assertCount(3, $seen); - $this->assertSame(['msg' => '1', 'context' => 'why', 'session_id' => 'start'], $seen[0]->arguments, 'unstripped'); - $this->assertSame('echo', $seen[0]->toolName); - $this->assertInstanceOf(CallToolRequest::class, $seen[0]->request); - $this->assertSame($seen[0]->arguments, $seen[0]->request->arguments); - $this->assertInstanceOf(SessionInterface::class, $seen[0]->context); - $this->assertSame(['user-1', 'user-2', 'user-3'], array_map(static fn(Event $event): ?string => $event->identifyActorGivenId, $events)); - } - - #[Group('ac-01-37')] - #[Group('ac-02-48')] - public function testHooksRunAfterTheResponse(): void - { - $runs = new \ArrayObject(); - $harness = $this->tracked(new Options(identify: static function () use ($runs): null { - $runs->append(true); - - return null; - })); - - $answer = $harness->callTool('echo', ['msg' => 'hi']); - - $this->assertObjectHasProperty('result', $answer); - $this->assertCount(0, $runs, 'the client has its answer before any hook runs'); - $this->fixture->events(); - $this->assertCount(1, $runs); - } - - #[Group('ac-02-50')] - #[Group('ac-01-29')] - public function testAThrowingIdentifyLeavesTheCallAndTheEventAnonymous(): void - { - $harness = $this->tracked(new Options(identify: static function (): never { - throw new \RuntimeException('who?'); - })); - - $answer = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); - - $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 1, 'text')); - $event = $this->fixture->event(); - $this->assertNull($event->identifyActorGivenId); - $this->assertSame('{}', $this->json($event->identifyActorData)); - $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 - { - $harness = $this->tracked(new Options(resolveSessionId: static function (): never { - throw new \RuntimeException('no session'); - })); - - $answer = $harness->callTool('echo', ['msg' => 'hi']); - - $this->assertEquals([(object) ['type' => 'text', 'text' => 'echo: hi']], JsonAssert::at(HttpHarness::result($answer), 'content')); - $event = $this->fixture->event(); - $this->assertMatchesRegularExpression(SessionId::PATTERN, $event->sessionId); - $this->assertSame('minted', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); - $this->assertContains('resolveSessionId hook error: RuntimeException: no session', $this->fixture->lines()); - } - - #[Group('ac-01-27')] - #[Group('ac-01-28')] - public function testTheHookSessionIsDerivedTheSameWayOnEveryServer(): void - { - $resolve = static fn(ToolCall $call): ?string => is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : null; - foreach (['customer-correlation-7', 'customer-correlation-7', 'customer-correlation-8'] as $value) { - $this->tracked(new Options(resolveSessionId: $resolve))->callTool('echo', ['msg' => $value]); - } - - $events = $this->fixture->events(); - $this->assertCount(3, $events); - $expected = SessionId::derive('customer-correlation-7', TrackingFixture::PROJECT); - $this->assertSame([$expected, $expected], [$events[0]->sessionId, $events[1]->sessionId]); - $this->assertSame(SessionId::derive('customer-correlation-8', TrackingFixture::PROJECT), $events[2]->sessionId); - $this->assertNotSame($expected, $events[2]->sessionId); - $this->assertSame('hook', $events[0]->tags[Constants::TAG_SESSION_SOURCE] ?? null); - } - - #[Group('ac-05-R20')] - public function testRedactEventSeesTheHookResultsBeforeTheEventHasAnId(): void - { - /** @var list $seen */ - $seen = []; - $harness = $this->tracked(new Options( - identify: static fn(): Identity => new Identity('u-redact'), - redactEvent: static function (Event $event) use (&$seen): Event { - $seen = [$event->identifyActorGivenId, $event->tags['env'] ?? null, $event->id]; - - return $event; - }, - eventTags: static fn(): array => ['env' => 'prod'], - )); - - $harness->callTool('echo', ['msg' => 'hi']); - - $event = $this->fixture->event(); - $this->assertSame(['u-redact', 'prod', ''], $seen); - $this->assertNotSame('', $event->id); - } - - #[Group('ac-05-R21')] - public function testTheHooksSeeTheRawTextAndTheDeliveredBodyTheRedactedOne(): void - { - $raw = null; - $harness = $this->tracked(new Options( - identify: static function (ToolCall $call) use (&$raw): null { - $raw = $call->arguments['text'] ?? null; - - return null; - }, - redactSensitiveInformation: static fn(string $text): string => str_replace('secret', '[gone]', $text), - )); - - $harness->callTool('echo', ['msg' => 'hi', 'text' => 'this contains a secret value']); - - $this->fixture->event(); - $this->assertSame('this contains a secret value', $raw); - $this->assertCount(1, $this->fixture->transport->deliveries); - $body = $this->fixture->transport->deliveries[0]->body; - $this->assertStringContainsString('this contains a [gone] value', $body); - $this->assertStringNotContainsString('secret', $body); - } - - private function tracked(Options $options): HttpHarness - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder, $options); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - return $harness; - } - - private function json(mixed $value): string + protected static function entry(): Entry { - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/HooksTestCase.php b/tests/Integration/McpSdk/HooksTestCase.php new file mode 100644 index 0000000..cbfa38c --- /dev/null +++ b/tests/Integration/McpSdk/HooksTestCase.php @@ -0,0 +1,264 @@ +fixture = new TrackingFixture(static::entry()); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-01-31')] + #[Group('ac-01-32')] + #[Group('ac-01-33')] + #[Group('ac-02-49')] + public function testIdentifySeesTheRawCallOncePerCall(): void + { + /** @var list $seen */ + $seen = []; + $harness = $this->tracked(new Options(identify: static function (ToolCall $call) use (&$seen): Identity { + $seen[] = $call; + + return new Identity('user-' . (is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : '')); + })); + + foreach (['1', '2', '3'] as $msg) { + $harness->callTool('echo', ['msg' => $msg, 'context' => 'why', 'session_id' => 'start']); + } + + $events = $this->fixture->events(); + $this->assertCount(3, $seen); + $this->assertSame(['msg' => '1', 'context' => 'why', 'session_id' => 'start'], $seen[0]->arguments, 'unstripped'); + $this->assertSame('echo', $seen[0]->toolName); + $this->assertInstanceOf(CallToolRequest::class, $seen[0]->request); + $this->assertSame($seen[0]->arguments, $seen[0]->request->arguments); + $this->assertInstanceOf(SessionInterface::class, $seen[0]->context); + $this->assertSame(['user-1', 'user-2', 'user-3'], array_map(static fn(Event $event): ?string => $event->identifyActorGivenId, $events)); + } + + #[Group('ac-01-37')] + #[Group('ac-02-48')] + public function testHooksRunAfterTheResponse(): void + { + $runs = new \ArrayObject(); + $harness = $this->tracked(new Options(identify: static function () use ($runs): null { + $runs->append(true); + + return null; + })); + + $answer = $harness->callTool('echo', ['msg' => 'hi']); + + $this->assertObjectHasProperty('result', $answer); + $this->assertCount(0, $runs, 'the client has its answer before any hook runs'); + $this->fixture->events(); + $this->assertCount(1, $runs); + } + + #[Group('ac-02-50')] + #[Group('ac-01-29')] + public function testAThrowingIdentifyLeavesTheCallAndTheEventAnonymous(): void + { + $harness = $this->tracked(new Options(identify: static function (): never { + throw new \RuntimeException('who?'); + })); + + $answer = $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertSame('echo: hi', JsonAssert::at(HttpHarness::result($answer), 'content', 1, 'text')); + $event = $this->fixture->event(); + $this->assertNull($event->identifyActorGivenId); + $this->assertSame('{}', $this->json($event->identifyActorData)); + $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 + { + $harness = $this->tracked(new Options(resolveSessionId: static function (): never { + throw new \RuntimeException('no session'); + })); + + $answer = $harness->callTool('echo', ['msg' => 'hi']); + + $this->assertEquals([(object) ['type' => 'text', 'text' => 'echo: hi']], JsonAssert::at(HttpHarness::result($answer), 'content')); + $event = $this->fixture->event(); + $this->assertMatchesRegularExpression(SessionId::PATTERN, $event->sessionId); + $this->assertSame('minted', $event->tags[Constants::TAG_SESSION_SOURCE] ?? null); + $this->assertContains('resolveSessionId hook error: RuntimeException: no session', $this->fixture->lines()); + } + + #[Group('ac-01-27')] + #[Group('ac-01-28')] + public function testTheHookSessionIsDerivedTheSameWayOnEveryServer(): void + { + $resolve = static fn(ToolCall $call): ?string => is_string($call->arguments['msg'] ?? null) ? $call->arguments['msg'] : null; + foreach (['customer-correlation-7', 'customer-correlation-7', 'customer-correlation-8'] as $value) { + $this->tracked(new Options(resolveSessionId: $resolve))->callTool('echo', ['msg' => $value]); + } + + $events = $this->fixture->events(); + $this->assertCount(3, $events); + $expected = SessionId::derive('customer-correlation-7', TrackingFixture::PROJECT); + $this->assertSame([$expected, $expected], [$events[0]->sessionId, $events[1]->sessionId]); + $this->assertSame(SessionId::derive('customer-correlation-8', TrackingFixture::PROJECT), $events[2]->sessionId); + $this->assertNotSame($expected, $events[2]->sessionId); + $this->assertSame('hook', $events[0]->tags[Constants::TAG_SESSION_SOURCE] ?? null); + } + + #[Group('ac-05-R20')] + public function testRedactEventSeesTheHookResultsBeforeTheEventHasAnId(): void + { + /** @var list $seen */ + $seen = []; + $harness = $this->tracked(new Options( + identify: static fn(): Identity => new Identity('u-redact'), + redactEvent: static function (Event $event) use (&$seen): Event { + $seen = [$event->identifyActorGivenId, $event->tags['env'] ?? null, $event->id]; + + return $event; + }, + eventTags: static fn(): array => ['env' => 'prod'], + )); + + $harness->callTool('echo', ['msg' => 'hi']); + + $event = $this->fixture->event(); + $this->assertSame(['u-redact', 'prod', ''], $seen); + $this->assertNotSame('', $event->id); + } + + #[Group('ac-05-R21')] + public function testTheHooksSeeTheRawTextAndTheDeliveredBodyTheRedactedOne(): void + { + $raw = null; + $harness = $this->tracked(new Options( + identify: static function (ToolCall $call) use (&$raw): null { + $raw = $call->arguments['text'] ?? null; + + return null; + }, + redactSensitiveInformation: static fn(string $text): string => str_replace('secret', '[gone]', $text), + )); + + $harness->callTool('echo', ['msg' => 'hi', 'text' => 'this contains a secret value']); + + $this->fixture->event(); + $this->assertSame('this contains a secret value', $raw); + $this->assertCount(1, $this->fixture->transport->deliveries); + $body = $this->fixture->transport->deliveries[0]->body; + $this->assertStringContainsString('this contains a [gone] value', $body); + $this->assertStringNotContainsString('secret', $body); + } + + private function tracked(Options $options): HttpHarness + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder, $options); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + return $harness; + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Integration/McpSdk/ListToolsAfterBuildTest.php b/tests/Integration/McpSdk/ListToolsAfterBuildTest.php new file mode 100644 index 0000000..7e06443 --- /dev/null +++ b/tests/Integration/McpSdk/ListToolsAfterBuildTest.php @@ -0,0 +1,17 @@ +logger = new MemoryLogger(); - Log::setLogger($this->logger); - $this->transport = new RecordingTransport(); - $this->queue = new EventQueue(transport: $this->transport); - $this->publisher = new Publisher($this->queue); - } - - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - #[Group('ac-02-6')] - #[Group('ac-02-39')] - public function testEveryToolGetsTheHandlesAndTheContextAndGetMoreToolsComesLast(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['echo', GetMoreTools::NAME], $this->names($result)); - $schema = JsonAssert::at($result, 'tools', 0, 'inputSchema'); - $this->assertSame(['msg', 'session_id', 'context'], $this->keys(JsonAssert::at($schema, 'properties'))); - $this->assertSame(['msg', 'session_id', 'context'], JsonAssert::at($schema, 'required')); - $this->assertSame(Constants::SESSION_ID_PARAM_PATTERN, JsonAssert::at($schema, 'properties', 'session_id', 'pattern')); - $descriptor = GetMoreTools::descriptor(); - $this->assertSame($descriptor->description, JsonAssert::at($result, 'tools', 1, 'description')); - $this->assertEquals($descriptor->annotations, JsonAssert::at($result, 'tools', 1, 'annotations')); - } - - #[Group('ac-02-7')] - public function testAgentTrackingAddsARequiredAgentId(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(enableAgentTracking: true)); - - $result = $this->harness($builder)->listTools(); - - $echo = JsonAssert::at($result, 'tools', 0, 'inputSchema'); - $this->assertSame(['msg', 'session_id', 'agent_id', 'context'], $this->keys(JsonAssert::at($echo, 'properties'))); - $this->assertSame(['msg', 'session_id', 'agent_id', 'context'], JsonAssert::at($echo, 'required')); - $more = JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties'); - $this->assertSame(['context', 'session_id', 'agent_id'], $this->keys($more)); - $this->assertSame(self::CONTEXT_DESCRIPTION, JsonAssert::at($more, 'context', 'description')); - } - - public function testTheModernEraGetsTheSameInjectedListing(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $harness = $this->harness($builder); - - $handshake = $harness->listTools(); - $modern = $harness->listToolsModern(); - - $this->assertSame(['echo', GetMoreTools::NAME], $this->names($modern)); - $this->assertEquals($handshake->tools, $modern->tools); - $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 testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsTheLibrarysCursor(): void - { - $build = function (bool $tracked): HttpHarness { - $builder = $this->builder()->setPaginationLimit(1) - ->add(self::tool('a'), new RecordingTool()) - ->add(self::tool('b'), new RecordingTool()); - if ($tracked) { - $this->track($builder); - } - - return $this->harness($builder); - }; - $twin = $build(false)->listTools(); - $harness = $build(true); - - $first = $harness->listTools(); - $this->assertIsString($first->nextCursor); - $second = $harness->listTools($first->nextCursor); - - $this->assertSame(['a'], $this->names($first)); - $this->assertSame($twin->nextCursor, $first->nextCursor); - $this->assertSame(['b', GetMoreTools::NAME], $this->names($second)); - $this->assertObjectNotHasProperty('nextCursor', $second); - } - - /** - * A customer's own get_more_tools on page 1: the process that served page 1 lists no second get_more_tools on the - * last page. A process that did not serve it (every request under PHP-FPM) lists AgentCat's there too. - */ - #[Group('ac-02-8')] - #[Group('ac-02-14')] - public function testACustomerGetMoreToolsOnAnEarlierPageIsTheOnlyOneTheProcessThatServedItLists(): void - { - $build = function (): HttpHarness { - $builder = $this->builder()->setPaginationLimit(1) - ->add(self::tool(GetMoreTools::NAME, 'Mine'), new RecordingTool()) - ->add(self::tool('b'), new RecordingTool()); - $this->track($builder); - - return $this->harness($builder); - }; - $harness = $build(); - - $first = $harness->listTools(); - $this->assertIsString($first->nextCursor); - $second = $harness->listTools($first->nextCursor); - $elsewhere = $build()->listTools($first->nextCursor); - - $this->assertSame([GetMoreTools::NAME], $this->names($first)); - $this->assertSame(['b'], $this->names($second)); - $this->assertSame(['b', GetMoreTools::NAME], $this->names($elsewhere)); - $this->assertSame([ListingLines::SHADOWED], array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'shadowed')))); - } - - #[Group('ac-02-9')] - public function testAToolAddedAfterInstallIsListedAndInjected(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $builder->addTool(static fn(string $text): string => $text, name: 'late', description: 'Late'); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['echo', 'late', GetMoreTools::NAME], $this->names($result)); - $this->assertSame(['text', 'session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties'))); - } - - #[Group('ac-02-10')] - public function testAThrowingCustomerHandlerGivesTheSameErrorAsWithoutAgentCat(): void - { - $build = function (bool $tracked): HttpHarness { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - if ($tracked) { - $this->track($builder); - } - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static function (): never { - throw new \RuntimeException('list failed'); - })); - - return $this->harness($builder); - }; - $twin = $build(false)->request('tools/list'); - - $answer = $build(true)->request('tools/list'); - - $this->assertObjectHasProperty('error', $answer); - $this->assertEquals($twin->error, $answer->error); - $this->assertSame(0, $this->queue->stats()['queueLength']); - } - - public function testAJsonRpcErrorFromTheNextHandlerPassesUnchanged(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $builder->addRequestHandler(new CustomerHandler( - ListToolsRequest::class, - static fn(Request $request): Error => Error::forInvalidParams('bad cursor', $request->getId()), - )); - - $answer = $this->harness($builder)->request('tools/list'); - - $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'bad cursor'], $answer->error); - } - - public function testAnAnswerThatIsNotAToolListPassesUnchanged(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): CallToolResult => new CallToolResult([new TextContent('odd')]))); - - $result = $this->harness($builder)->listTools(); - - $this->assertEquals((object) ['content' => [(object) ['type' => 'text', 'text' => 'odd']], 'isError' => false], $result); - $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages(), 'TS listWrap: the answer has no tools array'); - } - - #[Group('ac-02-11')] - #[Group('ac-04-28')] - public function testAnEmptyListingIsServedAsIs(): void - { - $builder = $this->builder(); - $server = $this->track($builder); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame([], $result->tools); - $this->assertContains(ToolListInjector::NO_TOOLS_WARNING, $this->logger->messages()); - $this->assertNull($server->registry); - } - - #[Group('ac-02-14')] - #[Group('ac-04-24')] - public function testACustomerGetMoreToolsIsListedOnceAndWarnedAboutOnce(): void - { - $builder = $this->builder()->add(self::tool(GetMoreTools::NAME, 'Mine'), new RecordingTool()); - $this->track($builder); - $harness = $this->harness($builder); - - $harness->listTools(); - $result = $harness->listTools(); - - $this->assertSame([GetMoreTools::NAME], $this->names($result)); - $this->assertSame('Mine', JsonAssert::at($result, 'tools', 0, 'description')); - $this->assertCount(1, array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'shadowed'))); - } - - #[Group('ac-02-15')] - #[Group('ac-01-38')] - #[Group('ac-01-83')] - public function testInitializeAndListingRunNoHookAndQueueNothing(): void - { - $runs = 0; - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(identify: static function () use (&$runs): null { - ++$runs; - - return null; - })); - - $this->harness($builder)->listTools(); - $this->queue->flush(); - - $this->assertSame(0, $runs); - $this->assertSame([], $this->transport->deliveries); - } - - #[Group('ac-04-21')] - public function testAToolThatDeclaresSessionIdKeepsItsOwnAndIsReportedOnce(): void - { - $own = ['type' => 'string', 'description' => 'mine']; - $builder = $this->builder()->add( - new Tool(name: 'own', title: null, inputSchema: ['type' => 'object', 'properties' => ['session_id' => $own], 'required' => ['session_id']], description: null, annotations: null), - new RecordingTool(), - ); - $this->track($builder); - $harness = $this->harness($builder); - - $harness->listTools(); - $result = $harness->listTools(); - - $this->assertEquals((object) $own, JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties', 'session_id')); - $prefix = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; - $this->assertCount(1, array_filter($this->logger->messages(), static fn(string $line): bool => str_starts_with($line, $prefix))); - } - - #[Group('ac-04-26')] - public function testAnOutputSchemaDeclaresMcpSessionAsOptional(): void - { - $builder = $this->builder()->add( - new Tool(name: 'echo', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: null, annotations: null, outputSchema: ['type' => 'object', 'properties' => ['echoed' => ['type' => 'string']]]), - new RecordingTool(), - ); - $server = $this->track($builder); - - $result = $this->harness($builder)->listTools(); - - $output = JsonAssert::at($result, 'tools', 0, 'outputSchema'); - $this->assertSame(['echoed', Constants::MCP_SESSION_KEY], $this->keys(JsonAssert::at($output, 'properties'))); - $this->assertObjectNotHasProperty('required', $this->object($output)); - $this->assertSame(['echo'], $server->outputRegistry); - } - - #[Group('ac-01-26')] - #[Group('ac-04-17')] - public function testHookModeInjectsNoSessionId(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(resolveSessionId: static fn(): string => 'mine')); - - $result = $this->harness($builder)->listTools(); - - foreach ([0, 1] as $index) { - $schema = JsonAssert::at($result, 'tools', $index, 'inputSchema'); - $this->assertNotContains('session_id', $this->keys(JsonAssert::at($schema, 'properties'))); - $this->assertNotContains('session_id', $this->list(JsonAssert::at($schema, 'required'))); - } - } - - #[Group('ac-01-14')] - #[Group('ac-04-27')] - public function testTracingOffInjectsNoHandlesButKeepsContextAndGetMoreTools(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(enableTracing: false, enableAgentTracking: true)); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['echo', GetMoreTools::NAME], $this->names($result)); - $this->assertSame(['msg', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); - } - - #[Group('ac-01-16')] - public function testReportMissingOffListsNoGetMoreTools(): void + protected static function entry(): Entry { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(enableReportMissing: false)); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['echo'], $this->names($result)); - $this->assertSame(['msg', 'session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); - } - - #[Group('ac-01-17')] - public function testContextOffInjectsNoContext(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(enableToolCallContext: false)); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['msg', 'session_id'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); - } - - /** @return iterable */ - public static function contextDescriptions(): iterable - { - yield 'custom' => ['X', 'X']; - yield 'empty' => ['', Constants::DEFAULT_CONTEXT_PARAMETER_DESCRIPTION]; - } - - #[DataProvider('contextDescriptions')] - #[Group('ac-01-18')] - #[Group('ac-01-19')] - public function testTheContextDescriptionCanBeReplaced(string $custom, string $expected): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder, new Options(customContextDescription: $custom)); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame($expected, JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties', 'context', 'description')); - $this->assertSame(self::CONTEXT_DESCRIPTION, JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties', 'context', 'description')); - } - - public function testACustomerListHandlerAddedAfterInstallGetsTheInjection(): void - { - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([self::tool('x')]))); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); - $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); - } - - /** - * 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, ['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, array $names): void - { - $build = function (bool $tracked) use ($cursor): HttpHarness { - $builder = $this->builder(); - if ($tracked) { - $this->track($builder); - } - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([self::tool('x')], $cursor))); - - return $this->harness($builder); - }; - $twin = $build(false)->listTools(); - - $result = $build(true)->listTools(); - - $this->assertSame($names, $this->names($result)); - $this->assertSame($this->keys($twin), $this->keys($result)); - $this->assertSame($twin->nextCursor ?? null, $result->nextCursor ?? null); - } - - public function testMembersALaterLibraryAddsToTheListingAreKept(): void - { - $listing = $this->listing(['tools' => [self::tool('x')], 'nextCursor' => 'c', '_meta' => ['source' => 'customer']]); - $twin = $this->customerListing(false, $listing); - - $result = $this->customerListing(true, $listing); - - $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); - } - - /** @return iterable}> */ - public static function listingsWithoutAToolList(): iterable - { - yield 'no tools member' => [['items' => [self::tool('x')]]]; - yield 'tools is an object' => [['tools' => ['x' => self::tool('x')]]]; - } - - /** @param array $members */ - #[DataProvider('listingsWithoutAToolList')] - public function testAListingWithoutAToolListOnTheWirePassesUnchanged(array $members): void - { - $listing = $this->listing($members); - $twin = $this->customerListing(false, $listing); - - $result = $this->customerListing(true, $listing); - - $this->assertEquals($twin, $result); - $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages(), 'TS listWrap: `tools` is not an array'); - } - - public function testAFailedInjectionServesTheOriginalListing(): void - { - $tool = new class ('flaky', null, ['type' => 'object', 'properties' => [], 'required' => []], null, null) extends Tool { - private bool $served = false; - - #[\Override] - public function jsonSerialize(): array - { - if (!$this->served) { - $this->served = true; - - throw new \RuntimeException('tree failed'); - } - - return parent::jsonSerialize(); - } - }; - $builder = $this->builder(); - $this->track($builder); - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$tool]))); - - $result = $this->harness($builder)->listTools(); - - $this->assertSame(['flaky'], $this->names($result)); - $this->assertContains('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - tree failed', $this->logger->messages()); - } - - public function testAListingStartsTheDeliveryOfAWaitingEvent(): void - { - $this->transport->complete = false; - $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); - $this->track($builder); - $harness = $this->harness($builder); - $this->queue->add(new QueuedEvent(new Event(sessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaaa', projectId: self::PROJECT, eventType: Constants::TOOLS_CALL_EVENT_TYPE), null, null, [])); - $this->assertSame(['queueLength' => 1, 'activeRequests' => 0], $this->queue->stats()); - - $harness->listTools(); - - $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $this->queue->stats()); - $this->assertCount(1, $this->transport->deliveries); - } - - /** The listing a server gets when a customer handler added after install() answers `tools/list`. */ - private function customerListing(bool $tracked, ListToolsResult $answer): \stdClass - { - $builder = $this->builder(); - if ($tracked) { - $this->track($builder); - } - $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => $answer)); - - return $this->harness($builder)->listTools(); - } - - /** - * A `ListToolsResult` whose wire members are exactly these, standing in for a later mcp/sdk whose result has - * more members (for example `_meta`) or another shape. - * - * @param array $members - */ - private function listing(array $members): ListToolsResult - { - return new class ($members) extends ListToolsResult { - /** @param array $members */ - public function __construct(private readonly array $members) - { - parent::__construct([]); - } - - /** @return array */ - #[\Override] // @phpstan-ignore method.childReturnType (stands in for a later ListToolsResult with other members) - public function jsonSerialize(): array - { - return $this->members; - } - }; - } - - private function builder(): Builder - { - return Server::builder()->setServerInfo('harness-server', '1.2.3'); - } - - private function track(Builder $builder, Options $options = new Options()): TrackedServer - { - $server = new TrackedServer(self::PROJECT, $options, McpSdkAdapter::serverInfo($builder), $this->publisher); - McpSdkAdapter::install($builder, $server, $this->publisher, new CallInterceptor($this->publisher)); - - return $server; - } - - private function harness(Builder $builder): HttpHarness - { - $harness = new HttpHarness($builder->build()); - $harness->initialize(); - - return $harness; - } - - private function echoTool(): Tool - { - return new Tool( - name: 'echo', - title: null, - inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']], - description: 'Echo', - annotations: null, - ); - } - - private static function tool(string $name, ?string $description = null): Tool - { - return new Tool(name: $name, title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: $description, annotations: null); - } - - /** @return list */ - private function names(\stdClass $result): array - { - $tools = $result->tools ?? null; - $this->assertIsArray($tools); - - return array_map(function (mixed $tool): string { - $name = JsonAssert::at($tool, 'name'); - $this->assertIsString($name); - - return $name; - }, array_values($tools)); - } - - /** @return list */ - private function keys(mixed $object): array - { - $this->assertInstanceOf(\stdClass::class, $object); - - return array_map(strval(...), array_keys(get_object_vars($object))); - } - - /** @return list */ - private function list(mixed $value): array - { - $this->assertIsList($value); - - return $value; - } - - private function object(mixed $value): \stdClass - { - $this->assertInstanceOf(\stdClass::class, $value); - - return $value; + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/ListToolsTestCase.php b/tests/Integration/McpSdk/ListToolsTestCase.php new file mode 100644 index 0000000..1a82ef9 --- /dev/null +++ b/tests/Integration/McpSdk/ListToolsTestCase.php @@ -0,0 +1,674 @@ +afterBuild()) { + self::markTestSkipped('Tracked after build(): ' . $why); + } + } + + private const string PROJECT = 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + private const string CONTEXT_DESCRIPTION = 'A description of your goal and what kind of tool would help accomplish it.'; + + private MemoryLogger $logger; + + private RecordingTransport $transport; + + private EventQueue $queue; + + private Publisher $publisher; + + #[\Override] + protected function setUp(): void + { + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + $this->transport = new RecordingTransport(); + $this->queue = new EventQueue(transport: $this->transport); + $this->publisher = new Publisher($this->queue); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-02-6')] + #[Group('ac-02-39')] + public function testEveryToolGetsTheHandlesAndTheContextAndGetMoreToolsComesLast(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($result)); + $schema = JsonAssert::at($result, 'tools', 0, 'inputSchema'); + $this->assertSame(['msg', 'session_id', 'context'], $this->keys(JsonAssert::at($schema, 'properties'))); + $this->assertSame(['msg', 'session_id', 'context'], JsonAssert::at($schema, 'required')); + $this->assertSame(Constants::SESSION_ID_PARAM_PATTERN, JsonAssert::at($schema, 'properties', 'session_id', 'pattern')); + $descriptor = GetMoreTools::descriptor(); + $this->assertSame($descriptor->description, JsonAssert::at($result, 'tools', 1, 'description')); + $this->assertEquals($descriptor->annotations, JsonAssert::at($result, 'tools', 1, 'annotations')); + } + + #[Group('ac-02-7')] + public function testAgentTrackingAddsARequiredAgentId(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(enableAgentTracking: true)); + + $result = $this->harness($builder)->listTools(); + + $echo = JsonAssert::at($result, 'tools', 0, 'inputSchema'); + $this->assertSame(['msg', 'session_id', 'agent_id', 'context'], $this->keys(JsonAssert::at($echo, 'properties'))); + $this->assertSame(['msg', 'session_id', 'agent_id', 'context'], JsonAssert::at($echo, 'required')); + $more = JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties'); + $this->assertSame(['context', 'session_id', 'agent_id'], $this->keys($more)); + $this->assertSame(self::CONTEXT_DESCRIPTION, JsonAssert::at($more, 'context', 'description')); + } + + public function testTheModernEraGetsTheSameInjectedListing(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + $harness = $this->harness($builder); + + $handshake = $harness->listTools(); + $modern = $harness->listToolsModern(); + + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($modern)); + $this->assertEquals($handshake->tools, $modern->tools); + $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 testOnlyTheLastPageGetsGetMoreToolsAndEachKeepsTheLibrarysCursor(): void + { + $build = function (bool $tracked): HttpHarness { + $builder = $this->builder()->setPaginationLimit(1) + ->add(self::tool('a'), new RecordingTool()) + ->add(self::tool('b'), new RecordingTool()); + if ($tracked) { + $this->track($builder); + } + + return $this->harness($builder); + }; + $twin = $build(false)->listTools(); + $harness = $build(true); + + $first = $harness->listTools(); + $this->assertIsString($first->nextCursor); + $second = $harness->listTools($first->nextCursor); + + $this->assertSame(['a'], $this->names($first)); + $this->assertSame($twin->nextCursor, $first->nextCursor); + if (static::entry()->afterBuild()) { + // After build(), get_more_tools is the registry's last tool: a full last page leaves it a page of its own. + $this->assertSame(['b'], $this->names($second)); + $this->assertIsString($second->nextCursor); + $third = $harness->listTools($second->nextCursor); + $this->assertSame([GetMoreTools::NAME], $this->names($third)); + $this->assertObjectNotHasProperty('nextCursor', $third); + + return; + } + $this->assertSame(['b', GetMoreTools::NAME], $this->names($second)); + $this->assertObjectNotHasProperty('nextCursor', $second); + } + + /** + * A customer's own get_more_tools on page 1: the process that served page 1 lists no second get_more_tools on the + * last page. A process that did not serve it (every request under PHP-FPM) lists AgentCat's there too. + */ + #[Group('ac-02-8')] + #[Group('ac-02-14')] + public function testACustomerGetMoreToolsOnAnEarlierPageIsTheOnlyOneTheProcessThatServedItLists(): void + { + $build = function (): HttpHarness { + $builder = $this->builder()->setPaginationLimit(1) + ->add(self::tool(GetMoreTools::NAME, 'Mine'), new RecordingTool()) + ->add(self::tool('b'), new RecordingTool()); + $this->track($builder); + + return $this->harness($builder); + }; + $harness = $build(); + + $first = $harness->listTools(); + $this->assertIsString($first->nextCursor); + $second = $harness->listTools($first->nextCursor); + $elsewhere = $build()->listTools($first->nextCursor); + + $this->assertSame([GetMoreTools::NAME], $this->names($first)); + $this->assertSame(['b'], $this->names($second)); + // After build(), AgentCat's get_more_tools takes the place of the customer's in the registry: every process lists it there. + $this->assertSame(static::entry()->afterBuild() ? ['b'] : ['b', GetMoreTools::NAME], $this->names($elsewhere)); + // After build(), each server warns when it is tracked, as it advertises all its tools then. + $this->assertSame(array_fill(0, static::entry()->afterBuild() ? 2 : 1, ListingLines::SHADOWED), array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'shadowed')))); + } + + #[Group('ac-02-9')] + public function testAToolAddedAfterInstallIsListedAndInjected(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + $builder->addTool(static fn(string $text): string => $text, name: 'late', description: 'Late'); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['echo', 'late', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['text', 'session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties'))); + } + + #[Group('ac-02-10')] + public function testAThrowingCustomerHandlerGivesTheSameErrorAsWithoutAgentCat(): void + { + $build = function (bool $tracked): HttpHarness { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + if ($tracked) { + $this->track($builder); + } + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static function (): never { + throw new \RuntimeException('list failed'); + })); + + return $this->harness($builder); + }; + $twin = $build(false)->request('tools/list'); + + $answer = $build(true)->request('tools/list'); + + $this->assertObjectHasProperty('error', $answer); + $this->assertEquals($twin->error, $answer->error); + $this->assertSame(0, $this->queue->stats()['queueLength']); + } + + public function testAJsonRpcErrorFromTheNextHandlerPassesUnchanged(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + $builder->addRequestHandler(new CustomerHandler( + ListToolsRequest::class, + static fn(Request $request): Error => Error::forInvalidParams('bad cursor', $request->getId()), + )); + + $answer = $this->harness($builder)->request('tools/list'); + + $this->assertEquals((object) ['code' => Error::INVALID_PARAMS, 'message' => 'bad cursor'], $answer->error); + } + + public function testAnAnswerThatIsNotAToolListPassesUnchanged(): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): CallToolResult => new CallToolResult([new TextContent('odd')]))); + + $result = $this->harness($builder)->listTools(); + + $this->assertEquals((object) ['content' => [(object) ['type' => 'text', 'text' => 'odd']], 'isError' => false], $result); + $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages(), 'TS listWrap: the answer has no tools array'); + } + + #[Group('ac-02-11')] + #[Group('ac-04-28')] + public function testAnEmptyListingIsServedAsIs(): void + { + $builder = $this->builder(); + $server = $this->track($builder); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame([], $result->tools); + $this->assertContains(ToolListInjector::NO_TOOLS_WARNING, $this->logger->messages()); + $this->assertNull($server->registry); + } + + #[Group('ac-02-14')] + #[Group('ac-04-24')] + public function testACustomerGetMoreToolsIsListedOnceAndWarnedAboutOnce(): void + { + $builder = $this->builder()->add(self::tool(GetMoreTools::NAME, 'Mine'), new RecordingTool()); + $this->track($builder); + $harness = $this->harness($builder); + + $harness->listTools(); + $result = $harness->listTools(); + + $this->assertSame([GetMoreTools::NAME], $this->names($result)); + $this->assertSame('Mine', JsonAssert::at($result, 'tools', 0, 'description')); + $this->assertCount(1, array_filter($this->logger->messages(), static fn(string $line): bool => str_contains($line, 'shadowed'))); + } + + #[Group('ac-02-15')] + #[Group('ac-01-38')] + #[Group('ac-01-83')] + public function testInitializeAndListingRunNoHookAndQueueNothing(): void + { + $runs = 0; + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(identify: static function () use (&$runs): null { + ++$runs; + + return null; + })); + + $this->harness($builder)->listTools(); + $this->queue->flush(); + + $this->assertSame(0, $runs); + $this->assertSame([], $this->transport->deliveries); + } + + #[Group('ac-04-21')] + public function testAToolThatDeclaresSessionIdKeepsItsOwnAndIsReportedOnce(): void + { + $own = ['type' => 'string', 'description' => 'mine']; + $builder = $this->builder()->add( + new Tool(name: 'own', title: null, inputSchema: ['type' => 'object', 'properties' => ['session_id' => $own], 'required' => ['session_id']], description: null, annotations: null), + new RecordingTool(), + ); + $this->track($builder); + $harness = $this->harness($builder); + + $harness->listTools(); + $result = $harness->listTools(); + + $this->assertEquals((object) $own, JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties', 'session_id')); + $prefix = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; + $this->assertCount(1, array_filter($this->logger->messages(), static fn(string $line): bool => str_starts_with($line, $prefix))); + } + + #[Group('ac-04-26')] + public function testAnOutputSchemaDeclaresMcpSessionAsOptional(): void + { + $builder = $this->builder()->add( + new Tool(name: 'echo', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: null, annotations: null, outputSchema: ['type' => 'object', 'properties' => ['echoed' => ['type' => 'string']]]), + new RecordingTool(), + ); + $server = $this->track($builder); + + $result = $this->harness($builder)->listTools(); + + $output = JsonAssert::at($result, 'tools', 0, 'outputSchema'); + $this->assertSame(['echoed', Constants::MCP_SESSION_KEY], $this->keys(JsonAssert::at($output, 'properties'))); + $this->assertObjectNotHasProperty('required', $this->object($output)); + $this->assertSame(['echo'], $server->outputRegistry); + } + + #[Group('ac-01-26')] + #[Group('ac-04-17')] + public function testHookModeInjectsNoSessionId(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(resolveSessionId: static fn(): string => 'mine')); + + $result = $this->harness($builder)->listTools(); + + foreach ([0, 1] as $index) { + $schema = JsonAssert::at($result, 'tools', $index, 'inputSchema'); + $this->assertNotContains('session_id', $this->keys(JsonAssert::at($schema, 'properties'))); + $this->assertNotContains('session_id', $this->list(JsonAssert::at($schema, 'required'))); + } + } + + #[Group('ac-01-14')] + #[Group('ac-04-27')] + public function testTracingOffInjectsNoHandlesButKeepsContextAndGetMoreTools(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(enableTracing: false, enableAgentTracking: true)); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['msg', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); + } + + #[Group('ac-01-16')] + public function testReportMissingOffListsNoGetMoreTools(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(enableReportMissing: false)); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['echo'], $this->names($result)); + $this->assertSame(['msg', 'session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); + } + + #[Group('ac-01-17')] + public function testContextOffInjectsNoContext(): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(enableToolCallContext: false)); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['msg', 'session_id'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); + } + + /** @return iterable */ + public static function contextDescriptions(): iterable + { + yield 'custom' => ['X', 'X']; + yield 'empty' => ['', Constants::DEFAULT_CONTEXT_PARAMETER_DESCRIPTION]; + } + + #[DataProvider('contextDescriptions')] + #[Group('ac-01-18')] + #[Group('ac-01-19')] + public function testTheContextDescriptionCanBeReplaced(string $custom, string $expected): void + { + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder, new Options(customContextDescription: $custom)); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame($expected, JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties', 'context', 'description')); + $this->assertSame(self::CONTEXT_DESCRIPTION, JsonAssert::at($result, 'tools', 1, 'inputSchema', 'properties', 'context', 'description')); + } + + /** @return iterable */ + public static function handlerMoments(): iterable + { + yield 'added before track()' => [true]; + yield 'added after track()' => [false]; + } + + /** A tools/list handler of the customer gets AgentCat's parameters, wherever track() runs. */ + #[DataProvider('handlerMoments')] + public function testACustomerListHandlerGetsTheInjection(bool $beforeTrack): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $customer = new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([self::tool('x')])); + if ($beforeTrack) { + $builder->addRequestHandler($customer); + } + $this->track($builder); + if (!$beforeTrack) { + $builder->addRequestHandler($customer); + } + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['x', GetMoreTools::NAME], $this->names($result)); + $this->assertSame(['session_id', 'context'], $this->keys(JsonAssert::at($result, 'tools', 0, 'inputSchema', 'properties'))); + } + + /** + * 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, ['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, array $names): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $build = function (bool $tracked) use ($cursor): HttpHarness { + $builder = $this->builder(); + if ($tracked) { + $this->track($builder); + } + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([self::tool('x')], $cursor))); + + return $this->harness($builder); + }; + $twin = $build(false)->listTools(); + + $result = $build(true)->listTools(); + + $this->assertSame($names, $this->names($result)); + $this->assertSame($this->keys($twin), $this->keys($result)); + $this->assertSame($twin->nextCursor ?? null, $result->nextCursor ?? null); + } + + public function testMembersALaterLibraryAddsToTheListingAreKept(): void + { + $listing = $this->listing(['tools' => [self::tool('x')], 'nextCursor' => 'c', '_meta' => ['source' => 'customer']]); + $twin = $this->customerListing(false, $listing); + + $result = $this->customerListing(true, $listing); + + $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); + } + + /** @return iterable}> */ + public static function listingsWithoutAToolList(): iterable + { + yield 'no tools member' => [['items' => [self::tool('x')]]]; + yield 'tools is an object' => [['tools' => ['x' => self::tool('x')]]]; + } + + /** @param array $members */ + #[DataProvider('listingsWithoutAToolList')] + public function testAListingWithoutAToolListOnTheWirePassesUnchanged(array $members): void + { + $listing = $this->listing($members); + $twin = $this->customerListing(false, $listing); + + $result = $this->customerListing(true, $listing); + + $this->assertEquals($twin, $result); + $this->assertSame([ToolListInjector::NO_TOOLS_WARNING], $this->logger->messages(), 'TS listWrap: `tools` is not an array'); + } + + public function testAFailedInjectionServesTheOriginalListing(): void + { + $this->beforeBuildOnly('a tools/list handler of the customer\'s'); + $tool = new class ('flaky', null, ['type' => 'object', 'properties' => [], 'required' => []], null, null) extends Tool { + private bool $served = false; + + #[\Override] + public function jsonSerialize(): array + { + if (!$this->served) { + $this->served = true; + + throw new \RuntimeException('tree failed'); + } + + return parent::jsonSerialize(); + } + }; + $builder = $this->builder(); + $this->track($builder); + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => new ListToolsResult([$tool]))); + + $result = $this->harness($builder)->listTools(); + + $this->assertSame(['flaky'], $this->names($result)); + $this->assertContains('Warning: AgentCat tool-list injection failed; serving the original tool list unmodified - tree failed', $this->logger->messages()); + } + + public function testAListingStartsTheDeliveryOfAWaitingEvent(): void + { + $this->beforeBuildOnly('a tools/list request'); + $this->transport->complete = false; + $builder = $this->builder()->add($this->echoTool(), new RecordingTool()); + $this->track($builder); + $harness = $this->harness($builder); + $this->queue->add(new QueuedEvent(new Event(sessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaaa', projectId: self::PROJECT, eventType: Constants::TOOLS_CALL_EVENT_TYPE), null, null, [])); + $this->assertSame(['queueLength' => 1, 'activeRequests' => 0], $this->queue->stats()); + + $harness->listTools(); + + $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $this->queue->stats()); + $this->assertCount(1, $this->transport->deliveries); + } + + /** The listing a server gets when a customer handler added after install() answers `tools/list`. */ + private function customerListing(bool $tracked, ListToolsResult $answer): \stdClass + { + $builder = $this->builder(); + if ($tracked) { + $this->track($builder); + } + $builder->addRequestHandler(new CustomerHandler(ListToolsRequest::class, static fn(): ListToolsResult => $answer)); + + return $this->harness($builder)->listTools(); + } + + /** + * A `ListToolsResult` whose wire members are exactly these, standing in for a later mcp/sdk whose result has + * more members (for example `_meta`) or another shape. + * + * @param array $members + */ + private function listing(array $members): ListToolsResult + { + return new class ($members) extends ListToolsResult { + /** @param array $members */ + public function __construct(private readonly array $members) + { + parent::__construct([]); + } + + /** @return array */ + #[\Override] // @phpstan-ignore method.childReturnType (stands in for a later ListToolsResult with other members) + public function jsonSerialize(): array + { + return $this->members; + } + }; + } + + private function builder(): Builder + { + return Server::builder()->setServerInfo('harness-server', '1.2.3'); + } + + private function track(Builder $builder, Options $options = new Options()): TrackedServer + { + $server = new TrackedServer(self::PROJECT, $options, McpSdkAdapter::serverInfo($builder), $this->publisher); + static::entry()->track($builder, $server, $this->publisher); + + return $server; + } + + private function harness(Builder $builder): HttpHarness + { + $harness = new HttpHarness(static::entry()->build($builder)); + $harness->initialize(); + + return $harness; + } + + private function echoTool(): Tool + { + return new Tool( + name: 'echo', + title: null, + inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']], + description: 'Echo', + annotations: null, + ); + } + + private static function tool(string $name, ?string $description = null): Tool + { + return new Tool(name: $name, title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => []], description: $description, annotations: null); + } + + /** @return list */ + private function names(\stdClass $result): array + { + $tools = $result->tools ?? null; + $this->assertIsArray($tools); + + return array_map(function (mixed $tool): string { + $name = JsonAssert::at($tool, 'name'); + $this->assertIsString($name); + + return $name; + }, array_values($tools)); + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } + + /** @return list */ + private function list(mixed $value): array + { + $this->assertIsList($value); + + return $value; + } + + private function object(mixed $value): \stdClass + { + $this->assertInstanceOf(\stdClass::class, $value); + + return $value; + } +} diff --git a/tests/Integration/McpSdk/ListingGoldenAfterBuildTest.php b/tests/Integration/McpSdk/ListingGoldenAfterBuildTest.php new file mode 100644 index 0000000..f3bd918 --- /dev/null +++ b/tests/Integration/McpSdk/ListingGoldenAfterBuildTest.php @@ -0,0 +1,17 @@ + */ - public static function configs(): iterable - { - $hook = static fn(): string => 'customer-session'; - - yield 'defaults' => ['defaults', new Options()]; - yield 'agent_tracking' => ['agent_tracking', new Options(enableAgentTracking: true)]; - yield 'hook_mode_agent_tracking' => ['hook_mode_agent_tracking', new Options(enableAgentTracking: true, resolveSessionId: $hook)]; - yield 'hook_mode_no_agent' => ['hook_mode_no_agent', new Options(resolveSessionId: $hook)]; - yield 'tracing_off' => ['tracing_off', new Options(enableTracing: false)]; - yield 'context_off_report_missing_off' => ['context_off_report_missing_off', new Options(enableReportMissing: false, enableToolCallContext: false)]; - yield 'custom_context_description' => ['custom_context_description', new Options(customContextDescription: 'Why?')]; - } - - #[DataProvider('configs')] - #[Group('ac-01-21')] - public function testTheInjectedListingMatchesTypeScriptInBothEras(string $key, Options $options): void - { - $fixture = Golden::json('handles/listing-fixtures.json'); - $this->assertInstanceOf(\stdClass::class, $fixture); - $golden = $fixture->{$key}; - $this->assertInstanceOf(\stdClass::class, $golden); - $expected = $this->comparable($golden->tools); - $harness = $this->harness($options); - - $this->assertSame($expected, $this->comparable($harness->listTools()->tools), 'handshake era'); - $this->assertSame($expected, $this->comparable($harness->listToolsModern()->tools), 'modern era'); - } - - private function harness(Options $options): HttpHarness - { - $builder = Server::builder()->setServerInfo('golden', '1.0.0'); - foreach ($this->inputTools() as $tool) { - $builder->add(Tool::fromArray($tool), new RecordingTool()); - } - $publisher = new Publisher(new EventQueue(transport: new RecordingTransport())); - McpSdkAdapter::install($builder, new TrackedServer('proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa', $options, McpSdkAdapter::serverInfo($builder), $publisher), $publisher, new CallInterceptor($publisher)); - $harness = new HttpHarness($builder->build()); - $harness->initialize(); - - return $harness; - } - - /** @return list the fixture's input tools that mcp/sdk can represent, as mcp/sdk tool data */ - private function inputTools(): array - { - $fixture = json_decode(Golden::text('handles/listing-fixtures.json'), true, flags: JSON_THROW_ON_ERROR); - $this->assertIsArray($fixture); - $this->assertIsList($fixture['input_tools'] ?? null); - /** @var list $tools decoded fixture data; Tool::fromArray() validates each entry */ - $tools = array_values(array_filter($fixture['input_tools'], static fn(mixed $tool): bool => is_array($tool) && ($tool['name'] ?? null) !== self::UNREPRESENTABLE)); - - return $tools; - } - - /** The tools without the unrepresentable one, top-level keys sorted, encoded: nested order still counts. */ - private function comparable(mixed $tools): string - { - $this->assertIsList($tools); - $sorted = []; - foreach ($tools as $tool) { - $this->assertInstanceOf(\stdClass::class, $tool); - if (($tool->name ?? null) === self::UNREPRESENTABLE) { - continue; - } - $members = get_object_vars($tool); - ksort($members, SORT_STRING); - $sorted[] = (object) $members; - } - $encoded = Json::encodeJs($sorted); - $this->assertNotNull($encoded); - - return $encoded; + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/ListingGoldenTestCase.php b/tests/Integration/McpSdk/ListingGoldenTestCase.php new file mode 100644 index 0000000..f849c89 --- /dev/null +++ b/tests/Integration/McpSdk/ListingGoldenTestCase.php @@ -0,0 +1,136 @@ + */ + public static function configs(): iterable + { + $hook = static fn(): string => 'customer-session'; + + yield 'defaults' => ['defaults', new Options()]; + yield 'agent_tracking' => ['agent_tracking', new Options(enableAgentTracking: true)]; + yield 'hook_mode_agent_tracking' => ['hook_mode_agent_tracking', new Options(enableAgentTracking: true, resolveSessionId: $hook)]; + yield 'hook_mode_no_agent' => ['hook_mode_no_agent', new Options(resolveSessionId: $hook)]; + yield 'tracing_off' => ['tracing_off', new Options(enableTracing: false)]; + yield 'context_off_report_missing_off' => ['context_off_report_missing_off', new Options(enableReportMissing: false, enableToolCallContext: false)]; + yield 'custom_context_description' => ['custom_context_description', new Options(customContextDescription: 'Why?')]; + } + + #[DataProvider('configs')] + #[Group('ac-01-21')] + public function testTheInjectedListingMatchesTypeScriptInBothEras(string $key, Options $options): void + { + $fixture = Golden::json('handles/listing-fixtures.json'); + $this->assertInstanceOf(\stdClass::class, $fixture); + $golden = $fixture->{$key}; + $this->assertInstanceOf(\stdClass::class, $golden); + $expected = $this->comparable($golden->tools); + $harness = $this->harness($options); + + $this->assertSame($expected, $this->comparable($harness->listTools()->tools), 'handshake era'); + $this->assertSame($expected, $this->comparable($harness->listToolsModern()->tools), 'modern era'); + } + + private function harness(Options $options): HttpHarness + { + $builder = Server::builder()->setServerInfo('golden', '1.0.0'); + foreach ($this->inputTools() as $tool) { + $builder->add(Tool::fromArray($tool), new RecordingTool()); + } + $publisher = new Publisher(new EventQueue(transport: new RecordingTransport())); + static::entry()->track($builder, new TrackedServer('proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa', $options, McpSdkAdapter::serverInfo($builder), $publisher), $publisher); + $harness = new HttpHarness(static::entry()->build($builder)); + $harness->initialize(); + + return $harness; + } + + /** @return list the fixture's input tools that mcp/sdk can represent, as mcp/sdk tool data */ + private function inputTools(): array + { + $fixture = json_decode(Golden::text('handles/listing-fixtures.json'), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($fixture); + $this->assertIsList($fixture['input_tools'] ?? null); + /** @var list $tools decoded fixture data; Tool::fromArray() validates each entry */ + $tools = array_values(array_filter($fixture['input_tools'], static fn(mixed $tool): bool => is_array($tool) && ($tool['name'] ?? null) !== self::UNREPRESENTABLE)); + + return $tools; + } + + /** The tools without the unrepresentable one, top-level keys sorted, encoded: nested order still counts. */ + private function comparable(mixed $tools): string + { + $this->assertIsList($tools); + $sorted = []; + foreach ($tools as $tool) { + $this->assertInstanceOf(\stdClass::class, $tool); + if (($tool->name ?? null) === self::UNREPRESENTABLE) { + continue; + } + $members = get_object_vars($tool); + ksort($members, SORT_STRING); + $sorted[] = (object) $members; + } + $encoded = Json::encodeJs($sorted); + $this->assertNotNull($encoded); + + return $encoded; + } +} diff --git a/tests/Integration/McpSdk/McpSdkAdapterTest.php b/tests/Integration/McpSdk/McpSdkAdapterTest.php index fdc2ced..28b7241 100644 --- a/tests/Integration/McpSdk/McpSdkAdapterTest.php +++ b/tests/Integration/McpSdk/McpSdkAdapterTest.php @@ -7,9 +7,12 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; use AgentCat\McpSdk\McpSdkAdapter; +use AgentCat\Tests\Support\McpSdk\BuilderMembers; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Thrown; +use Mcp\Capability\RegistryInterface; use Mcp\Server; +use Mcp\Server\Handler\Request\InitializeHandler; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Log\LogLevel; @@ -17,7 +20,7 @@ final class McpSdkAdapterTest extends TestCase { - private const string MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()).'; + private const string MATRIX = 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder or the Mcp\Server built from it to AgentCat::track()).'; private MemoryLogger $logger; @@ -42,12 +45,12 @@ public static function servers(): iterable yield 'Builder' => [Server::builder(), true]; yield 'built Builder' => [$built, true]; - yield 'Server' => [Server::builder()->build(), false]; + yield 'Server' => [Server::builder()->build(), true]; yield 'anything else' => [new \stdClass(), false]; } #[DataProvider('servers')] - public function testOnlyABuilderIsSupported(object $server, bool $supported): void + public function testABuilderAndAServerAreSupported(object $server, bool $supported): void { $this->assertSame($supported, McpSdkAdapter::supports($server)); } @@ -77,15 +80,59 @@ public function testAFreshBuilderPassesInspection(): void $this->assertSame([], $this->logger->messages()); } - public function testABuiltBuilderFailsInspection(): void + public function testABuiltBuilderAndABuiltServerPassInspection(): void + { + $builder = Server::builder(); + $server = $builder->build(); + + McpSdkAdapter::inspect($builder); + McpSdkAdapter::inspect($server); + + $this->assertSame([], $this->logger->messages()); + } + + public function testABuilderWhoseHandlerListCannotBeReadFailsInspection(): void + { + $builder = Server::builder(); + BuilderMembers::uninitialize('requestHandlers')($builder); + + $error = Thrown::by(\UnexpectedValueException::class, static fn() => McpSdkAdapter::inspect($builder)); + + $this->assertSame(sprintf('AgentCat SDK compatibility error: mcp/sdk %s internals changed (Error: Typed property Mcp\Server\Builder::$requestHandlers must not be accessed before initialization). %s', McpSdkAdapter::version(), self::MATRIX), $error->getMessage()); + } + + public function testABuiltBuilderWithoutTheLibrarysCallHandlerFailsInspection(): void { $builder = Server::builder(); $builder->build(); + $parts = BuilderMembers::reader('parts')($builder); + $this->assertIsArray($parts); + $parts['requestHandlers'] = []; + BuilderMembers::writer('parts')($builder, $parts); $error = Thrown::by(\UnexpectedValueException::class, static fn() => McpSdkAdapter::inspect($builder)); - $this->assertSame('AgentCat SDK compatibility error: the mcp/sdk Builder was already built. ' . self::MATRIX, $error->getMessage()); - $this->assertNull($error->getPrevious()); + $this->assertSame('AgentCat SDK compatibility error: the mcp/sdk server has no tools/call handler of its own. ' . self::MATRIX, $error->getMessage()); + } + + /** The first `initialize` handler names the server; one without a configuration names none, and nothing drifted. */ + public function testABuiltServerWhoseInitializeHandlerHasNoConfigurationHasNoName(): void + { + $server = Server::builder()->setServerInfo('named', '1.0.0')->addRequestHandler(new InitializeHandler())->build(); + + $this->assertSame([null, null], McpSdkAdapter::serverInfo($server)()); + $this->assertSame(['named', '1.0.0'], McpSdkAdapter::serverInfo(Server::builder()->setServerInfo('named', '1.0.0')->build())()); + $this->assertSame([], $this->logger->messages()); + } + + public function testAServerWithARegistryOfItsOwnFailsInspection(): void + { + $registry = self::createStub(RegistryInterface::class); + $server = Server::builder()->setRegistry($registry)->build(); + + $error = Thrown::by(\UnexpectedValueException::class, static fn() => McpSdkAdapter::inspect($server)); + + $this->assertSame('AgentCat SDK compatibility error: the mcp/sdk server has a registry of its own (' . $registry::class . '). ' . self::MATRIX, $error->getMessage()); $this->assertSame([['level' => LogLevel::WARNING, 'message' => 'AgentCat SDK Compatibility: ' . self::MATRIX]], $this->logger->records); } diff --git a/tests/Integration/McpSdk/McpSdkLibraryTest.php b/tests/Integration/McpSdk/McpSdkLibraryTest.php index 3cf7bd4..0784188 100644 --- a/tests/Integration/McpSdk/McpSdkLibraryTest.php +++ b/tests/Integration/McpSdk/McpSdkLibraryTest.php @@ -19,13 +19,14 @@ final class McpSdkLibraryTest extends TestCase { - public function testTheLibraryAnswersForTheBuilder(): void + public function testTheLibraryAnswersForTheBuilderAndTheBuiltServer(): void { $library = new McpSdkLibrary(); $builder = Server::builder(); $this->assertTrue($library->supports($builder)); - $this->assertFalse($library->supports(Server::builder()->build())); + $this->assertTrue($library->supports(Server::builder()->build())); + $this->assertFalse($library->supports(new \stdClass())); $this->assertSame('mcp-sdk/builder', $library->sdk($builder)); $this->assertSame('mcp-sdk/server', $library->sdk(Server::builder()->build())); $this->assertSame(McpSdkAdapter::SUPPORT_MATRIX, $library->supportMatrix()); @@ -53,6 +54,13 @@ public function testServerInfoReadsTheBuilder(): void $this->assertSame(['library-server', '2.0.0'], $info()); } + public function testServerInfoReadsTheBuiltServer(): void + { + $info = (new McpSdkLibrary())->serverInfo(Server::builder()->setServerInfo('built-server', '3.0.0')->build()); + + $this->assertSame(['built-server', '3.0.0'], $info()); + } + public function testTheBuilderStepsRefuseAnyOtherObject(): void { $library = new McpSdkLibrary(); @@ -66,7 +74,7 @@ public function testTheBuilderStepsRefuseAnyOtherObject(): void 'install' => static fn() => $library->install($object, $state, $publisher, new CallInterceptor($publisher)), ]; foreach ($steps as $step => $run) { - $this->assertSame('Not an mcp/sdk Builder: stdClass', Thrown::by(\UnexpectedValueException::class, $run)->getMessage(), $step); + $this->assertSame('Not an mcp/sdk Builder or server: stdClass', Thrown::by(\UnexpectedValueException::class, $run)->getMessage(), $step); } } } diff --git a/tests/Integration/McpSdk/RequestContextAfterBuildTest.php b/tests/Integration/McpSdk/RequestContextAfterBuildTest.php new file mode 100644 index 0000000..4541431 --- /dev/null +++ b/tests/Integration/McpSdk/RequestContextAfterBuildTest.php @@ -0,0 +1,17 @@ +fixture = new TrackingFixture(); - } - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - #[Group('ac-02-44')] - #[Group('ac-01-81')] - public function testClientInfoInMetaBeatsTheHandshakeFieldByField(): void - { - $harness = $this->tracked(); - - $harness->callTool('echo', ['msg' => 'hi'], [Constants::META_CLIENT_INFO_KEY => ['name' => 'meta-client', 'version' => 7]]); - $harness->callTool('echo', ['msg' => 'hi']); - $harness->callToolModern('echo', ['msg' => 'hi']); - - $meta = $this->fixture->event(0, of: 3); - $handshake = $this->fixture->event(1, of: 3); - $modern = $this->fixture->event(2, of: 3); - $this->assertSame(['meta-client', null], [$meta->clientName, $meta->clientVersion]); - $this->assertSame(['harness-client', '1.0.0'], [$handshake->clientName, $handshake->clientVersion]); - $this->assertSame(['modern-client', '2.0.0'], [$modern->clientName, $modern->clientVersion]); - } - - #[Group('ac-02-45')] - #[Group('ac-01-82')] - public function testTheProtocolTagComesFromTheCallNeverFromTheHandshake(): void - { - $harness = $this->tracked(); - - // mcp/sdk rejects a handshake call whose claim differs from its MCP-Protocol-Version header. - $harness->callTool('echo', ['msg' => 'hi'], [Constants::META_PROTOCOL_VERSION_KEY => '2025-06-18']); - $harness->callTool('echo', ['msg' => 'hi']); - $harness->callToolModern('echo', ['msg' => 'hi']); - - $claimed = $this->fixture->event(0, of: 3); - $plain = $this->fixture->event(1, of: 3); - $modern = $this->fixture->event(2, of: 3); - $this->assertSame('2025-06-18', $this->tag($claimed, Constants::TAG_PROTOCOL_VERSION)); - $this->assertArrayNotHasKey(Constants::TAG_PROTOCOL_VERSION, $plain->tags ?? [], 'the negotiated version is not a claim'); - $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, $this->tag($modern, Constants::TAG_PROTOCOL_VERSION)); - } - - #[Group('ac-02-46')] - public function testExtraCarriesTheTransportSessionOfTheHandshakeEraOnly(): void - { - $harness = $this->tracked(); - - $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); - $harness->callToolModern('echo', ['msg' => 'hi', 'session_id' => 'start']); - - $handshake = $this->fixture->event(0, of: 2); - $modern = $this->fixture->event(1, of: 2); - $transportSession = $harness->sessionId(); - $this->assertIsString($transportSession); - $this->assertSame($transportSession, JsonAssert::at($handshake->parameters, 'extra', 'sessionId')); - $this->assertNotSame($transportSession, $handshake->sessionId); - $this->assertObjectNotHasProperty('sessionId', $this->extra($modern)); - } - - public function testExtraHasTheD34KeysInOrderAndNoRequestInfoOnTheCommandLine(): void - { - $harness = $this->tracked(); - - $harness->callTool('echo', ['msg' => 'hi']); - $harness->callTool('echo', ['msg' => 'hi'], ['progressToken' => 'p1']); - $harness->callToolModern('echo', ['msg' => 'hi']); - - $plain = $this->fixture->event(0, of: 3); - $meta = $this->fixture->event(1, of: 3); - $modern = $this->fixture->event(2, of: 3); - $this->assertSame(['sessionId', 'requestId'], $this->keys($this->extra($plain))); - $this->assertIsInt(JsonAssert::at($plain->parameters, 'extra', 'requestId')); - $this->assertSame(['sessionId', 'requestId', '_meta'], $this->keys($this->extra($meta))); - $this->assertSame('{"progressToken":"p1"}', $this->json(JsonAssert::at($meta->parameters, 'extra', '_meta'))); - $this->assertSame('p1', JsonAssert::at($meta->parameters, 'request', 'params', '_meta', 'progressToken')); - $this->assertSame(['requestId', '_meta'], $this->keys($this->extra($modern))); - } - - public function testTheReaderRecordsTheHttpRequestForTheEventAndTheHooks(): void - { - $reader = new RequestReader(static fn(): HttpContext => new HttpContext('POST', 'http://x/mcp', ['x-agentcat-health-run' => 'S'])); - $session = new Session(new InMemorySessionStore()); - $request = (new CallToolRequest('echo', ['msg' => 'hi']))->withId(9)->withMeta(['progressToken' => 'p1']); - - $context = $reader->read($request, $session); - - $this->assertSame(['sessionId', 'requestId', '_meta', 'requestInfo'], $this->keys($context->extra)); - $this->assertSame((string) $session->getId(), $context->extra->sessionId); - $this->assertSame(9, $context->extra->requestId); - $this->assertSame('{"headers":{"x-agentcat-health-run":"S"},"url":"http://x/mcp"}', $this->json($context->extra->requestInfo)); - $this->assertSame(['x-agentcat-health-run' => 'S'], $context->toolCall->headers); - } - - public function testTheReaderBuildsTheRequestTreeAndTheToolCall(): void - { - $session = new Session(new InMemorySessionStore()); - $session->set('client_info', ['name' => 'harness-client', 'version' => '1.0.0']); - $session->set('protocol_version', '2025-06-18'); - $request = (new CallToolRequest('echo', ['msg' => 'hi', 'context' => 'why']))->withId('r1')->withMeta(['progressToken' => 'p1']); - - $context = (new RequestReader(static fn(): ?HttpContext => null))->read($request, $session); - - $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","context":"why"},"_meta":{"progressToken":"p1"}}}', $this->json($context->request)); - $call = $context->toolCall; - $this->assertSame('echo', $call->toolName); - $this->assertSame(['msg' => 'hi', 'context' => 'why'], $call->arguments); - $this->assertSame(['progressToken' => 'p1'], $call->meta); - $this->assertSame([], $call->headers); - $this->assertSame(['harness-client', '1.0.0'], [$call->clientName, $call->clientVersion]); - $this->assertSame(['harness-client', '1.0.0'], [$context->clientName, $context->clientVersion]); - $this->assertNull($call->protocolVersion, 'the negotiated version is not a claim'); - $this->assertNull($context->protocolVersion); - $this->assertSame((string) $session->getId(), $call->transportSessionId); - $this->assertSame($request, $call->request); - $this->assertSame($session, $call->context); - $this->assertFalse($context->continuation); - } - - public function testAnEmptyMetaIsRecordedAsAnEmptyObject(): void - { - $harness = $this->tracked(); - - $harness->callTool('echo', ['msg' => 'hi'], []); - - $parameters = $this->fixture->event()->parameters; - $this->assertSame('{}', $this->json(JsonAssert::at($parameters, 'request', 'params', '_meta'))); - $this->assertSame('{}', $this->json(JsonAssert::at($parameters, 'extra', '_meta'))); - } - - public function testTheReaderKeepsIntegerArgumentKeysAndEmptyArgumentsAsAnObject(): void - { - $reader = new RequestReader(static fn(): ?HttpContext => null); - $session = new Session(new InMemorySessionStore()); - - $listed = $reader->read(CallToolRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['zero', 'one']]]), $session); - $empty = $reader->read((new CallToolRequest('echo', []))->withId(2), $session); - $emptyMeta = $reader->read((new CallToolRequest('echo', []))->withId(3)->withMeta([]), $session); - - $this->assertSame('{"0":"zero","1":"one"}', $this->json(JsonAssert::at($listed->request, 'params', 'arguments')), 'N8'); - $this->assertSame(['zero', 'one'], $listed->toolCall->arguments); - $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{}}}', $this->json($empty->request)); - $this->assertSame(['sessionId', 'requestId'], $this->keys($empty->extra), 'no _meta when none was sent'); - $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{},"_meta":{}}}', $this->json($emptyMeta->request)); - } - - public function testTheModernEraReadsTheRequestMeta(): void - { - $session = new Session(new InMemorySessionStore()); - $session->set(RequestMeta::class, new RequestMeta('2026-07-28', new ClientCapabilities(), new Implementation('modern-client', '2.0.0'))); - $session->set('protocol_version', '2026-07-28'); - $request = (new CallToolRequest('echo', []))->withId(3)->withMeta([ - Constants::META_PROTOCOL_VERSION_KEY => '2099-01-01', - Constants::META_CLIENT_INFO_KEY => ['name' => 'meta-client', 'version' => '9'], - ]); - - $context = (new RequestReader(static fn(): ?HttpContext => null))->read($request, $session); - - $this->assertSame('2026-07-28', $context->protocolVersion); - $this->assertSame('2026-07-28', $context->toolCall->protocolVersion); - $this->assertSame(['modern-client', '2.0.0'], [$context->clientName, $context->clientVersion]); - $this->assertNull($context->toolCall->transportSessionId); - $this->assertSame(['requestId', '_meta'], $this->keys($context->extra)); - } - - public function testAProtocolClaimInMetaMustBeANonEmptyString(): void - { - $reader = new RequestReader(static fn(): ?HttpContext => null); - $session = new Session(new InMemorySessionStore()); - $read = static fn(mixed $claim): ?string => $reader->read( - (new CallToolRequest('echo', []))->withId(1)->withMeta([Constants::META_PROTOCOL_VERSION_KEY => $claim]), - $session, - )->protocolVersion; - - $this->assertSame('2025-06-18', $read('2025-06-18')); - $this->assertNull($read('')); - $this->assertNull($read(20250618)); - } - - /** `authInfo` exists when the session holds an access token (mcp/sdk main), read by duck typing. */ - public function testExtraCarriesTheAccessTokenAsAuthInfo(): void - { - $session = new Session(new InMemorySessionStore()); - $session->set('Mcp\Server\Authorization\AccessToken', new class { - /** @return list */ - public function getScopes(): array - { - return ['read', 'write']; - } - - /** @return array */ - public function getClaims(): array - { - return ['sub' => 'u1', 'aud' => ['mcp']]; - } - }); - $reader = new RequestReader(static fn(): HttpContext => new HttpContext('POST', null, [])); - - $context = $reader->read((new CallToolRequest('echo', []))->withId(1), $session); - - $this->assertSame(['sessionId', 'requestId', 'requestInfo', 'authInfo'], $this->keys($context->extra)); - $this->assertSame('{"scopes":["read","write"],"claims":{"sub":"u1","aud":["mcp"]}}', $this->json($context->extra->authInfo)); - $this->assertSame('{"headers":{}}', $this->json($context->extra->requestInfo)); - } - - public function testAnAccessTokenWithoutClaimsIsAnEmptyObjectAndItsScopesAList(): void - { - $session = new Session(new InMemorySessionStore()); - $session->set('Mcp\Server\Authorization\AccessToken', new class { - /** @return array */ - public function getScopes(): array - { - return [2 => 'read']; - } - - /** @return array */ - public function getClaims(): array - { - return []; - } - }); - - $context = (new RequestReader(static fn(): ?HttpContext => null))->read((new CallToolRequest('echo', []))->withId(1), $session); - - $this->assertSame('{"scopes":["read"],"claims":{}}', $this->json($context->extra->authInfo)); - } - - public function testNoAuthInfoWithoutAnAccessToken(): void - { - $reader = new RequestReader(static fn(): ?HttpContext => null); - $request = (new CallToolRequest('echo', []))->withId(1); - $none = new Session(new InMemorySessionStore()); - $notAToken = new Session(new InMemorySessionStore()); - $notAToken->set('Mcp\Server\Authorization\AccessToken', new class { - /** @return list */ - public function getScopes(): array - { - return ['read']; - } - }); - - $this->assertObjectNotHasProperty('authInfo', $reader->read($request, $none)->extra); - $this->assertObjectNotHasProperty('authInfo', $reader->read($request, $notAToken)->extra); - } - - #[Group('ac-02-35')] - public function testARoundWithInputResponsesIsAContinuation(): void - { - $reader = new RequestReader(static fn(): ?HttpContext => null); - $request = (new CallToolRequest('echo', []))->withId(1); - $retry = new Session(new InMemorySessionStore()); - $retry->set(InputContext::class, new InputContext()); - - $this->assertTrue($reader->read($request, $retry)->continuation); - $this->assertFalse($reader->read($request, new Session(new InMemorySessionStore()))->continuation); - } - - private function tracked(): HttpHarness - { - $builder = TrackingFixture::builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - return $harness; - } - - private function extra(Event $event): \stdClass - { - $extra = JsonAssert::at($event->parameters, 'extra'); - $this->assertInstanceOf(\stdClass::class, $extra); - - return $extra; - } - - private function tag(Event $event, string $key): ?string - { - return $event->tags[$key] ?? null; - } - - /** @return list */ - private function keys(mixed $object): array - { - $this->assertInstanceOf(\stdClass::class, $object); - - return array_map(strval(...), array_keys(get_object_vars($object))); - } - - private function json(mixed $value): string + protected static function entry(): Entry { - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/RequestContextTestCase.php b/tests/Integration/McpSdk/RequestContextTestCase.php new file mode 100644 index 0000000..b935614 --- /dev/null +++ b/tests/Integration/McpSdk/RequestContextTestCase.php @@ -0,0 +1,334 @@ +fixture = new TrackingFixture(static::entry()); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-02-44')] + #[Group('ac-01-81')] + public function testClientInfoInMetaBeatsTheHandshakeFieldByField(): void + { + $harness = $this->tracked(); + + $harness->callTool('echo', ['msg' => 'hi'], [Constants::META_CLIENT_INFO_KEY => ['name' => 'meta-client', 'version' => 7]]); + $harness->callTool('echo', ['msg' => 'hi']); + $harness->callToolModern('echo', ['msg' => 'hi']); + + $meta = $this->fixture->event(0, of: 3); + $handshake = $this->fixture->event(1, of: 3); + $modern = $this->fixture->event(2, of: 3); + $this->assertSame(['meta-client', null], [$meta->clientName, $meta->clientVersion]); + $this->assertSame(['harness-client', '1.0.0'], [$handshake->clientName, $handshake->clientVersion]); + $this->assertSame(['modern-client', '2.0.0'], [$modern->clientName, $modern->clientVersion]); + } + + #[Group('ac-02-45')] + #[Group('ac-01-82')] + public function testTheProtocolTagComesFromTheCallNeverFromTheHandshake(): void + { + $harness = $this->tracked(); + + // mcp/sdk rejects a handshake call whose claim differs from its MCP-Protocol-Version header. + $harness->callTool('echo', ['msg' => 'hi'], [Constants::META_PROTOCOL_VERSION_KEY => '2025-06-18']); + $harness->callTool('echo', ['msg' => 'hi']); + $harness->callToolModern('echo', ['msg' => 'hi']); + + $claimed = $this->fixture->event(0, of: 3); + $plain = $this->fixture->event(1, of: 3); + $modern = $this->fixture->event(2, of: 3); + $this->assertSame('2025-06-18', $this->tag($claimed, Constants::TAG_PROTOCOL_VERSION)); + $this->assertArrayNotHasKey(Constants::TAG_PROTOCOL_VERSION, $plain->tags ?? [], 'the negotiated version is not a claim'); + $this->assertSame(HttpHarness::MODERN_PROTOCOL_VERSION, $this->tag($modern, Constants::TAG_PROTOCOL_VERSION)); + } + + #[Group('ac-02-46')] + public function testExtraCarriesTheTransportSessionOfTheHandshakeEraOnly(): void + { + $harness = $this->tracked(); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + $harness->callToolModern('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $handshake = $this->fixture->event(0, of: 2); + $modern = $this->fixture->event(1, of: 2); + $transportSession = $harness->sessionId(); + $this->assertIsString($transportSession); + $this->assertSame($transportSession, JsonAssert::at($handshake->parameters, 'extra', 'sessionId')); + $this->assertNotSame($transportSession, $handshake->sessionId); + $this->assertObjectNotHasProperty('sessionId', $this->extra($modern)); + } + + public function testExtraHasTheD34KeysInOrderAndNoRequestInfoOnTheCommandLine(): void + { + $harness = $this->tracked(); + + $harness->callTool('echo', ['msg' => 'hi']); + $harness->callTool('echo', ['msg' => 'hi'], ['progressToken' => 'p1']); + $harness->callToolModern('echo', ['msg' => 'hi']); + + $plain = $this->fixture->event(0, of: 3); + $meta = $this->fixture->event(1, of: 3); + $modern = $this->fixture->event(2, of: 3); + $this->assertSame(['sessionId', 'requestId'], $this->keys($this->extra($plain))); + $this->assertIsInt(JsonAssert::at($plain->parameters, 'extra', 'requestId')); + $this->assertSame(['sessionId', 'requestId', '_meta'], $this->keys($this->extra($meta))); + $this->assertSame('{"progressToken":"p1"}', $this->json(JsonAssert::at($meta->parameters, 'extra', '_meta'))); + $this->assertSame('p1', JsonAssert::at($meta->parameters, 'request', 'params', '_meta', 'progressToken')); + $this->assertSame(['requestId', '_meta'], $this->keys($this->extra($modern))); + } + + public function testTheReaderRecordsTheHttpRequestForTheEventAndTheHooks(): void + { + $reader = new RequestReader(static fn(): HttpContext => new HttpContext('POST', 'http://x/mcp', ['x-agentcat-health-run' => 'S'])); + $session = new Session(new InMemorySessionStore()); + $request = (new CallToolRequest('echo', ['msg' => 'hi']))->withId(9)->withMeta(['progressToken' => 'p1']); + + $context = $reader->read($request, $session); + + $this->assertSame(['sessionId', 'requestId', '_meta', 'requestInfo'], $this->keys($context->extra)); + $this->assertSame((string) $session->getId(), $context->extra->sessionId); + $this->assertSame(9, $context->extra->requestId); + $this->assertSame('{"headers":{"x-agentcat-health-run":"S"},"url":"http://x/mcp"}', $this->json($context->extra->requestInfo)); + $this->assertSame(['x-agentcat-health-run' => 'S'], $context->toolCall->headers); + } + + public function testTheReaderBuildsTheRequestTreeAndTheToolCall(): void + { + $session = new Session(new InMemorySessionStore()); + $session->set('client_info', ['name' => 'harness-client', 'version' => '1.0.0']); + $session->set('protocol_version', '2025-06-18'); + $request = (new CallToolRequest('echo', ['msg' => 'hi', 'context' => 'why']))->withId('r1')->withMeta(['progressToken' => 'p1']); + + $context = (new RequestReader(static fn(): ?HttpContext => null))->read($request, $session); + + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi","context":"why"},"_meta":{"progressToken":"p1"}}}', $this->json($context->request)); + $call = $context->toolCall; + $this->assertSame('echo', $call->toolName); + $this->assertSame(['msg' => 'hi', 'context' => 'why'], $call->arguments); + $this->assertSame(['progressToken' => 'p1'], $call->meta); + $this->assertSame([], $call->headers); + $this->assertSame(['harness-client', '1.0.0'], [$call->clientName, $call->clientVersion]); + $this->assertSame(['harness-client', '1.0.0'], [$context->clientName, $context->clientVersion]); + $this->assertNull($call->protocolVersion, 'the negotiated version is not a claim'); + $this->assertNull($context->protocolVersion); + $this->assertSame((string) $session->getId(), $call->transportSessionId); + $this->assertSame($request, $call->request); + $this->assertSame($session, $call->context); + $this->assertFalse($context->continuation); + } + + public function testAnEmptyMetaIsRecordedAsAnEmptyObject(): void + { + $harness = $this->tracked(); + + $harness->callTool('echo', ['msg' => 'hi'], []); + + $parameters = $this->fixture->event()->parameters; + $this->assertSame('{}', $this->json(JsonAssert::at($parameters, 'request', 'params', '_meta'))); + $this->assertSame('{}', $this->json(JsonAssert::at($parameters, 'extra', '_meta'))); + } + + public function testTheReaderKeepsIntegerArgumentKeysAndEmptyArgumentsAsAnObject(): void + { + $reader = new RequestReader(static fn(): ?HttpContext => null); + $session = new Session(new InMemorySessionStore()); + + $listed = $reader->read(CallToolRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['zero', 'one']]]), $session); + $empty = $reader->read((new CallToolRequest('echo', []))->withId(2), $session); + $emptyMeta = $reader->read((new CallToolRequest('echo', []))->withId(3)->withMeta([]), $session); + + $this->assertSame('{"0":"zero","1":"one"}', $this->json(JsonAssert::at($listed->request, 'params', 'arguments')), 'N8'); + $this->assertSame(['zero', 'one'], $listed->toolCall->arguments); + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{}}}', $this->json($empty->request)); + $this->assertSame(['sessionId', 'requestId'], $this->keys($empty->extra), 'no _meta when none was sent'); + $this->assertSame('{"method":"tools/call","params":{"name":"echo","arguments":{},"_meta":{}}}', $this->json($emptyMeta->request)); + } + + public function testTheModernEraReadsTheRequestMeta(): void + { + $session = new Session(new InMemorySessionStore()); + $session->set(RequestMeta::class, new RequestMeta('2026-07-28', new ClientCapabilities(), new Implementation('modern-client', '2.0.0'))); + $session->set('protocol_version', '2026-07-28'); + $request = (new CallToolRequest('echo', []))->withId(3)->withMeta([ + Constants::META_PROTOCOL_VERSION_KEY => '2099-01-01', + Constants::META_CLIENT_INFO_KEY => ['name' => 'meta-client', 'version' => '9'], + ]); + + $context = (new RequestReader(static fn(): ?HttpContext => null))->read($request, $session); + + $this->assertSame('2026-07-28', $context->protocolVersion); + $this->assertSame('2026-07-28', $context->toolCall->protocolVersion); + $this->assertSame(['modern-client', '2.0.0'], [$context->clientName, $context->clientVersion]); + $this->assertNull($context->toolCall->transportSessionId); + $this->assertSame(['requestId', '_meta'], $this->keys($context->extra)); + } + + public function testAProtocolClaimInMetaMustBeANonEmptyString(): void + { + $reader = new RequestReader(static fn(): ?HttpContext => null); + $session = new Session(new InMemorySessionStore()); + $read = static fn(mixed $claim): ?string => $reader->read( + (new CallToolRequest('echo', []))->withId(1)->withMeta([Constants::META_PROTOCOL_VERSION_KEY => $claim]), + $session, + )->protocolVersion; + + $this->assertSame('2025-06-18', $read('2025-06-18')); + $this->assertNull($read('')); + $this->assertNull($read(20250618)); + } + + /** `authInfo` exists when the session holds an access token (mcp/sdk main), read by duck typing. */ + public function testExtraCarriesTheAccessTokenAsAuthInfo(): void + { + $session = new Session(new InMemorySessionStore()); + $session->set('Mcp\Server\Authorization\AccessToken', new class { + /** @return list */ + public function getScopes(): array + { + return ['read', 'write']; + } + + /** @return array */ + public function getClaims(): array + { + return ['sub' => 'u1', 'aud' => ['mcp']]; + } + }); + $reader = new RequestReader(static fn(): HttpContext => new HttpContext('POST', null, [])); + + $context = $reader->read((new CallToolRequest('echo', []))->withId(1), $session); + + $this->assertSame(['sessionId', 'requestId', 'requestInfo', 'authInfo'], $this->keys($context->extra)); + $this->assertSame('{"scopes":["read","write"],"claims":{"sub":"u1","aud":["mcp"]}}', $this->json($context->extra->authInfo)); + $this->assertSame('{"headers":{}}', $this->json($context->extra->requestInfo)); + } + + public function testAnAccessTokenWithoutClaimsIsAnEmptyObjectAndItsScopesAList(): void + { + $session = new Session(new InMemorySessionStore()); + $session->set('Mcp\Server\Authorization\AccessToken', new class { + /** @return array */ + public function getScopes(): array + { + return [2 => 'read']; + } + + /** @return array */ + public function getClaims(): array + { + return []; + } + }); + + $context = (new RequestReader(static fn(): ?HttpContext => null))->read((new CallToolRequest('echo', []))->withId(1), $session); + + $this->assertSame('{"scopes":["read"],"claims":{}}', $this->json($context->extra->authInfo)); + } + + public function testNoAuthInfoWithoutAnAccessToken(): void + { + $reader = new RequestReader(static fn(): ?HttpContext => null); + $request = (new CallToolRequest('echo', []))->withId(1); + $none = new Session(new InMemorySessionStore()); + $notAToken = new Session(new InMemorySessionStore()); + $notAToken->set('Mcp\Server\Authorization\AccessToken', new class { + /** @return list */ + public function getScopes(): array + { + return ['read']; + } + }); + + $this->assertObjectNotHasProperty('authInfo', $reader->read($request, $none)->extra); + $this->assertObjectNotHasProperty('authInfo', $reader->read($request, $notAToken)->extra); + } + + #[Group('ac-02-35')] + public function testARoundWithInputResponsesIsAContinuation(): void + { + $reader = new RequestReader(static fn(): ?HttpContext => null); + $request = (new CallToolRequest('echo', []))->withId(1); + $retry = new Session(new InMemorySessionStore()); + $retry->set(InputContext::class, new InputContext()); + + $this->assertTrue($reader->read($request, $retry)->continuation); + $this->assertFalse($reader->read($request, new Session(new InMemorySessionStore()))->continuation); + } + + private function tracked(): HttpHarness + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + return $harness; + } + + private function extra(Event $event): \stdClass + { + $extra = JsonAssert::at($event->parameters, 'extra'); + $this->assertInstanceOf(\stdClass::class, $extra); + + return $extra; + } + + private function tag(Event $event, string $key): ?string + { + return $event->tags[$key] ?? null; + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } + + private function json(mixed $value): string + { + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Integration/McpSdk/ResponseEventAfterBuildTest.php b/tests/Integration/McpSdk/ResponseEventAfterBuildTest.php new file mode 100644 index 0000000..9350386 --- /dev/null +++ b/tests/Integration/McpSdk/ResponseEventAfterBuildTest.php @@ -0,0 +1,17 @@ +fixture = new TrackingFixture(); - $this->dispatcher = new RecordingDispatcher(); - } - - #[\Override] - protected function tearDown(): void - { - Log::reset(); - } - - public function testListenersSeeTheLibrarysToolListWhileTheWireCarriesTheInjectedOne(): void - { - $builder = $this->builder(); - Tools::echo($builder); - $this->fixture->track($builder); - - $wire = $this->fixture->harness($builder)->listTools(); - - $this->assertSame(['echo', GetMoreTools::NAME], $this->names($wire)); - $result = $this->only('tools/list'); - $this->assertInstanceOf(ListToolsResult::class, $result); - $this->assertSame(['echo'], array_map(static fn(Tool $tool): string => $tool->name, array_values($result->tools))); - $this->assertNull($result->nextCursor); - $this->assertEquals($wire, Trees::of($result)); - } - - public function testListenersSeeTheToolsOwnResultWhileTheWireCarriesTheMintBack(): void - { - $builder = $this->builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - $harness->listTools(); - - $wire = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi'])); - - $this->assertSame('echo: hi', JsonAssert::at($wire, 'content', 1, 'text'), 'the mint-back text comes first on the wire'); - $result = $this->only('tools/call'); - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertSame(['echo: hi'], $this->texts($result)); - $this->assertFalse($result->isError); - $this->assertNull($result->structuredContent); - $this->assertNull($result->meta); - $this->assertEquals($wire, Trees::of($result)); - } - - public function testListenersSeeTheGetMoreToolsAnswerAsACallToolResult(): void - { - $builder = $this->builder(); - $this->fixture->track($builder); - $harness = $this->fixture->harness($builder); - - $wire = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'a calendar tool'])); - - $result = $this->only('tools/call'); - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertSame([JsonAssert::at($wire, 'content', 1, 'text')], $this->texts($result), 'the mint-back text comes first on the wire'); - $this->assertFalse($result->isError); - $this->assertEquals($wire, Trees::of($result)); - } - - public function testListenersSeeAnotherResultTypeAsTheCallToolResultItsTreeHolds(): void - { - $builder = $this->builder(); - $this->fixture->track($builder); - $builder->addRequestHandler(new CustomerHandler(CallToolRequest::class, static fn(): ResultInterface => new class implements ResultInterface { - /** @return array */ - #[\Override] - public function jsonSerialize(): array - { - return ['content' => [['type' => 'text', 'text' => 'custom']], 'isError' => true]; - } - })); - $harness = $this->fixture->harness($builder); - - $wire = HttpHarness::result($harness->callTool('anything', [])); - - $this->assertSame('custom', JsonAssert::at($wire, 'content', 1, 'text'), 'the mint-back text comes first on the wire'); - $result = $this->only('tools/call'); - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertSame(['custom'], $this->texts($result)); - $this->assertTrue($result->isError); - $this->assertEquals($wire, Trees::of($result)); - } - - public function testModernEraListenersSeeTheLibrarysTypes(): void + protected static function entry(): Entry { - $builder = $this->builder(); - Tools::echo($builder); - $this->fixture->track($builder); - $harness = new HttpHarness($builder->build()); - - $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; 0.9.0 and main do. - $lists = $this->dispatcher->results('tools/list'); - $calls = $this->dispatcher->results('tools/call'); - $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); - } - foreach ($calls as $result) { - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertSame(['echo: hi'], $this->texts($result)); - $this->assertWireCarries($call, $result); - } - } - - private function builder(): Builder - { - return TrackingFixture::builder()->setEventDispatcher($this->dispatcher); - } - - /** Every member of the result is on the wire: the 2026-07-28 codec adds its own (`resultType`, `ttlMs`, …). */ - private function assertWireCarries(\stdClass $wire, mixed $result): void - { - $tree = Trees::of($result); - $this->assertInstanceOf(\stdClass::class, $tree); - foreach (get_object_vars($tree) as $member => $value) { - $this->assertEquals($value, $wire->{$member} ?? null, (string) $member); - } - } - - /** The one result listeners saw for this method. */ - private function only(string $method): mixed - { - $results = $this->dispatcher->results($method); - $this->assertCount(1, $results); - - return $results[0]; - } - - /** @return list */ - private function names(\stdClass $listing): array - { - $tools = $listing->tools ?? null; - $this->assertIsArray($tools); - - return array_map(static function (mixed $tool): string { - $name = JsonAssert::at($tool, 'name'); - self::assertIsString($name); - - return $name; - }, array_values($tools)); - } - - /** @return list the text of every content block, each one a `TextContent` */ - private function texts(CallToolResult $result): array - { - return array_map(static function (mixed $content): string { - self::assertInstanceOf(TextContent::class, $content); - self::assertIsString($content->text); - - return $content->text; - }, array_values($result->content)); + return Entry::BeforeBuild; } } diff --git a/tests/Integration/McpSdk/ResponseEventTestCase.php b/tests/Integration/McpSdk/ResponseEventTestCase.php new file mode 100644 index 0000000..dd8b7cd --- /dev/null +++ b/tests/Integration/McpSdk/ResponseEventTestCase.php @@ -0,0 +1,217 @@ +afterBuild()) { + self::markTestSkipped('Tracked after build(): ' . $why); + } + } + + private TrackingFixture $fixture; + + private RecordingDispatcher $dispatcher; + + #[\Override] + protected function setUp(): void + { + $this->fixture = new TrackingFixture(static::entry()); + $this->dispatcher = new RecordingDispatcher(); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testListenersSeeTheLibrarysToolListWhileTheWireCarriesTheInjectedOne(): void + { + $builder = $this->builder(); + Tools::echo($builder); + $this->fixture->track($builder); + + $wire = $this->fixture->harness($builder)->listTools(); + + $this->assertSame(['echo', GetMoreTools::NAME], $this->names($wire)); + $result = $this->only('tools/list'); + $this->assertInstanceOf(ListToolsResult::class, $result); + // After build(), the library lists the registry, in which AgentCat's tools are: listeners see them too. + $this->assertSame(static::entry()->afterBuild() ? ['echo', GetMoreTools::NAME] : ['echo'], array_map(static fn(Tool $tool): string => $tool->name, array_values($result->tools))); + $this->assertNull($result->nextCursor); + $this->assertEquals($wire, Trees::of($result)); + } + + public function testListenersSeeTheToolsOwnResultWhileTheWireCarriesTheMintBack(): void + { + $builder = $this->builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + $harness->listTools(); + + $wire = HttpHarness::result($harness->callTool('echo', ['msg' => 'hi'])); + + $this->assertSame('echo: hi', JsonAssert::at($wire, 'content', 1, 'text'), 'the mint-back text comes first on the wire'); + $result = $this->only('tools/call'); + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertSame(['echo: hi'], $this->texts($result)); + $this->assertFalse($result->isError); + $this->assertNull($result->structuredContent); + $this->assertNull($result->meta); + $this->assertEquals($wire, Trees::of($result)); + } + + public function testListenersSeeTheGetMoreToolsAnswerAsACallToolResult(): void + { + $this->beforeBuildOnly('a call to an unknown tool (a server without tools lists no get_more_tools)'); + $builder = $this->builder(); + $this->fixture->track($builder); + $harness = $this->fixture->harness($builder); + + $wire = HttpHarness::result($harness->callTool(GetMoreTools::NAME, ['context' => 'a calendar tool'])); + + $result = $this->only('tools/call'); + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertSame([JsonAssert::at($wire, 'content', 1, 'text')], $this->texts($result), 'the mint-back text comes first on the wire'); + $this->assertFalse($result->isError); + $this->assertEquals($wire, Trees::of($result)); + } + + public function testListenersSeeAnotherResultTypeAsTheCallToolResultItsTreeHolds(): void + { + $this->beforeBuildOnly('a tools/call handler of the customer\'s'); + $builder = $this->builder(); + $this->fixture->track($builder); + $builder->addRequestHandler(new CustomerHandler(CallToolRequest::class, static fn(): ResultInterface => new class implements ResultInterface { + /** @return array */ + #[\Override] + public function jsonSerialize(): array + { + return ['content' => [['type' => 'text', 'text' => 'custom']], 'isError' => true]; + } + })); + $harness = $this->fixture->harness($builder); + + $wire = HttpHarness::result($harness->callTool('anything', [])); + + $this->assertSame('custom', JsonAssert::at($wire, 'content', 1, 'text'), 'the mint-back text comes first on the wire'); + $result = $this->only('tools/call'); + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertSame(['custom'], $this->texts($result)); + $this->assertTrue($result->isError); + $this->assertEquals($wire, Trees::of($result)); + } + + public function testModernEraListenersSeeTheLibrarysTypes(): void + { + $builder = $this->builder(); + Tools::echo($builder); + $this->fixture->track($builder); + $harness = new HttpHarness(static::entry()->build($builder)); + + $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; 0.9.0 and main do. + $lists = $this->dispatcher->results('tools/list'); + $calls = $this->dispatcher->results('tools/call'); + $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); + } + foreach ($calls as $result) { + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertSame(['echo: hi'], $this->texts($result)); + $this->assertWireCarries($call, $result); + } + } + + private function builder(): Builder + { + return TrackingFixture::builder()->setEventDispatcher($this->dispatcher); + } + + /** Every member of the result is on the wire: the 2026-07-28 codec adds its own (`resultType`, `ttlMs`, …). */ + private function assertWireCarries(\stdClass $wire, mixed $result): void + { + $tree = Trees::of($result); + $this->assertInstanceOf(\stdClass::class, $tree); + foreach (get_object_vars($tree) as $member => $value) { + $this->assertEquals($value, $wire->{$member} ?? null, (string) $member); + } + } + + /** The one result listeners saw for this method. */ + private function only(string $method): mixed + { + $results = $this->dispatcher->results($method); + $this->assertCount(1, $results); + + return $results[0]; + } + + /** @return list */ + private function names(\stdClass $listing): array + { + $tools = $listing->tools ?? null; + $this->assertIsArray($tools); + + return array_map(static function (mixed $tool): string { + $name = JsonAssert::at($tool, 'name'); + self::assertIsString($name); + + return $name; + }, array_values($tools)); + } + + /** @return list the text of every content block, each one a `TextContent` */ + private function texts(CallToolResult $result): array + { + return array_map(static function (mixed $content): string { + self::assertInstanceOf(TextContent::class, $content); + self::assertIsString($content->text); + + return $content->text; + }, array_values($result->content)); + } +} diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 3b42808..8099e1f 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -175,14 +175,13 @@ public function testACustomerToolsCallHandlerRunsAfterAgentCats(): void public static function handlersCalls(): iterable { yield 'the bundle\'s own shape' => [null, 1]; - yield 'a named argument' => [static fn(TaggedIteratorArgument $handlers): array => ['$handlers' => $handlers], 0]; - yield 'a list of services' => [static fn(TaggedIteratorArgument $handlers): array => [[new Reference(CustomerCallHandler::class)]], 0]; + yield 'a named argument' => [static fn(TaggedIteratorArgument $handlers): array => ['$handlers' => $handlers], 1]; + yield 'a list of services' => [static fn(TaggedIteratorArgument $handlers): array => [[new Reference(CustomerCallHandler::class)]], 1]; } /** - * With a compiled container, an `addRequestHandlers` call of another shape stays where it is. The customer's - * handler still answers; it comes before AgentCat's, which still tracks the server (its listing), but no longer - * sees the calls that the customer's handler answers. + * With a compiled container, an `addRequestHandlers` call of another shape stays where it is, before AgentCat's + * track(). AgentCat's handlers still come first: the customer's handler answers, and AgentCat records the call. * * @param (\Closure(TaggedIteratorArgument): array)|null $shape */ diff --git a/tests/Support/McpSdk/BuilderMembers.php b/tests/Support/McpSdk/BuilderMembers.php index 1515045..14d4014 100644 --- a/tests/Support/McpSdk/BuilderMembers.php +++ b/tests/Support/McpSdk/BuilderMembers.php @@ -18,4 +18,16 @@ public static function uninitialize(string $member): \Closure { return Members::uninitialize(Builder::class, $member); } + + /** @return \Closure(Builder): mixed */ + public static function reader(string $member): \Closure + { + return Members::reader(Builder::class, $member); + } + + /** @return \Closure(Builder, mixed): void */ + public static function writer(string $member): \Closure + { + return Members::writer(Builder::class, $member); + } } diff --git a/tests/Support/McpSdk/Entry.php b/tests/Support/McpSdk/Entry.php new file mode 100644 index 0000000..d7f9311 --- /dev/null +++ b/tests/Support/McpSdk/Entry.php @@ -0,0 +1,55 @@ +build(); + $pending = Pending::take($builder); + if ($pending !== null) { + [$server, $publisher] = $pending; + McpSdkAdapter::install($this === self::BuiltServer ? $built : $builder, $server, $publisher, new CallInterceptor($publisher)); + } + + return $built; + } + + /** True for the entries whose request handlers AgentCat cannot precede (after build()). */ + public function afterBuild(): bool + { + return $this !== self::BeforeBuild; + } +} diff --git a/tests/Support/McpSdk/Pending.php b/tests/Support/McpSdk/Pending.php new file mode 100644 index 0000000..89f33b3 --- /dev/null +++ b/tests/Support/McpSdk/Pending.php @@ -0,0 +1,33 @@ +|null */ + private static ?\WeakMap $builders = null; + + public static function put(Builder $builder, TrackedServer $server, Publisher $publisher): void + { + self::$builders ??= new \WeakMap(); + self::$builders[$builder] = [$server, $publisher]; + } + + /** @return array{TrackedServer, Publisher}|null */ + public static function take(Builder $builder): ?array + { + $pending = self::$builders[$builder] ?? null; + if ($pending !== null) { + unset(self::$builders[$builder]); + } + + return $pending; + } +} diff --git a/tests/Support/McpSdk/TrackingFixture.php b/tests/Support/McpSdk/TrackingFixture.php index 111ec8f..ec2b989 100644 --- a/tests/Support/McpSdk/TrackingFixture.php +++ b/tests/Support/McpSdk/TrackingFixture.php @@ -4,10 +4,15 @@ namespace AgentCat\Tests\Support\McpSdk; -use AgentCat\Internal\Engine\CallInterceptor; +use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Logging\Log; use AgentCat\McpSdk\McpSdkAdapter; use AgentCat\Options; +use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\RecordingExporter; +use AgentCat\Tests\Support\RecordingTransport; use AgentCat\Tests\Support\RecordsEvents; use Mcp\Server; use Mcp\Server\Builder; @@ -20,24 +25,37 @@ { use RecordsEvents; + /** @param Entry $entry where the tests hand the server to AgentCat */ + public function __construct(public Entry $entry = Entry::BeforeBuild) + { + // RecordsEvents's constructor, which this one replaces. + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + $this->transport = new RecordingTransport(); + $this->exporter = new RecordingExporter(); + $this->queue = new EventQueue(transport: $this->transport, exporters: $this->exporter); + $this->publisher = new Publisher($this->queue); + } + /** A Builder for the server `harness-server` 1.2.3. */ public static function builder(): Builder { return Server::builder()->setServerInfo('harness-server', '1.2.3'); } + /** Tracks the Builder (`Entry::BeforeBuild`), or the Builder or its server once `harness()` builds it. */ public function track(Builder $builder, Options $options = new Options(), string $projectId = self::PROJECT): TrackedServer { $server = new TrackedServer($projectId, $options, McpSdkAdapter::serverInfo($builder), $this->publisher); - McpSdkAdapter::install($builder, $server, $this->publisher, new CallInterceptor($this->publisher)); + $this->entry->track($builder, $server, $this->publisher); return $server; } - /** Builds the server and runs the handshake as `harness-client` 1.0.0. */ + /** Builds the server, tracks it after build() for the other entries, and runs the handshake as `harness-client` 1.0.0. */ public function harness(Builder $builder): HttpHarness { - $harness = new HttpHarness($builder->build()); + $harness = new HttpHarness($this->entry->build($builder)); $harness->initialize(); return $harness; diff --git a/tests/Support/Members.php b/tests/Support/Members.php index 2723fb8..f005b09 100644 --- a/tests/Support/Members.php +++ b/tests/Support/Members.php @@ -24,4 +24,35 @@ public static function uninitialize(string $class, string $member): \Closure $unset($object); }; } + + /** + * @param class-string $class the class that declares the member + * + * @return \Closure(object): mixed that reads the member + */ + public static function reader(string $class, string $member): \Closure + { + return static function (object $object) use ($class, $member): mixed { + // @phpstan-ignore disallowed.method (a drift test reads the state that it changes) + $read = \Closure::bind(static fn(object $o): mixed => $o->{$member}, null, $class); // @phpstan-ignore property.dynamicName (the caller names the member) + + return $read($object); + }; + } + + /** + * @param class-string $class the class that declares the member + * + * @return \Closure(object, mixed): void that sets the member, to put a drifted member back or to change the state + */ + public static function writer(string $class, string $member): \Closure + { + return static function (object $object, mixed $value) use ($class, $member): void { + // @phpstan-ignore disallowed.method (simulates drift: PHP has no public way to set a private property) + $write = \Closure::bind(static function (object $o) use ($member, $value): void { + $o->{$member} = $value; // @phpstan-ignore property.dynamicName (the caller names the member) + }, null, $class); + $write($object); + }; + } } diff --git a/tests/Unit/Internal/Engine/LibrariesTest.php b/tests/Unit/Internal/Engine/LibrariesTest.php index b78f97e..dede647 100644 --- a/tests/Unit/Internal/Engine/LibrariesTest.php +++ b/tests/Unit/Internal/Engine/LibrariesTest.php @@ -51,7 +51,7 @@ public function testTheSupportMatrixHasOneSentencePerLibrary(): void { $this->assertSame(McpSdkAdapter::SUPPORT_MATRIX . ' ' . LaravelMcpAdapter::SUPPORT_MATRIX, Libraries::supportMatrix()); $this->assertSame( - 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder to AgentCat::track() before build()). AgentCat supports laravel/mcp 1.0+ (the Laravel service provider tracks every server).', + 'AgentCat supports mcp/sdk 0.8.1+ (pass the Mcp\Server\Builder or the Mcp\Server built from it to AgentCat::track()). AgentCat supports laravel/mcp 1.0+ (the Laravel service provider tracks every server).', Libraries::supportMatrix(), ); } diff --git a/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php b/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php index 06749c8..24591e1 100644 --- a/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php +++ b/tests/Unit/McpSdk/CapturingReferenceHandlerTest.php @@ -15,25 +15,19 @@ final class CapturingReferenceHandlerTest extends TestCase { - public function testTheInnerHandlerIsResolvedOnceAndAnswersUnchanged(): void + public function testTheInnerHandlerAnswersUnchanged(): void { - $resolutions = new \ArrayObject(); - $handler = new CapturingReferenceHandler(static function () use ($resolutions): ReferenceHandlerInterface { - $resolutions->append(true); - - return self::inner(static fn(array $arguments): mixed => $arguments['n'] ?? null); - }, new ThrowableStash()); + $handler = new CapturingReferenceHandler($this->inner(static fn(array $arguments): mixed => $arguments['n'] ?? null), new ThrowableStash()); $this->assertSame(1, $handler->handle(self::tool(), ['n' => 1])); $this->assertSame(2, $handler->handle(self::tool(), ['n' => 2])); - $this->assertCount(1, $resolutions); } public function testAToolExceptionIsStashedForItsRequestAndRethrown(): void { $error = new \RuntimeException('tool boom'); $stash = new ThrowableStash(); - $handler = new CapturingReferenceHandler(static fn(): ReferenceHandlerInterface => self::inner(static function () use ($error): never { + $handler = new CapturingReferenceHandler($this->inner(static function () use ($error): never { throw $error; }), $stash); $request = new \stdClass(); @@ -48,7 +42,7 @@ public function testOnlyAToolExceptionWithARequestIsStashed(): void { $error = new \RuntimeException('prompt boom'); $stash = new ThrowableStash(); - $handler = new CapturingReferenceHandler(static fn(): ReferenceHandlerInterface => self::inner(static function () use ($error): never { + $handler = new CapturingReferenceHandler($this->inner(static function () use ($error): never { throw $error; }), $stash); $request = new \stdClass(); @@ -66,7 +60,7 @@ private static function tool(): ToolReference } /** @param \Closure(array): mixed $answer */ - private static function inner(\Closure $answer): ReferenceHandlerInterface + private function inner(\Closure $answer): ReferenceHandlerInterface { return new readonly class ($answer) implements ReferenceHandlerInterface { /** @param \Closure(array): mixed $answer */ diff --git a/tests/Unit/McpSdk/InjectedSchemaValidatorTest.php b/tests/Unit/McpSdk/InjectedSchemaValidatorTest.php new file mode 100644 index 0000000..f734797 --- /dev/null +++ b/tests/Unit/McpSdk/InjectedSchemaValidatorTest.php @@ -0,0 +1,84 @@ + 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg'], 'additionalProperties' => false]; + + private const array INJECTED = [ + 'type' => 'object', + 'properties' => ['msg' => ['type' => 'string'], 'session_id' => ['type' => 'string'], 'context' => ['type' => 'string']], + 'required' => ['msg', 'session_id', 'context'], + 'additionalProperties' => false, + ]; + + private const array GET_MORE_TOOLS = ['type' => 'object', 'properties' => ['context' => ['type' => 'string']], 'required' => ['context']]; + + public function testAnInjectedSchemaChecksTheOtherArgumentsAgainstTheToolsOwnSchema(): void + { + $validator = $this->validator([[self::INJECTED, self::OWN, ['session_id', 'context']]]); + + $this->assertSame([], $validator->validateAgainstJsonSchema(['msg' => 'hi'], self::INJECTED), 'without the injected arguments'); + $this->assertSame([], $validator->validateAgainstJsonSchema(['msg' => 'hi', 'session_id' => 'ses_x', 'context' => 'why'], self::INJECTED)); + } + + public function testTheErrorsAreTheLibrarysForTheToolsOwnSchema(): void + { + $validator = $this->validator([[self::INJECTED, self::OWN, ['session_id', 'context']]]); + + $errors = $validator->validateAgainstJsonSchema(['session_id' => 'ses_x', 'msg' => 7, 'extra' => true], self::INJECTED); + + $this->assertEquals((new SchemaValidator())->validateAgainstJsonSchema(['msg' => 7, 'extra' => true], self::OWN), $errors); + $this->assertNotSame([], $errors); + } + + public function testNoArgumentsLeftAreAnEmptyObject(): void + { + $empty = ['type' => 'object', 'properties' => new \stdClass()]; + $validator = $this->validator([[self::INJECTED, $empty, ['session_id', 'context']]]); + + $this->assertSame([], $validator->validateAgainstJsonSchema(['session_id' => 'ses_x', 'context' => 'why'], self::INJECTED)); + } + + public function testAToolThatAgentCatAnswersIsNotChecked(): void + { + $validator = $this->validator([[self::GET_MORE_TOOLS, null, []]]); + + $this->assertSame([], $validator->validateAgainstJsonSchema(['context' => 7], self::GET_MORE_TOOLS)); + } + + public function testAnySchemaAgentCatDidNotInjectIsCheckedUnchanged(): void + { + $validator = $this->validator([[self::INJECTED, self::OWN, ['session_id', 'context']]]); + + $this->assertSame([], $validator->validateAgainstJsonSchema(['context' => 'kept'], self::GET_MORE_TOOLS)); + $this->assertEquals((new SchemaValidator())->validateAgainstJsonSchema(['msg' => 'hi', 'session_id' => 'kept'], self::OWN), $validator->validateAgainstJsonSchema(['msg' => 'hi', 'session_id' => 'kept'], self::OWN)); + $this->assertNotSame([], $validator->validateAgainstJsonSchema(['msg' => 'hi', 'session_id' => 'kept'], self::OWN), 'session_id is not stripped for a schema AgentCat did not inject'); + } + + public function testTheLibrarysLoggerGetsTheValidatorsLines(): void + { + $logger = new MemoryLogger(); + $broken = ['type' => 'object', 'properties' => ['msg' => ['type' => 7]]]; + $validator = new InjectedSchemaValidator($logger, [[self::INJECTED, $broken, ['session_id', 'context']]]); + + $validator->validateAgainstJsonSchema(['msg' => 'hi', 'session_id' => 'ses_x'], self::INJECTED); + + $this->assertNotSame([], $logger->messages(), 'the library logs a schema it cannot use'); + } + + /** @param list, array|null, array}> $injected */ + private function validator(array $injected): InjectedSchemaValidator + { + return new InjectedSchemaValidator(new NullLogger(), $injected); + } +}