diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php index 0780a8ec..ca9ab647 100644 --- a/examples/client/stdio_elicitation.php +++ b/examples/client/stdio_elicitation.php @@ -43,6 +43,10 @@ public function __invoke(ElicitRequest $request): ElicitResult { echo "\n[ELICIT] {$request->message}\n"; + if (null === $request->requestedSchema) { + return new ElicitResult(ElicitAction::Decline); + } + $content = []; foreach ($request->requestedSchema->properties as $name => $definition) { $default = $this->defaultFor($definition); @@ -78,7 +82,7 @@ private function defaultFor(object $definition): mixed private function labelFor(AbstractSchemaDefinition $definition): string { - return $definition->title; + return $definition->title ?? ''; } private function cast(object $definition, string $input): mixed diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index 99fcfdaf..4b53e610 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -51,7 +51,7 @@ function transport(): TransportInterface function shutdown(ResponseInterface|int $result): never { - if ('cli' === \PHP_SAPI) { + if (is_int($result)) { exit($result); } diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php index 159e8c47..8269573e 100644 --- a/examples/server/mcp-apps/WeatherApp.php +++ b/examples/server/mcp-apps/WeatherApp.php @@ -32,10 +32,15 @@ public function getWeatherApp(): TextResourceContents prefersBorder: true, ); + $html = file_get_contents(__DIR__.'/weather-app.html'); + if (false === $html) { + throw new \RuntimeException('Could not read the weather app template.'); + } + return new TextResourceContents( uri: 'ui://weather-app', mimeType: McpApps::MIME_TYPE, - text: file_get_contents(__DIR__.'/weather-app.html'), + text: $html, meta: ['ui' => $contentMeta], ); } diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php index 48208f22..d3365433 100644 --- a/examples/server/oauth-microsoft/McpElements.php +++ b/examples/server/oauth-microsoft/McpElements.php @@ -106,7 +106,7 @@ public function listEmails(int $count = 5): array 'id' => 'msg_'.uniqid(), 'subject' => "Sample Email #{$i}", 'from' => "sender{$i}@example.com", - 'receivedDateTime' => date('c', strtotime("-{$i} hours")), + 'receivedDateTime' => date('c', strtotime("-{$i} hours") ?: time()), ], range(1, $count)), ]; } diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 259c4686..0ff24719 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -2,7 +2,7 @@ includes: - phpstan-baseline.neon parameters: - level: 6 + level: 8 paths: - examples/ - src/ diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index 9e8dc802..48241177 100644 --- a/src/Capability/Attribute/CompletionProvider.php +++ b/src/Capability/Attribute/CompletionProvider.php @@ -21,9 +21,11 @@ class CompletionProvider { /** - * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved - * from the container at the point of use - * @param ?array $values a list of values to use for completion + * @param class-string|null $providerClass a provider class to resolve from the container at the point of use + * @param class-string|ProviderInterface|null $provider if a class-string, it will be resolved + * from the container at the point of use + * @param ?array $values a list of values to use for completion + * @param class-string|null $enum an enum class whose cases are used for completion */ public function __construct( public ?string $providerClass = null, diff --git a/src/Capability/Attribute/Schema.php b/src/Capability/Attribute/Schema.php index 80ec4b53..8a375e15 100644 --- a/src/Capability/Attribute/Schema.php +++ b/src/Capability/Attribute/Schema.php @@ -29,10 +29,10 @@ * minLength?: int, * maxLength?: int, * pattern?: string, - * minimum?: int, - * maximum?: int, - * exclusiveMinimum?: int, - * exclusiveMaximum?: int, + * minimum?: int|float, + * maximum?: int|float, + * exclusiveMinimum?: bool, + * exclusiveMaximum?: bool, * multipleOf?: int|float, * items?: array, * minItems?: int, diff --git a/src/Capability/Completion/ListCompletionProvider.php b/src/Capability/Completion/ListCompletionProvider.php index 5d48f4bd..45a41c45 100644 --- a/src/Capability/Completion/ListCompletionProvider.php +++ b/src/Capability/Completion/ListCompletionProvider.php @@ -17,11 +17,16 @@ class ListCompletionProvider implements ProviderInterface { /** - * @param string[] $values + * @var string[] */ - public function __construct( - private array $values, - ) { + private array $values; + + /** + * @param array $values + */ + public function __construct(array $values) + { + $this->values = array_values(array_map(strval(...), $values)); } public function getCompletions(string $currentValue): array diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 5b7e4765..faf387d2 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -49,10 +49,13 @@ */ final class Discoverer implements DiscovererInterface { + private readonly DocBlockParser $docBlockParser; + private readonly SchemaGeneratorInterface $schemaGenerator; + public function __construct( private readonly LoggerInterface $logger = new NullLogger(), - private ?DocBlockParser $docBlockParser = null, - private ?SchemaGeneratorInterface $schemaGenerator = null, + ?DocBlockParser $docBlockParser = null, + ?SchemaGeneratorInterface $schemaGenerator = null, ) { if (!class_exists(Finder::class)) { throw new RuntimeException('File-based discovery requires symfony/finder. Run: composer require symfony/finder'); @@ -223,76 +226,65 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun try { $instance = $attribute->newInstance(); - switch ($attributeClassName) { - case McpTool::class: - $name = ElementMetadataResolver::resolveName($method, $instance->name); - $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); - $inputSchema = $this->schemaGenerator->generate($method); - $outputSchema = $this->schemaGenerator->generateOutputSchema($method); - $tool = new Tool( - name: $name, - title: $instance->title, - inputSchema: $inputSchema, - description: $description, - annotations: $instance->annotations, - icons: $instance->icons, - meta: $instance->meta, - outputSchema: $outputSchema, - ); - $tools[$name] = new ToolReference($tool, [$className, $methodName]); - ++$discoveredCount['tools']; - break; - - case McpResource::class: - $name = ElementMetadataResolver::resolveName($method, $instance->name); - $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); - $resource = new ResourceDefinition( - $instance->uri, - $name, - $instance->title, - $description, - $instance->mimeType, - $instance->annotations, - $instance->size, - $instance->icons, - $instance->meta, - ); - $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]); - - ++$discoveredCount['resources']; - break; - - case McpPrompt::class: - $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $name = ElementMetadataResolver::resolveName($method, $instance->name); - $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); - $arguments = []; - $paramTags = $this->docBlockParser->getParamTags($docBlock); - foreach ($method->getParameters() as $param) { - $reflectionType = $param->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - $paramTag = $paramTags['$'.$param->getName()] ?? null; - $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); + if ($instance instanceof McpTool) { + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); + $inputSchema = $this->schemaGenerator->generate($method); + $outputSchema = $this->schemaGenerator->generateOutputSchema($method); + $tool = new Tool( + name: $name, + title: $instance->title, + inputSchema: $inputSchema, + description: $description, + annotations: $instance->annotations, + icons: $instance->icons, + meta: $instance->meta, + outputSchema: $outputSchema, + ); + $tools[$name] = new ToolReference($tool, [$className, $methodName]); + ++$discoveredCount['tools']; + } elseif ($instance instanceof McpResource) { + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); + $resource = new ResourceDefinition( + $instance->uri, + $name, + $instance->title, + $description, + $instance->mimeType, + $instance->annotations, + $instance->size, + $instance->icons, + $instance->meta, + ); + $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]); + + ++$discoveredCount['resources']; + } elseif ($instance instanceof McpPrompt) { + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); + $arguments = []; + $paramTags = $this->docBlockParser->getParamTags($docBlock); + foreach ($method->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; } - $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta); - $completionProviders = $this->getCompletionProviders($method); - $prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders); - ++$discoveredCount['prompts']; - break; - - case McpResourceTemplate::class: - $name = ElementMetadataResolver::resolveName($method, $instance->name); - $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); - $mimeType = $instance->mimeType; - $annotations = $instance->annotations; - $meta = $instance->meta ?? null; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $mimeType, $annotations, $meta); - $completionProviders = $this->getCompletionProviders($method); - $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders); - ++$discoveredCount['resourceTemplates']; - break; + $paramTag = $paramTags['$'.$param->getName()] ?? null; + $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); + } + $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta); + $completionProviders = $this->getCompletionProviders($method); + $prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders); + ++$discoveredCount['prompts']; + } elseif ($instance instanceof McpResourceTemplate) { + $name = ElementMetadataResolver::resolveName($method, $instance->name); + $description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser); + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $instance->mimeType, $instance->annotations, $instance->meta); + $completionProviders = $this->getCompletionProviders($method); + $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders); + ++$discoveredCount['resourceTemplates']; } } catch (ExceptionInterface $e) { $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", [ @@ -308,7 +300,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun } /** - * @return array + * @return array|ProviderInterface> */ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array { @@ -326,7 +318,7 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar if ($attributeInstance->provider) { $completionProviders[$param->getName()] = $attributeInstance->provider; } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->provider; + $completionProviders[$param->getName()] = $attributeInstance->providerClass; } elseif ($attributeInstance->values) { $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); } elseif ($attributeInstance->enum) { @@ -433,17 +425,13 @@ private function getClassFromFile(SplFileInfo $file): ?string } foreach ($potentialClasses as $potentialClass) { - if (class_exists($potentialClass, true)) { + if (class_exists($potentialClass, true) || interface_exists($potentialClass, true) || trait_exists($potentialClass, true)) { return $potentialClass; } } if (!empty($potentialClasses)) { - if (!class_exists($potentialClasses[0], false)) { - $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]); - } - - return $potentialClasses[0]; + $this->logger->debug('getClassFromFile found no loadable type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]); } return null; diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 357c9e98..949ed013 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -49,12 +49,6 @@ * enum?: array, * items?: array, * } - * @phpstan-type VariadicParameterSchema array{ - * type: 'array', - * items?: array, - * description?: string, - * parameter_schema?: array - * } * * @author Kyrian Obikwelu */ @@ -328,7 +322,7 @@ private function buildInferredParameterSchema(array $paramInfo): array * * @param ParameterInfo $paramInfo * - * @return VariadicParameterSchema + * @return array */ private function buildVariadicParameterSchema(array $paramInfo): array { @@ -536,7 +530,8 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl $paramName = $rp->getName(); if (\in_array(strtolower($paramName), ['_session', '_request'], true)) { - throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" has parameter named "%s" which is not allowed. Please change the name of that parameter.', $reflection->class, $reflection->name, $paramName)); + $handlerName = $reflection instanceof \ReflectionMethod ? $reflection->class.'::'.$reflection->name : $reflection->name; + throw new InvalidArgumentException(\sprintf('Handler "%s" has parameter named "%s" which is not allowed. Please change the name of that parameter.', $handlerName, $paramName)); } $paramTag = $paramTags['$'.$paramName] ?? null; @@ -693,8 +688,10 @@ private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativ // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string' if (str_contains($typeString, '\\')) { $parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE); - $processedParts = array_map(static fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); - $typeString = implode('', $processedParts); + if (false !== $parts) { + $processedParts = array_map(static fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); + $typeString = implode('', $processedParts); + } } return $typeString ?: 'mixed'; diff --git a/src/Capability/Discovery/SchemaGeneratorInterface.php b/src/Capability/Discovery/SchemaGeneratorInterface.php index c21d3cdd..4ee27f86 100644 --- a/src/Capability/Discovery/SchemaGeneratorInterface.php +++ b/src/Capability/Discovery/SchemaGeneratorInterface.php @@ -11,9 +11,13 @@ namespace Mcp\Capability\Discovery; +use Mcp\Schema\Tool; + /** * Provides JSON Schema generation for reflected elements. * + * @phpstan-import-type ToolInputSchema from Tool + * * @author Antoine Bluchet */ interface SchemaGeneratorInterface @@ -24,11 +28,7 @@ interface SchemaGeneratorInterface * The returned schema must be a valid JSON Schema object (type: 'object') * with properties corresponding to a tool's parameters. * - * @return array{ - * type: 'object', - * properties: array|object, - * required?: string[] - * } + * @return ToolInputSchema */ public function generate(\Reflector $reflection): array; diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php index 56174bdc..7e4a818f 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -165,7 +165,7 @@ private function convertDataForValidator(mixed $data): mixed /** * Recursively collects leaf validation errors. * - * @param Error[] $collectedErrors + * @param list $collectedErrors */ private function collectSubErrors(ValidationError $error, array &$collectedErrors): void { @@ -319,13 +319,12 @@ private function formatValidationError(ValidationError $error): string $builtInMessage = $error->message(); if ($builtInMessage && 'The data must match the schema' !== $builtInMessage) { $placeholders = $args; - $builtInMessage = preg_replace_callback('/\{(\w+)\}/', static function ($match) use ($placeholders) { + $message = preg_replace_callback('/\{(\w+)\}/', static function (array $match) use ($placeholders): string { $key = $match[1]; $value = $placeholders[$key] ?? '{'.$key.'}'; - return \is_array($value) ? json_encode($value) : (string) $value; - }, $builtInMessage); - $message = $builtInMessage; + return \is_array($value) ? json_encode($value, \JSON_THROW_ON_ERROR) : (string) $value; + }, $builtInMessage) ?? $builtInMessage; } break; } diff --git a/src/Capability/Formatter/ResourceResultFormatter.php b/src/Capability/Formatter/ResourceResultFormatter.php index bac2bc77..4205933d 100644 --- a/src/Capability/Formatter/ResourceResultFormatter.php +++ b/src/Capability/Formatter/ResourceResultFormatter.php @@ -138,7 +138,12 @@ public function format(mixed $readResult, string $uri, ?string $mimeType = null, if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; + $text = file_get_contents($readResult->getPathname()); + if (false === $text) { + throw new RuntimeException(\sprintf('Could not read file: "%s".', $readResult->getPathname())); + } + + return [new TextResourceContents($uri, $mimeType, $text, $meta)]; } return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 97840431..7d043aae 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -245,6 +245,9 @@ public function hasTools(): bool return [] !== $this->tools; } + /** + * @return Page + */ public function getTools(?int $limit = null, ?string $cursor = null): Page { $this->load(); @@ -283,6 +286,9 @@ public function hasResources(): bool return [] !== $this->resources; } + /** + * @return Page + */ public function getResources(?int $limit = null, ?string $cursor = null): Page { $this->load(); @@ -338,6 +344,9 @@ public function hasResourceTemplates(): bool return [] !== $this->resourceTemplates; } + /** + * @return Page + */ public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { $this->load(); @@ -376,6 +385,9 @@ public function hasPrompts(): bool return [] !== $this->prompts; } + /** + * @return Page + */ public function getPrompts(?int $limit = null, ?string $cursor = null): Page { $this->load(); @@ -449,11 +461,13 @@ private function calculateNextCursor(int $totalItems, ?string $currentCursor, in /** * Helper method to paginate results using cursor-based pagination. * - * @param array $items The full array of items to paginate The full array of items to paginate - * @param int $limit Maximum number of items to return - * @param string|null $cursor Base64 encoded offset position + * @template T + * + * @param array $items The full array of items to paginate + * @param int $limit Maximum number of items to return + * @param string|null $cursor Base64 encoded offset position * - * @return array Paginated results + * @return list Paginated results * * @throws InvalidCursorException When cursor is invalid (MCP error code -32602) */ diff --git a/src/Capability/Registry/Loader/ReflectedElementLoader.php b/src/Capability/Registry/Loader/ReflectedElementLoader.php index d1e849da..2901c17e 100644 --- a/src/Capability/Registry/Loader/ReflectedElementLoader.php +++ b/src/Capability/Registry/Loader/ReflectedElementLoader.php @@ -104,7 +104,8 @@ public function load(RegistryInterface $registry): void $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $handler = $data['handler']; + $name = $data['name'] ?? ($handler instanceof \Closure ? 'closure_tool_'.spl_object_id($handler) : $reflection->getName()); $description = $data['description'] ?? null; } else { $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); @@ -142,7 +143,8 @@ public function load(RegistryInterface $registry): void $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $handler = $data['handler']; + $name = $data['name'] ?? ($handler instanceof \Closure ? 'closure_resource_'.spl_object_id($handler) : $reflection->getName()); $description = $data['description'] ?? null; } else { $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); @@ -179,7 +181,8 @@ public function load(RegistryInterface $registry): void $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $handler = $data['handler']; + $name = $data['name'] ?? ($handler instanceof \Closure ? 'closure_template_'.spl_object_id($handler) : $reflection->getName()); $description = $data['description'] ?? null; } else { $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); @@ -215,7 +218,8 @@ public function load(RegistryInterface $registry): void $reflection = HandlerResolver::resolve($data['handler']); if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); + $handler = $data['handler']; + $name = $data['name'] ?? ($handler instanceof \Closure ? 'closure_prompt_'.spl_object_id($handler) : $reflection->getName()); $description = $data['description'] ?? null; } else { $name = ElementMetadataResolver::resolveName($reflection, $data['name'] ?? null); @@ -287,7 +291,7 @@ private function getHandlerDescription(\Closure|array|string $handler): string } /** - * @return array + * @return array|ProviderInterface> */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index c0084cbf..ff10de31 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -48,7 +48,11 @@ public function handle(ElementReference $reference, array $arguments): mixed $instance = $this->getClassInstance($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func($instance, ...$arguments); + if (!\is_callable($instance)) { + throw new InvalidArgumentException(\sprintf('Handler "%s" is not invokable.', $reference->handler)); + } + + return $instance(...$arguments); } if (\function_exists($reference->handler)) { @@ -67,12 +71,17 @@ public function handle(ElementReference $reference, array $arguments): mixed } if (\is_array($reference->handler)) { - [$className, $methodName] = $reference->handler; - $reflection = new \ReflectionMethod($className, $methodName); - $instance = $this->getClassInstance($className); + [$classOrObject, $methodName] = $reference->handler; + $reflection = new \ReflectionMethod($classOrObject, $methodName); + $instance = \is_object($classOrObject) ? $classOrObject : $this->getClassInstance($classOrObject); $arguments = $this->prepareArguments($reflection, $arguments); - return \call_user_func([$instance, $methodName], ...$arguments); + $callable = [$instance, $methodName]; + if (!\is_callable($callable)) { + throw new InvalidArgumentException(\sprintf('Handler "%s::%s" is not callable.', $instance::class, $methodName)); + } + + return $callable(...$arguments); } throw new InvalidArgumentException('Invalid handler type'); diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 49d03b39..925550f5 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -12,6 +12,7 @@ namespace Mcp\Capability\Registry; use Mcp\Capability\Formatter\ResourceResultFormatter; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\ResourceTemplate; @@ -97,6 +98,10 @@ private function compileTemplate(): void $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + if (false === $segments) { + throw new InvalidArgumentException(\sprintf('Invalid URI template "%s".', $this->resourceTemplate->uriTemplate)); + } + foreach ($segments as $segment) { if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { $varName = $matches[1]; diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php index bbbef766..94b0db23 100644 --- a/src/Capability/RegistryInterface.php +++ b/src/Capability/RegistryInterface.php @@ -112,6 +112,8 @@ public function hasTools(): bool; /** * Gets all registered tools. + * + * @return Page */ public function getTools(?int $limit = null, ?string $cursor = null): Page; @@ -129,6 +131,8 @@ public function hasResources(): bool; /** * Gets all registered resources. + * + * @return Page */ public function getResources(?int $limit = null, ?string $cursor = null): Page; @@ -146,6 +150,8 @@ public function hasResourceTemplates(): bool; /** * Gets all registered resource templates. + * + * @return Page */ public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; @@ -163,6 +169,8 @@ public function hasPrompts(): bool; /** * Gets all registered prompts. + * + * @return Page */ public function getPrompts(?int $limit = null, ?string $cursor = null): Page; diff --git a/src/Client.php b/src/Client.php index 532f60a2..fac199b2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -16,6 +16,7 @@ use Mcp\Client\Protocol; use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestException; use Mcp\Exception\RuntimeException; use Mcp\Schema\Enum\LoggingLevel; @@ -246,6 +247,10 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl */ public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult { + if ('' === $uri) { + throw new InvalidArgumentException('Resource URI must not be empty.'); + } + $request = new ReadResourceRequest($uri); $response = $this->sendRequest($request, $onProgress); @@ -339,13 +344,14 @@ public function sendRootsListChanged(): void */ private function sendRequest(Request $request, ?callable $onProgress = null): Response { - if (!$this->isConnected()) { + $transport = $this->transport; + if (null === $transport || !$this->protocol->getState()->isInitialized()) { throw new ConnectionException('Client is not connected. Call connect() first.'); } $withProgress = null !== $onProgress; $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress)); - $response = $this->transport->runRequest($fiber, $onProgress); + $response = $transport->runRequest($fiber, $onProgress); if ($response instanceof Error) { throw RequestException::fromError($response); diff --git a/src/Client/Stateless/ToolCatalog.php b/src/Client/Stateless/ToolCatalog.php index 3348db00..0e35570a 100644 --- a/src/Client/Stateless/ToolCatalog.php +++ b/src/Client/Stateless/ToolCatalog.php @@ -32,7 +32,7 @@ */ final class ToolCatalog { - /** @var array> tool name to input schema */ + /** @var array> tool name to input schema */ private array $schemas = []; /** @var array tool name to the reason it was refused */ @@ -50,17 +50,17 @@ public function __construct( * is how the client "rejects" it: it never reaches the caller, so it cannot * be called, and the tools listed beside it are untouched. * - * @param list> $tools raw `tools/list` entries + * @param array $tools raw `tools/list` entries * - * @return list> + * @return list */ public function record(array $tools): array { $usable = []; foreach ($tools as $tool) { - $name = $tool['name'] ?? null; - $schema = $tool['inputSchema'] ?? null; + $name = \is_array($tool) ? $tool['name'] ?? null : null; + $schema = \is_array($tool) ? $tool['inputSchema'] ?? null : null; if (!\is_string($name) || !\is_array($schema)) { $usable[] = $tool; diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 3c12ea74..9df70500 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -98,15 +98,16 @@ public function __construct( public function connect(): void { - $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); + $fiber = new \Fiber(fn () => $this->handleInitialize()); + $this->activeFiber = $fiber; - $this->activeFiber->start(); + $fiber->start(); - while (!$this->activeFiber->isTerminated()) { + while (!$fiber->isTerminated()) { $this->tick(); } - $result = $this->activeFiber->getReturn(); + $result = $fiber->getReturn(); $this->activeFiber = null; if ($result instanceof Error) { @@ -288,7 +289,7 @@ private function processSSEStream(): void } } - if ($this->activeStream->eof()) { + if (null !== $this->activeStream && $this->activeStream->eof()) { // The stream ended without a trailing blank line: dispatch what is left. if (!empty(trim($this->sseBuffer))) { $this->processSSEEvent($this->sseBuffer); diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index f1029619..95590cf9 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -88,15 +88,16 @@ public function connect(): void { $this->spawnProcess(); - $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); + $fiber = new \Fiber(fn () => $this->handleInitialize()); + $this->activeFiber = $fiber; - $this->activeFiber->start(); + $fiber->start(); - while (!$this->activeFiber->isTerminated()) { + while (!$fiber->isTerminated()) { $this->tick(); } - $result = $this->activeFiber->getReturn(); + $result = $fiber->getReturn(); $this->activeFiber = null; if ($result instanceof Error) { @@ -175,7 +176,7 @@ private function spawnProcess(): void $cmd .= ' '.escapeshellarg($arg); } - $this->process = proc_open( + $process = proc_open( $cmd, $descriptors, $pipes, @@ -183,10 +184,12 @@ private function spawnProcess(): void $this->env ); - if (!\is_resource($this->process)) { + if (!\is_resource($process)) { throw new ConnectionException('Failed to start process: '.$cmd); } + $this->process = $process; + $this->stdin = $pipes[0]; $this->stdout = $pipes[1]; $this->stderr = $pipes[2]; diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 75d50ff6..88ea3f6e 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -146,11 +146,11 @@ public function withExtensions(array $extensions): self /** * @return array{ - * roots?: object, - * sampling?: object, - * elicitation?: object, - * experimental?: object, - * extensions?: object, + * roots?: \stdClass, + * sampling?: \stdClass, + * elicitation?: \stdClass, + * experimental?: \stdClass, + * extensions?: \stdClass, * }|\stdClass */ public function jsonSerialize(): array|object diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php index cf7418c5..d38cd106 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -40,7 +40,7 @@ public function __construct( } /** - * @param AudioContentData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index ee126fbc..5db4bf5d 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -43,7 +43,7 @@ public function __construct( } /** - * @param BlobResourceContentsData $data + * @param array $data */ public static function fromArray(array $data): self { @@ -71,6 +71,9 @@ public static function fromArray(array $data): self public static function fromStream(string $uri, $stream, string $mimeType, ?array $meta = null): self { $blob = stream_get_contents($stream); + if (false === $blob) { + throw new InvalidArgumentException('Could not read stream.'); + } return new self($uri, $mimeType, base64_encode($blob), $meta); } @@ -80,8 +83,11 @@ public static function fromStream(string $uri, $stream, string $mimeType, ?array * */ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?array $meta = null): self { - $mimeType = $explicitMimeType ?? mime_content_type($file->getPathname()); + $mimeType = $explicitMimeType ?? (mime_content_type($file->getPathname()) ?: null); $blob = file_get_contents($file->getPathname()); + if (false === $blob) { + throw new InvalidArgumentException(\sprintf('Could not read file: "%s".', $file->getPathname())); + } return new self($uri, $mimeType, base64_encode($blob), $meta); } diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php index 76c4d679..5cb27a24 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -40,7 +40,7 @@ public function __construct( } /** - * @param EmbeddedResourceData $data + * @param array $data */ public static function fromArray(array $data): self { @@ -121,7 +121,7 @@ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string throw new RuntimeException(\sprintf('Could not read file: "%s".', $file->getPathname())); } - return new self(new BlobResourceContents($uri, $explicitMimeType ?? mime_content_type($file->getPathname()), base64_encode($content)), $annotations); + return new self(new BlobResourceContents($uri, $explicitMimeType ?? (mime_content_type($file->getPathname()) ?: null), base64_encode($content)), $annotations); } /** @@ -134,7 +134,7 @@ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string public function jsonSerialize(): array { $data = [ - 'type' => $this->type, + 'type' => 'resource', 'resource' => $this->resource, ]; if (null !== $this->annotations) { diff --git a/src/Schema/Content/ImageContent.php b/src/Schema/Content/ImageContent.php index 9e2cbffd..0837c38c 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -44,7 +44,7 @@ public function __construct( } /** - * @param ImageContentData $data + * @param array $data */ public static function fromArray(array $data): self { @@ -77,7 +77,12 @@ public static function fromFile(string $path, ?string $mimeType = null, ?Annotat throw new InvalidArgumentException(\sprintf('Image file not found: "%s".', $path)); } - $data = base64_encode(file_get_contents($path)); + $contents = file_get_contents($path); + if (false === $contents) { + throw new InvalidArgumentException(\sprintf('Could not read image file: "%s".', $path)); + } + + $data = base64_encode($contents); $detectedMime = $mimeType ?? mime_content_type($path) ?: 'image/png'; return new self($data, $detectedMime, $annotations); @@ -101,7 +106,7 @@ public static function fromString(string $data, string $mimeType, ?Annotations $ public function jsonSerialize(): array { $result = [ - 'type' => $this->type, + 'type' => 'image', 'data' => $this->data, 'mimeType' => $this->mimeType, ]; diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index d47f5ec9..a660c786 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -46,7 +46,7 @@ public function __construct( } /** - * @param PromptMessageData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/ResourceLink.php b/src/Schema/Content/ResourceLink.php index 946c9b82..0775ffe9 100644 --- a/src/Schema/Content/ResourceLink.php +++ b/src/Schema/Content/ResourceLink.php @@ -68,7 +68,7 @@ public function __construct( } /** - * @param ResourceLinkData $data + * @param array $data */ public static function fromArray(array $data): self { @@ -124,7 +124,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $data = [ - 'type' => $this->type, + 'type' => 'resource_link', 'uri' => $this->uri, 'name' => $this->name, ]; diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 17343d06..26d446e7 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -81,7 +81,7 @@ public function getContentBlocks(): array } /** - * @param SamplingMessageData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php index b743af57..d84f09ad 100644 --- a/src/Schema/Content/TextContent.php +++ b/src/Schema/Content/TextContent.php @@ -46,7 +46,7 @@ public function __construct( } /** - * @param TextContentData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php index 9beff811..ef28bfbb 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -43,7 +43,7 @@ public function __construct( } /** - * @param TextResourceContentsData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/ToolUseContent.php b/src/Schema/Content/ToolUseContent.php index 8952e8cb..d0401111 100644 --- a/src/Schema/Content/ToolUseContent.php +++ b/src/Schema/Content/ToolUseContent.php @@ -70,7 +70,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $data = [ - 'type' => $this->type, + 'type' => 'tool_use', 'id' => $this->id, 'name' => $this->name, 'input' => $this->input ?: new \stdClass(), diff --git a/src/Schema/Elicitation/AbstractSchemaDefinition.php b/src/Schema/Elicitation/AbstractSchemaDefinition.php index a05135f1..a6164bde 100644 --- a/src/Schema/Elicitation/AbstractSchemaDefinition.php +++ b/src/Schema/Elicitation/AbstractSchemaDefinition.php @@ -43,7 +43,7 @@ protected static function validateTitle(array $data, string $schemaType): void /** * Build the base JSON structure with type, optional title and description. * - * @return array + * @return array{type: string, title?: string, description?: string} */ protected function buildBaseJson(string $type): array { diff --git a/src/Schema/Elicitation/BooleanSchemaDefinition.php b/src/Schema/Elicitation/BooleanSchemaDefinition.php index 39766858..b1118a0a 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -32,11 +32,7 @@ public function __construct( } /** - * @param array{ - * title?: string, - * description?: string, - * default?: bool, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index 36d130af..35d09e36 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -47,11 +47,7 @@ public function __construct( /** * Create an ElicitationSchema from array data. * - * @param array{ - * type?: string, - * properties: array, - * required?: string[], - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 1eaef8b5..cb335dd8 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -58,13 +58,7 @@ public function __construct( } /** - * @param array{ - * title?: string, - * enum: string[], - * description?: string, - * default?: string, - * enumNames?: string[], - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php index ce2ab08e..11a0a1b6 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -72,14 +72,7 @@ public function __construct( } /** - * @param array{ - * title?: string, - * items: array{type: string, enum: string[]}, - * description?: string, - * default?: string[], - * minItems?: int, - * maxItems?: int, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php index d2bf540c..5b5540a2 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -58,14 +58,7 @@ public function __construct( } /** - * @param array{ - * type: string, - * title?: string, - * description?: string, - * default?: int|float, - * minimum?: int|float, - * maximum?: int|float, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php index 36c5e447..953f4973 100644 --- a/src/Schema/Elicitation/StringSchemaDefinition.php +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -60,14 +60,7 @@ public function __construct( } /** - * @param array{ - * title?: string, - * description?: string, - * default?: string, - * format?: string, - * minLength?: int, - * maxLength?: int, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php index f6c47d4f..8ff3364a 100644 --- a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php @@ -24,14 +24,19 @@ final class TitledEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param ?string $title Optional human-readable title for the field - * @param list $oneOf Array of const/title pairs - * @param string|null $description Optional description/help text - * @param string|null $default Optional default value (must match a const) + * @var list + */ + public readonly array $oneOf; + + /** + * @param ?string $title Optional human-readable title for the field + * @param array $oneOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string|null $default Optional default value (must match a const) */ public function __construct( ?string $title, - public readonly array $oneOf, + array $oneOf, ?string $description = null, public readonly ?string $default = null, ) { @@ -42,28 +47,27 @@ public function __construct( } $consts = []; + $pairs = []; foreach ($oneOf as $item) { - if (!isset($item['const']) || !\is_string($item['const'])) { + if (!\is_array($item) || !isset($item['const']) || !\is_string($item['const'])) { throw new InvalidArgumentException('Each oneOf item must have a string "const" property.'); } if (!isset($item['title']) || !\is_string($item['title'])) { throw new InvalidArgumentException('Each oneOf item must have a string "title" property.'); } $consts[] = $item['const']; + $pairs[] = ['const' => $item['const'], 'title' => $item['title']]; } + $this->oneOf = $pairs; + if (null !== $default && !\in_array($default, $consts, true)) { throw new InvalidArgumentException(\sprintf('Default value "%s" is not in the oneOf const values.', $default)); } } /** - * @param array{ - * title?: string, - * oneOf: list, - * description?: string, - * default?: string, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php index ee49a053..43c0494c 100644 --- a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php @@ -23,16 +23,21 @@ final class TitledMultiSelectEnumSchemaDefinition extends AbstractSchemaDefinition { /** - * @param ?string $title Optional human-readable title for the field - * @param list $anyOf Array of const/title pairs - * @param string|null $description Optional description/help text - * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) - * @param int|null $minItems Optional minimum number of selections - * @param int|null $maxItems Optional maximum number of selections + * @var list + */ + public readonly array $anyOf; + + /** + * @param ?string $title Optional human-readable title for the field + * @param array $anyOf Array of const/title pairs + * @param string|null $description Optional description/help text + * @param string[]|null $default Optional default selected values (must be subset of anyOf consts) + * @param int|null $minItems Optional minimum number of selections + * @param int|null $maxItems Optional maximum number of selections */ public function __construct( ?string $title, - public readonly array $anyOf, + array $anyOf, ?string $description = null, public readonly ?array $default = null, public readonly ?int $minItems = null, @@ -45,16 +50,20 @@ public function __construct( } $consts = []; + $pairs = []; foreach ($anyOf as $item) { - if (!isset($item['const']) || !\is_string($item['const'])) { + if (!\is_array($item) || !isset($item['const']) || !\is_string($item['const'])) { throw new InvalidArgumentException('Each anyOf item must have a string "const" property.'); } if (!isset($item['title']) || !\is_string($item['title'])) { throw new InvalidArgumentException('Each anyOf item must have a string "title" property.'); } $consts[] = $item['const']; + $pairs[] = ['const' => $item['const'], 'title' => $item['title']]; } + $this->anyOf = $pairs; + if (null !== $minItems && $minItems < 0) { throw new InvalidArgumentException('minItems must be non-negative.'); } @@ -77,14 +86,7 @@ public function __construct( } /** - * @param array{ - * title?: string, - * items: array{anyOf: list}, - * description?: string, - * default?: string[], - * minItems?: int, - * maxItems?: int, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index 03ebddfd..c0666b88 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -80,7 +80,13 @@ public static function latestHandshake(): self */ public static function handshakeVersions(): array { - return array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern())); + $versions = array_values(array_filter(self::cases(), static fn (self $v): bool => !$v->isModern())); + + if ([] === $versions) { + throw new LogicException('No handshake-era protocol revision is declared.'); + } + + return $versions; } /** @@ -90,7 +96,13 @@ public static function handshakeVersions(): array */ public static function modernVersions(): array { - return array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern())); + $versions = array_values(array_filter(self::cases(), static fn (self $v): bool => $v->isModern())); + + if ([] === $versions) { + throw new LogicException('No modern-era protocol revision is declared.'); + } + + return $versions; } /** diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php index 235df2a4..39edd8f3 100644 --- a/src/Schema/Extension/Apps/UiToolMeta.php +++ b/src/Schema/Extension/Apps/UiToolMeta.php @@ -50,7 +50,7 @@ public static function fromArray(array $data): self return new self( resourceUri: $data['resourceUri'] ?? null, - visibility: isset($data['visibility']) ? array_map( + visibility: isset($data['visibility']) ? array_values(array_map( static function (mixed $entry): ToolVisibility { if (!\is_string($entry) || null === $case = ToolVisibility::tryFrom($entry)) { throw new InvalidArgumentException('Each entry in "visibility" of UiToolMeta data must be a valid tool visibility.'); @@ -59,7 +59,7 @@ static function (mixed $entry): ToolVisibility { return $case; }, $data['visibility'], - ) : null, + )) : null, ); } diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index 27513435..41b2788a 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -63,7 +63,7 @@ public function __construct( } /** - * @param IconData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index b48be544..95c3ba39 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -37,14 +37,7 @@ public function __construct( } /** - * @param array{ - * name: string, - * version: string, - * description?: string, - * icons?: IconData[], - * websiteUrl?: string, - * title?: string, - * } $data + * @param array $data */ public static function fromArray(array $data): self { @@ -55,12 +48,13 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); } + $icons = null; if (isset($data['icons'])) { if (!\is_array($data['icons'])) { throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); } - $data['icons'] = Icon::listFromArray($data['icons'], 'Implementation'); + $icons = Icon::listFromArray($data['icons'], 'Implementation'); } if (isset($data['description']) && !\is_string($data['description'])) { @@ -77,7 +71,7 @@ public static function fromArray(array $data): self $data['name'], $data['version'], $data['description'] ?? null, - $data['icons'] ?? null, + $icons, $data['websiteUrl'] ?? null, $data['title'] ?? null, ); diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 7749fa8f..35590444 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -73,7 +73,7 @@ public function __construct( } /** - * @param ErrorData $data + * @param array $data */ final public static function fromArray(array $data): self { diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 4c64881e..5acadbbc 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -47,7 +47,7 @@ public function getId(): string|int } /** - * @param ResponseData $data + * @param array $data * * @return self> */ @@ -65,11 +65,19 @@ public static function fromArray(array $data): self if (!isset($data['result'])) { throw new InvalidArgumentException('Response must contain "result" field.'); } - if (!\is_array($data['result'])) { + $result = $data['result']; + if (!\is_array($result)) { throw new InvalidArgumentException('Response "result" must be an array.'); } - return new self($data['id'], $data['result']); + // A decoded JSON object is already string-keyed; re-keying only restates + // that for the result type, and PHP folds numeric keys back to integers. + $members = []; + foreach ($result as $key => $value) { + $members[(string) $key] = $value; + } + + return new self($data['id'], $members); } /** diff --git a/src/Schema/Page.php b/src/Schema/Page.php index 3d546464..419dd2fd 100644 --- a/src/Schema/Page.php +++ b/src/Schema/Page.php @@ -12,14 +12,14 @@ namespace Mcp\Schema; /** - * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|ResourceDefinition + * @template TItem of Tool|Prompt|ResourceTemplate|ResourceDefinition * - * @extends \ArrayObject + * @extends \ArrayObject */ final class Page extends \ArrayObject { /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or ResourceDefinition */ public function __construct( public readonly array $references, diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 0674066c..29754663 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -59,7 +59,7 @@ protected static function fromParams(?array $params): static } /** - * @return array{name: string, arguments: array} + * @return array{name: string, arguments: array|\stdClass} */ protected function getParams(): array { diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php index 7ad0332c..34eb57dc 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -54,7 +54,10 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Missing or invalid "argument" parameter for completion/complete.'); } - return new self($ref, $params['argument']); + return new self($ref, [ + 'name' => self::refString($params['argument'], 'name'), + 'value' => self::refString($params['argument'], 'value'), + ]); } /** diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index 15a74ee1..6541c7ab 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -110,17 +110,25 @@ protected static function fromParams(?array $params): static protected function getParams(): array { if (ElicitationMode::Url === $this->mode) { - return [ + $params = [ 'message' => $this->message, 'mode' => $this->mode->value, - 'url' => $this->url, ]; + + if (null !== $this->url) { + $params['url'] = $this->url; + } + + return $params; } // We don't need to send the mode if it's the default (form). - return [ - 'message' => $this->message, - 'requestedSchema' => $this->requestedSchema, - ]; + $params = ['message' => $this->message]; + + if (null !== $this->requestedSchema) { + $params['requestedSchema'] = $this->requestedSchema; + } + + return $params; } } diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index 69c44d5f..91dbf211 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -69,7 +69,7 @@ public function __construct( } /** - * @param ResourceDefinitionData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46033975..f42e0c73 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -62,7 +62,7 @@ public function __construct( } /** - * @param ResourceTemplateData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index 533d28f1..b83cf8d9 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -42,7 +42,7 @@ public function __construct( * answers has to be passed in: it decides whether an accepted response is * expected to carry content. * - * @param array{action: string, content?: array} $data + * @param array $data */ public static function fromArray(array $data, ElicitationMode $mode = ElicitationMode::Form): self { diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index e80c28ca..150eb0d0 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -44,13 +44,7 @@ public function __construct( } /** - * @param array{ - * protocolVersion: string, - * capabilities: array, - * serverInfo: array, - * instructions?: string, - * _meta?: array, - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index 613b53f6..2c42ec81 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -39,10 +39,7 @@ public function __construct( } /** - * @param array{ - * roots: array, - * _meta?: ?array - * } $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 97a2e3af..b327c326 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -50,7 +50,7 @@ public function __construct( } /** - * @param RootData $data + * @param array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 33cc1ee1..f7b4af34 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -140,13 +140,13 @@ public function withExtensions(array $extensions): self /** * @return array{ - * logging?: object, - * completions?: object, - * prompts?: object, - * resources?: object, - * tools?: object, - * experimental?: object, - * extensions?: object, + * logging?: \stdClass, + * completions?: \stdClass, + * prompts?: \stdClass, + * resources?: \stdClass, + * tools?: \stdClass, + * experimental?: \stdClass, + * extensions?: \stdClass, * } */ public function jsonSerialize(): array diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 1e939f6d..aabb274e 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -22,16 +22,10 @@ * * @phpstan-type ToolInputSchema array{ * type: 'object', - * properties: array|\stdClass, - * required: string[]|null - * } - * @phpstan-type ToolOutputSchema array{ - * type?: string, * properties?: array|\stdClass, - * required?: string[]|null, - * additionalProperties?: bool|array|\stdClass, - * description?: string + * required?: array|null * } + * @phpstan-type ToolOutputSchema array * @phpstan-type ToolData array{ * name: string, * title?: string, @@ -95,18 +89,18 @@ class Tool implements \JsonSerializable public readonly ?array $outputSchema; /** - * @param string $name the name of the tool - * @param ?string $title Optional human-readable title for display in UI - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ?string $description A human-readable description of the tool. - * This can be used by clients to improve the LLM's understanding of - * available tools. It can be thought of like a "hint" to the model. - * @param ?ToolAnnotations $annotations optional additional tool information - * @param ?Icon[] $icons optional icons representing the tool - * @param ?array $meta Optional metadata - * @param ToolOutputSchema|null $outputSchema Optional JSON Schema (as a PHP array) describing the tool's - * structuredContent. Unlike $inputSchema its root is unconstrained — - * it may describe an array, a primitive, or a composition. + * @param string $name the name of the tool + * @param ?string $title Optional human-readable title for display in UI + * @param array $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool + * @param ?string $description A human-readable description of the tool. + * This can be used by clients to improve the LLM's understanding of + * available tools. It can be thought of like a "hint" to the model. + * @param ?ToolAnnotations $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool + * @param ?array $meta Optional metadata + * @param array|null $outputSchema Optional JSON Schema (as a PHP array) describing the tool's + * structuredContent. Unlike $inputSchema its root is unconstrained — + * it may describe an array, a primitive, or a composition. */ public function __construct( public readonly string $name, @@ -124,7 +118,7 @@ public function __construct( // Always normalize here so every construction path emits `{}` for empty // sub-schemas — not only SchemaGenerator / fromArray. - $this->inputSchema = self::normalizeSchema($inputSchema); + $this->inputSchema = self::normalizeInputSchema($inputSchema); $this->outputSchema = null !== $outputSchema ? self::normalizeSchema($outputSchema) : null; // An out-of-bounds `x-mcp-header` reachable through `properties` makes @@ -137,7 +131,7 @@ public function __construct( } /** - * @param ToolData $data + * @param array $data */ public static function fromArray(array $data): self { @@ -206,6 +200,31 @@ public function jsonSerialize(): array return $data; } + /** + * Normalize an input schema, restating the guarantees the constructor validated. + * + * @param array $schema + * + * @return ToolInputSchema + */ + private static function normalizeInputSchema(array $schema): array + { + $normalized = self::normalizeSchema($schema); + $normalized['type'] = 'object'; + + if (\array_key_exists('properties', $normalized)) { + $properties = $normalized['properties']; + $normalized['properties'] = \is_array($properties) || $properties instanceof \stdClass ? $properties : new \stdClass(); + } + + if (\array_key_exists('required', $normalized)) { + $required = $normalized['required']; + $normalized['required'] = \is_array($required) ? $required : null; + } + + return $normalized; + } + /** * Normalize a JSON Schema so that empty sub-schemas JSON-encode as `{}` rather than `[]`. * diff --git a/src/Schema/ToolChoice.php b/src/Schema/ToolChoice.php index ad003232..cbde2232 100644 --- a/src/Schema/ToolChoice.php +++ b/src/Schema/ToolChoice.php @@ -28,16 +28,19 @@ public function __construct( } /** - * @param array{mode?: string} $data + * @param array $data */ public static function fromArray(array $data): self { - if (\array_key_exists('mode', $data) && !\is_string($data['mode'])) { + if (!\array_key_exists('mode', $data)) { + return new self(ToolChoiceMode::Auto); + } + + if (!\is_string($data['mode'])) { throw new InvalidArgumentException('Invalid "mode" in ToolChoice data.'); } - $mode = \array_key_exists('mode', $data) ? ToolChoiceMode::tryFrom($data['mode']) : ToolChoiceMode::Auto; - if (null === $mode) { + if (null === $mode = ToolChoiceMode::tryFrom($data['mode'])) { throw new InvalidArgumentException(\sprintf('Invalid tool choice mode "%s".', $data['mode'])); } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 1a5dd73b..88006382 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -1036,7 +1036,7 @@ private function resolve(): array if (null !== $this->discoveryBasePath) { if (null !== $this->discoverer || class_exists(Finder::class)) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); - $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns, $logger); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer, $this->discoveryNamePatterns ?? DiscovererInterface::DEFAULT_NAME_PATERNS, $logger); } else { $logger->warning('File-based discovery requires symfony/finder. Skipping automatic discovery. Run: composer require symfony/finder'); } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index a60e539c..d4452b15 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -157,12 +157,13 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int if (\is_string($message)) { $message = new TextContent($message); } - if (\is_object($message) && \in_array($message::class, [TextContent::class, AudioContent::class, ImageContent::class], true)) { - $message = [new SamplingMessage(Role::User, $message)]; - } + + $messages = $message instanceof TextContent || $message instanceof AudioContent || $message instanceof ImageContent + ? [new SamplingMessage(Role::User, $message)] + : $message; $request = new CreateSamplingMessageRequest( - messages: $message, + messages: $messages, maxTokens: $maxTokens, preferences: $preferences, systemPrompt: $options['systemPrompt'] ?? null, diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index f460c3c7..f3302de3 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -247,7 +247,15 @@ private function handleInvalidMessage(TransportInterface $transport, InvalidInpu */ private function dispatchEvent(object $event): object { - return $this->eventDispatcher?->dispatch($event) ?? $event; + if (null === $this->eventDispatcher) { + return $event; + } + + $dispatched = $this->eventDispatcher->dispatch($event); + + // PSR-14 dispatchers return the event they were given; a dispatcher that + // swaps it for something else is not what the caller asked to dispatch. + return $dispatched instanceof $event ? $dispatched : $event; } /** diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 4db11f71..39a196b1 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -66,18 +66,17 @@ public function get(string $key, mixed $default = null): mixed public function set(string $key, mixed $value, bool $overwrite = true): void { $segments = explode('.', $key); + $lastKey = array_pop($segments); $this->readData(); $data = &$this->data; - while (\count($segments) > 1) { - $segment = array_shift($segments); + foreach ($segments as $segment) { if (!isset($data[$segment]) || !\is_array($data[$segment])) { $data[$segment] = []; } $data = &$data[$segment]; } - $lastKey = array_shift($segments); if ($overwrite || !isset($data[$lastKey])) { $data[$lastKey] = $value; } @@ -104,18 +103,18 @@ public function has(string $key): bool public function forget(string $key): void { $segments = explode('.', $key); + $lastKey = array_pop($segments); $this->readData(); $data = &$this->data; - while (\count($segments) > 1) { - $segment = array_shift($segments); + foreach ($segments as $segment) { if (!isset($data[$segment]) || !\is_array($data[$segment])) { return; } $data = &$data[$segment]; } - unset($data[array_shift($segments)]); + unset($data[$lastKey]); } public function clear(): void diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index a179a217..69851bd9 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -530,7 +530,7 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str * server genuinely does not implement it — but naming the extension turns * an opaque refusal into something the caller can act on. */ - private function unknownMethod(string $method, string|int $id): Error + private function unknownMethod(string $method, string|int|null $id): Error { $extension = $this->extensionMethods[$method] ?? null; diff --git a/src/Server/Subscription/PublishingEventDispatcher.php b/src/Server/Subscription/PublishingEventDispatcher.php index a1c4e688..15016156 100644 --- a/src/Server/Subscription/PublishingEventDispatcher.php +++ b/src/Server/Subscription/PublishingEventDispatcher.php @@ -30,7 +30,7 @@ */ final class PublishingEventDispatcher implements EventDispatcherInterface { - /** @var array */ + /** @var array */ private readonly array $listeners; public function __construct( diff --git a/src/Server/Subscription/RegistryChangePublisher.php b/src/Server/Subscription/RegistryChangePublisher.php index f54a356d..363a2aea 100644 --- a/src/Server/Subscription/RegistryChangePublisher.php +++ b/src/Server/Subscription/RegistryChangePublisher.php @@ -67,7 +67,7 @@ public function onResourceTemplateListChanged(ResourceTemplateListChangedEvent $ * Shaped for a dispatcher that wants a map; a framework's own subscriber * conventions can read it too rather than restating the list. * - * @return array + * @return array */ public function listeners(): array { diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php index f59a6dc8..90883023 100644 --- a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -210,7 +210,7 @@ private function createAuthServerMetadataResponse(): ResponseInterface private function createErrorResponse(int $status, string $message): ResponseInterface { - $body = json_encode(['error' => 'server_error', 'error_description' => $message]); + $body = json_encode(['error' => 'server_error', 'error_description' => $message], \JSON_THROW_ON_ERROR); return $this->responseFactory ->createResponse($status) diff --git a/src/Server/Transport/ManagesTransportCallbacks.php b/src/Server/Transport/ManagesTransportCallbacks.php index 072d3f0e..1f53424f 100644 --- a/src/Server/Transport/ManagesTransportCallbacks.php +++ b/src/Server/Transport/ManagesTransportCallbacks.php @@ -38,7 +38,7 @@ trait ManagesTransportCallbacks /** @var callable(Uuid): array> */ protected $pendingRequestsProvider; - /** @var callable(int, Uuid): Response>|Error|null */ + /** @var (callable(int, Uuid): (Response>|Error|null))|null */ protected $responseFinder; /** @var callable(FiberSuspend|null, ?Uuid): void */ diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 565f7da7..92a49887 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -21,6 +21,8 @@ /** * @extends BaseTransport * + * @phpstan-import-type McpFiber from TransportInterface + * * @author Kyrian Obikwelu */ class StdioTransport extends BaseTransport @@ -33,6 +35,9 @@ class StdioTransport extends BaseTransport /** Whether the current over-length line is still being drained and discarded. */ private bool $discardingLine = false; + /** @var positive-int */ + private readonly int $maxLineBytes; + /** * @param resource $input * @param resource $output @@ -46,13 +51,15 @@ public function __construct( private $output = \STDOUT, ?LoggerInterface $logger = null, private readonly RunnerControlInterface $runnerControl = new RunnerControl(), - private readonly int $maxLineBytes = self::DEFAULT_MAX_LINE_BYTES, + int $maxLineBytes = self::DEFAULT_MAX_LINE_BYTES, ) { parent::__construct($logger); if ($maxLineBytes < 1) { throw new InvalidArgumentException(\sprintf('The maximum line size must be a positive number of bytes, got %d.', $maxLineBytes)); } + + $this->maxLineBytes = $maxLineBytes; } public function send(string $data, array $context): void @@ -129,7 +136,7 @@ private function processFiber(): void } if ($this->sessionFiber->isTerminated()) { - $this->handleFiberTermination(); + $this->handleFiberTermination($this->sessionFiber); return; } @@ -171,9 +178,12 @@ private function processFiber(): void } } - private function handleFiberTermination(): void + /** + * @param McpFiber $fiber + */ + private function handleFiberTermination(\Fiber $fiber): void { - $finalResult = $this->sessionFiber->getReturn(); + $finalResult = $fiber->getReturn(); if (null !== $finalResult) { try { diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index c01503eb..39da2957 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -49,6 +49,8 @@ * * @extends BaseTransport * + * @phpstan-import-type McpFiber from TransportInterface + * * @author Kyrian Obikwelu */ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTransportInterface @@ -246,17 +248,23 @@ protected function createJsonResponse(): ResponseInterface protected function createStreamedResponse(): ResponseInterface { - $callback = function (): void { + $fiber = $this->sessionFiber; + + $callback = function () use ($fiber): void { + if (null === $fiber) { + return; + } + try { $this->logger->info('SSE: Starting request processing loop'); - while ($this->sessionFiber->isSuspended()) { + while ($fiber->isSuspended()) { $this->flushOutgoingMessages($this->sessionId); $pendingRequests = $this->getPendingRequests($this->sessionId); if (empty($pendingRequests)) { - $yielded = $this->sessionFiber->resume(); + $yielded = $fiber->resume(); $this->handleFiberYield($yielded, $this->sessionId); continue; } @@ -270,7 +278,7 @@ protected function createStreamedResponse(): ResponseInterface $response = $this->checkForResponse($requestId, $this->sessionId); if (null !== $response) { - $yielded = $this->sessionFiber->resume($response); + $yielded = $fiber->resume($response); $this->handleFiberYield($yielded, $this->sessionId); $resumed = true; break; @@ -278,7 +286,7 @@ protected function createStreamedResponse(): ResponseInterface if ($this->clock->now()->getTimestamp() - $timestamp >= $timeout) { $error = Error::forInternalError('Request timed out', $requestId); - $yielded = $this->sessionFiber->resume($error); + $yielded = $fiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); $resumed = true; break; @@ -290,7 +298,7 @@ protected function createStreamedResponse(): ResponseInterface } // Prevent tight loop } - $this->handleFiberTermination(); + $this->handleFiberTermination($fiber); } finally { $this->sessionFiber = null; } @@ -311,9 +319,12 @@ protected function createStreamedResponse(): ResponseInterface return $response; } - protected function handleFiberTermination(): void + /** + * @param McpFiber $fiber + */ + protected function handleFiberTermination(\Fiber $fiber): void { - $finalResult = $this->sessionFiber->getReturn(); + $finalResult = $fiber->getReturn(); if (null !== $finalResult) { try { diff --git a/src/Server/Wire/InboundClassifier.php b/src/Server/Wire/InboundClassifier.php index 826284df..6956f495 100644 --- a/src/Server/Wire/InboundClassifier.php +++ b/src/Server/Wire/InboundClassifier.php @@ -175,7 +175,7 @@ private function classifyMessage(array $message, ?string $headerVersion): EraCla return self::eraOf($claim); } - if (!self::namesModern($headerVersion)) { + if (null === $headerVersion || !self::namesModern($headerVersion)) { return EraClassification::legacy(); } @@ -215,8 +215,8 @@ private static function eraOf(string $version): EraClassification * version this endpoint does not serve, and the handshake leg's version * middleware is what says so, naming everything the endpoint does serve. */ - private static function namesModern(?string $version): bool + private static function namesModern(string $version): bool { - return null !== $version && true === ProtocolVersion::tryFrom($version)?->isModern(); + return true === ProtocolVersion::tryFrom($version)?->isModern(); } } diff --git a/tests/Conformance/Elements.php b/tests/Conformance/Elements.php index d7eac2b3..627c5ccb 100644 --- a/tests/Conformance/Elements.php +++ b/tests/Conformance/Elements.php @@ -200,7 +200,7 @@ public function resourceTemplate(string $id): TextResourceContents 'id' => $id, 'templateTest' => true, 'data' => \sprintf('Data for ID: %s', $id), - ]), + ], \JSON_THROW_ON_ERROR), ); } diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php index 51a00b72..d5c6df8c 100644 --- a/tests/Inspector/Http/HttpSchemaShowcaseTest.php +++ b/tests/Inspector/Http/HttpSchemaShowcaseTest.php @@ -92,7 +92,7 @@ protected function getServerScript(): string protected function normalizeTestOutput(string $output, ?string $testName = null): string { - return match ($testName) { + $normalized = match ($testName) { 'validate_profile' => preg_replace( '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', '2025-01-01 00:00:00', @@ -112,5 +112,7 @@ protected function normalizeTestOutput(string $output, ?string $testName = null) ], $output), default => $output, }; + + return $normalized ?? $output; } } diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index ddfbacb8..3d33c3b1 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -108,6 +108,7 @@ public function testOutputMatchesSnapshot( } $expected = file_get_contents($snapshotFile); + $this->assertNotFalse($expected, \sprintf('Could not read snapshot "%s".', $snapshotFile)); $message = \sprintf('Output does not match snapshot "%s".', $snapshotFile); $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput, $message); diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php index 9e1affd0..d9510055 100644 --- a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php +++ b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php @@ -60,7 +60,7 @@ protected function getServerScript(): string protected function normalizeTestOutput(string $output, ?string $testName = null): string { - return match ($testName) { + $normalized = match ($testName) { 'add_task' => preg_replace( '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', '2025-01-01T00:00:00+00:00', @@ -73,5 +73,7 @@ protected function normalizeTestOutput(string $output, ?string $testName = null) ), default => $output, }; + + return $normalized ?? $output; } } diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5d213209..67479f5f 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -76,8 +76,10 @@ public function testServerInfoIsExchanged(): void { $client = $this->connect('handshake'); - $this->assertSame('integration-server', $client->getServerInfo()->name); - $this->assertSame('1.0.0', $client->getServerInfo()->version); + $serverInfo = $client->getServerInfo(); + $this->assertNotNull($serverInfo); + $this->assertSame('integration-server', $serverInfo->name); + $this->assertSame('1.0.0', $serverInfo->version); $this->assertSame('Be brief.', $client->getInstructions()); $this->assertTrue($client->isConnected()); } diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index 0f452865..9de36bf7 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -51,9 +51,12 @@ public function testPromptReachesTheClient(): void $client->callTool('summarize', ['text' => 'inspect me']); $this->assertCount(1, $seen); - $this->assertInstanceOf(TextContent::class, $seen[0]->messages[0]->content); - $this->assertSame('inspect me', $seen[0]->messages[0]->content->text); - $this->assertSame(64, $seen[0]->maxTokens); + + $request = $seen[0]; + $this->assertInstanceOf(CreateSamplingMessageRequest::class, $request); + $this->assertInstanceOf(TextContent::class, $request->messages[0]->content); + $this->assertSame('inspect me', $request->messages[0]->content->text); + $this->assertSame(64, $request->maxTokens); } #[TestDox('a gateway parameter is injected, not published in the schema')] @@ -69,9 +72,12 @@ public function testGatewayParameterIsInjectedNotPublished(): void } $this->assertNotNull($tool); - $this->assertArrayNotHasKey('client', $tool->inputSchema['properties']); - $this->assertArrayHasKey('text', $tool->inputSchema['properties']); - $this->assertSame(['text'], $tool->inputSchema['required']); + + $properties = $tool->inputSchema['properties'] ?? null; + $this->assertIsArray($properties); + $this->assertArrayNotHasKey('client', $properties); + $this->assertArrayHasKey('text', $properties); + $this->assertSame(['text'], $tool->inputSchema['required'] ?? null); $result = $client->callTool('summarize_via_gateway', ['text' => 'a long report']); diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php index 107f163b..58725346 100644 --- a/tests/Integration/SamplingToolsTest.php +++ b/tests/Integration/SamplingToolsTest.php @@ -51,12 +51,18 @@ public function testToolsReachTheClient(): void $client->callTool('weather_report', ['city' => 'Paris']); $this->assertCount(2, $seen); - $this->assertSame('get_weather', $seen[0]->tools[0]->name); + + $first = $seen[0]; + $this->assertInstanceOf(CreateSamplingMessageRequest::class, $first); + $this->assertNotNull($first->tools); + $this->assertSame('get_weather', $first->tools[0]->name); // Second turn carries the assistant's tool use and the server's tool result. - $this->assertCount(3, $seen[1]->messages); - $this->assertInstanceOf(ToolUseContent::class, $seen[1]->messages[1]->getContentBlocks()[0]); - $toolResult = $seen[1]->messages[2]->getContentBlocks()[0]; + $second = $seen[1]; + $this->assertInstanceOf(CreateSamplingMessageRequest::class, $second); + $this->assertCount(3, $second->messages); + $this->assertInstanceOf(ToolUseContent::class, $second->messages[1]->getContentBlocks()[0]); + $toolResult = $second->messages[2]->getContentBlocks()[0]; $this->assertInstanceOf(ToolResultContent::class, $toolResult); $this->assertSame('call-1', $toolResult->toolUseId); } diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 5c716d7d..42886c67 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -45,12 +45,17 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertEquals('greet_user', $tools['greet_user']->tool->name); $this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description); $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler); - $this->assertArrayHasKey('name', $tools['greet_user']->tool->inputSchema['properties'] ?? []); + $greetProperties = $tools['greet_user']->tool->inputSchema['properties'] ?? []; + $this->assertIsArray($greetProperties); + $this->assertArrayHasKey('name', $greetProperties); $this->assertArrayHasKey('repeatAction', $tools); $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); + $this->assertNotNull($tools['repeatAction']->tool->annotations); $this->assertTrue($tools['repeatAction']->tool->annotations->readOnlyHint); - $this->assertEquals(['count', 'loudly', 'mode'], array_keys($tools['repeatAction']->tool->inputSchema['properties'] ?? [])); + $repeatProperties = $tools['repeatAction']->tool->inputSchema['properties'] ?? []; + $this->assertIsArray($repeatProperties); + $this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatProperties)); $this->assertArrayHasKey('InvokableCalculator', $tools); $this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']); @@ -75,16 +80,17 @@ public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles(): void $this->assertArrayHasKey('ui://widget/clock', $resources); $this->assertEquals(McpApps::MIME_TYPE, $resources['ui://widget/clock']->resource->mimeType); - $this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta)); + $this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta, \JSON_THROW_ON_ERROR)); $this->assertJsonStringEqualsJsonString( '{"ui":{"resourceUri":"ui://widget/clock","visibility":["app"]}}', - json_encode($tools['show_clock']->tool->meta), + json_encode($tools['show_clock']->tool->meta, \JSON_THROW_ON_ERROR), ); $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); $this->assertArrayHasKey('creative_story_prompt', $prompts); + $this->assertNotNull($prompts['creative_story_prompt']->prompt->arguments); $this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments); $this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']); diff --git a/tests/Unit/Capability/Discovery/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php index a2a832ad..2bddb1bc 100644 --- a/tests/Unit/Capability/Discovery/DocBlockParserTest.php +++ b/tests/Unit/Capability/Discovery/DocBlockParserTest.php @@ -105,7 +105,9 @@ public function testGetTagsByNameReturnsSpecificTags(): void $deprecatedTags = $docBlock->getTagsByName('deprecated'); $this->assertCount(1, $deprecatedTags); $this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]); - $this->assertEquals('use newMethod() instead', $deprecatedTags[0]->getDescription()->render()); + $deprecatedDescription = $deprecatedTags[0]->getDescription(); + $this->assertNotNull($deprecatedDescription); + $this->assertEquals('use newMethod() instead', $deprecatedDescription->render()); $seeTags = $docBlock->getTagsByName('see'); $this->assertCount(1, $seeTags); diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php index 9464dcac..01a86777 100644 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -181,7 +181,7 @@ public function testArrayItemValidationErrorPointer(): void public function testValidatesDataPassedAsStdClassObject(): void { $schema = $this->getSimpleSchema(); - $dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass + $dataObj = json_decode(json_encode($this->getValidData(), \JSON_THROW_ON_ERROR)); // Convert to stdClass $errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema); $this->assertEmpty($errors); diff --git a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php index 52bb1767..6a57c990 100644 --- a/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php +++ b/tests/Unit/Capability/Formatter/PromptResultFormatterTest.php @@ -208,7 +208,9 @@ public function testFormatTypedTextResourceContentDefaultsMimeType(): void ], ]); - $this->assertSame('text/plain', $result[0]->content->resource->mimeType); + $content = $result[0]->content; + $this->assertInstanceOf(EmbeddedResource::class, $content); + $this->assertSame('text/plain', $content->resource->mimeType); } public function testFormatTypedBlobResourceContentDefaultsMimeType(): void @@ -223,7 +225,9 @@ public function testFormatTypedBlobResourceContentDefaultsMimeType(): void ], ]); - $this->assertSame('application/octet-stream', $result[0]->content->resource->mimeType); + $content = $result[0]->content; + $this->assertInstanceOf(EmbeddedResource::class, $content); + $this->assertSame('application/octet-stream', $content->resource->mimeType); } public function testFormatTypedContentRejectsInvalidDataWithIndexContext(): void diff --git a/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php index 5488818e..e2c68d45 100644 --- a/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/ChainLoaderTest.php @@ -40,7 +40,9 @@ public function testLastWriterWinsForConflictingKeys(): void (new ChainLoader([$first, $second]))->load($registry); - $this->assertSame('second', ($registry->getTool('shared')->handler)()); + $handler = $registry->getTool('shared')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('second', $handler()); } public function testEmptyChainIsNoop(): void diff --git a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php index dcb1d8d4..25279a9f 100644 --- a/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/DiscoveryLoaderTest.php @@ -81,7 +81,9 @@ public function testLoadTwiceUnregistersStaleAndKeepsNew(): void $updatedResource = $this->registry->getResource('r://1', false); $this->assertInstanceOf(ResourceReference::class, $updatedResource); - $this->assertSame('r1-updated', ($updatedResource->handler)()); + $handler = $updatedResource->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('r1-updated', $handler()); $this->expectException(ToolNotFoundException::class); $this->registry->getTool('t1'); @@ -150,7 +152,9 @@ public function testLoadOverwritesPreviousRegistrationOnSameKey(): void ); $loader->load($this->registry); - $this->assertSame('v2', ($this->registry->getTool('t')->handler)()); + $handler = $this->registry->getTool('t')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('v2', $handler()); $this->assertSame(['arg' => ListCompletionProvider::class], $this->registry->getPrompt('p')->completionProviders); } @@ -166,7 +170,9 @@ public function testLoadPreservesConflictingRuntimeRegistration(): void )); (new DiscoveryLoader('/base', [], [], $discoverer))->load($this->registry); - $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); + $handler = $this->registry->getTool('shared')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('runtime', $handler()); } public function testLoadPreservesRuntimeOverrideOfPreviouslyOwnedEntry(): void @@ -187,7 +193,9 @@ public function testLoadPreservesRuntimeOverrideOfPreviouslyOwnedEntry(): void ); $loader->load($this->registry); - $this->assertSame('runtime', ($this->registry->getTool('shared')->handler)()); + $handler = $this->registry->getTool('shared')->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertSame('runtime', $handler()); } public function testLoadDoesNotUnregisterRuntimeAdditions(): void diff --git a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php index dad2de64..9403b1f4 100644 --- a/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php +++ b/tests/Unit/Capability/Registry/Loader/ExplicitElementLoaderTest.php @@ -14,6 +14,7 @@ use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\RegistryInterface; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Prompt; @@ -100,6 +101,7 @@ public function read(string $uri, ClientGateway $gateway): mixed $registry = $this->buildAndGetRegistry(static fn (Server\Builder $b) => $b->add($resource, $handler)); $reference = $registry->getResource('config://demo', false); + $this->assertInstanceOf(ResourceReference::class, $reference); $this->assertSame('config://demo', $reference->resource->uri); $this->assertSame('demo', $reference->resource->name); $this->assertSame('text/plain', $reference->resource->mimeType); diff --git a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php index 6dd09991..a8308a12 100644 --- a/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php +++ b/tests/Unit/Capability/Registry/Loader/ReflectedElementLoaderResourceTitleTest.php @@ -13,6 +13,7 @@ use Mcp\Capability\Registry; use Mcp\Capability\Registry\Loader\ReflectedElementLoader; +use Mcp\Capability\Registry\ResourceReference; use PHPUnit\Framework\TestCase; class ReflectedElementLoaderResourceTitleTest extends TestCase @@ -40,6 +41,7 @@ public function testLoadPropagatesResourceTitleToRegisteredResource(): void $loader->load($registry); $resourceRef = $registry->getResource('config://app/settings'); + $this->assertInstanceOf(ResourceReference::class, $resourceRef); $this->assertSame('Application Settings', $resourceRef->resource->title); } diff --git a/tests/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php index 92782ab9..cd005be5 100644 --- a/tests/Unit/Capability/RegistryTest.php +++ b/tests/Unit/Capability/RegistryTest.php @@ -38,7 +38,7 @@ class RegistryTest extends TestCase { private Registry $registry; - private LoggerInterface|MockObject $logger; + private LoggerInterface&MockObject $logger; protected function setUp(): void { @@ -100,7 +100,9 @@ public function testRegisterToolOverwritesPriorRegistration(): void $this->registry->registerTool($second, static fn () => 'second'); $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals('second', ($toolRef->handler)()); + $handler = $toolRef->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertEquals('second', $handler()); } public function testGetToolThrowsExceptionForUnregisteredTool(): void @@ -157,7 +159,9 @@ public function testRegisterResourceOverwritesPriorRegistration(): void $this->registry->registerResource($second, static fn () => 'second'); $resourceRef = $this->registry->getResource('test://resource'); - $this->assertEquals('second', ($resourceRef->handler)()); + $handler = $resourceRef->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertEquals('second', $handler()); } public function testGetResourceThrowsExceptionForUnregisteredResource(): void @@ -267,7 +271,9 @@ public function testRegisterResourceTemplateOverwritesPriorRegistration(): void $this->registry->registerResourceTemplate($second, static fn () => 'second'); $templateRef = $this->registry->getResourceTemplate('test://{id}'); - $this->assertEquals('second', ($templateRef->handler)()); + $handler = $templateRef->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertEquals('second', $handler()); } public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void @@ -349,7 +355,9 @@ public function testRegisterPromptOverwritesPriorRegistration(): void $this->registry->registerPrompt($second, static fn () => 'second'); $promptRef = $this->registry->getPrompt('test_prompt'); - $this->assertEquals('second', ($promptRef->handler)()); + $handler = $promptRef->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertEquals('second', $handler()); } public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void @@ -454,7 +462,9 @@ public function testMultipleRegistrationsOfSameElementWithSameType(): void // Second registration should override the first $toolRef = $this->registry->getTool('test_tool'); - $this->assertEquals('second', ($toolRef->handler)()); + $handler = $toolRef->handler; + $this->assertInstanceOf(\Closure::class, $handler); + $this->assertEquals('second', $handler()); } public function testExtractStructuredContentReturnsNullWhenOutputSchemaIsNull(): void diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 83a77e0b..a951727e 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -114,7 +114,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface 'capabilities' => ['tools' => ['listChanged' => false]], 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], ], - ]); + ], \JSON_THROW_ON_ERROR); return new Response(200, [ 'Content-Type' => 'application/json', diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index fb314083..ed7dc405 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -84,6 +84,7 @@ public function testRejectsNonPositiveCap(): void private function stream(string $contents) { $stream = fopen('php://temp', 'r+'); + $this->assertNotFalse($stream); fwrite($stream, $contents); rewind($stream); diff --git a/tests/Unit/Schema/ClientCapabilitiesTest.php b/tests/Unit/Schema/ClientCapabilitiesTest.php index c2176e9f..2e9162ae 100644 --- a/tests/Unit/Schema/ClientCapabilitiesTest.php +++ b/tests/Unit/Schema/ClientCapabilitiesTest.php @@ -21,7 +21,7 @@ public function testSerializesRootsWithoutListChanged(): void { $capabilities = new ClientCapabilities(roots: true); - $data = json_decode(json_encode($capabilities), true); + $data = json_decode(json_encode($capabilities, \JSON_THROW_ON_ERROR), true); $this->assertArrayHasKey('roots', $data); $this->assertSame([], $data['roots']); @@ -31,7 +31,7 @@ public function testSerializesRootsWithListChanged(): void { $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); - $data = json_decode(json_encode($capabilities), true); + $data = json_decode(json_encode($capabilities, \JSON_THROW_ON_ERROR), true); $this->assertSame(['listChanged' => true], $data['roots']); } @@ -63,7 +63,7 @@ public function testRoundTripPreservesRootsListChanged(): void { $capabilities = new ClientCapabilities(roots: true, rootsListChanged: true); - $data = json_decode(json_encode($capabilities), true); + $data = json_decode(json_encode($capabilities, \JSON_THROW_ON_ERROR), true); $restored = ClientCapabilities::fromArray($data); $this->assertTrue($restored->roots); @@ -75,10 +75,13 @@ public function testRoundTripPreservesSamplingSubCapabilities(): void $capabilities = new ClientCapabilities(sampling: true, samplingContext: true, samplingTools: true); $serialized = $capabilities->jsonSerialize(); - $this->assertObjectHasProperty('context', $serialized['sampling']); - $this->assertObjectHasProperty('tools', $serialized['sampling']); + $this->assertIsArray($serialized); + $sampling = $serialized['sampling'] ?? null; + $this->assertIsObject($sampling); + $this->assertObjectHasProperty('context', $sampling); + $this->assertObjectHasProperty('tools', $sampling); - $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities, \JSON_THROW_ON_ERROR), true)); $this->assertTrue($restored->sampling); $this->assertTrue($restored->samplingContext); @@ -90,10 +93,13 @@ public function testPlainSamplingLeavesSubCapabilitiesOff(): void $capabilities = new ClientCapabilities(sampling: true); $serialized = $capabilities->jsonSerialize(); - $this->assertObjectNotHasProperty('context', $serialized['sampling']); - $this->assertObjectNotHasProperty('tools', $serialized['sampling']); + $this->assertIsArray($serialized); + $sampling = $serialized['sampling'] ?? null; + $this->assertIsObject($sampling); + $this->assertObjectNotHasProperty('context', $sampling); + $this->assertObjectNotHasProperty('tools', $sampling); - $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities), true)); + $restored = ClientCapabilities::fromArray(json_decode(json_encode($capabilities, \JSON_THROW_ON_ERROR), true)); $this->assertTrue($restored->sampling); $this->assertFalse($restored->samplingContext); diff --git a/tests/Unit/Schema/Content/ImageContentTest.php b/tests/Unit/Schema/Content/ImageContentTest.php index b1672021..da5c214c 100644 --- a/tests/Unit/Schema/Content/ImageContentTest.php +++ b/tests/Unit/Schema/Content/ImageContentTest.php @@ -61,14 +61,14 @@ public function testJsonSerializeIncludesAnnotations(): void $data = $content->jsonSerialize(); - $this->assertSame($annotations, $data['annotations']); + $this->assertSame($annotations, $data['annotations'] ?? null); } public function testRoundTripWithAnnotations(): void { $original = new ImageContent(base64_encode('binary'), 'image/png', new Annotations([Role::User], 0.5)); - $decoded = json_decode(json_encode($original), true); + $decoded = json_decode(json_encode($original, \JSON_THROW_ON_ERROR), true); $rehydrated = ImageContent::fromArray($decoded); $this->assertSame($original->data, $rehydrated->data); diff --git a/tests/Unit/Schema/Content/PromptMessageTest.php b/tests/Unit/Schema/Content/PromptMessageTest.php index 8808d412..46a41657 100644 --- a/tests/Unit/Schema/Content/PromptMessageTest.php +++ b/tests/Unit/Schema/Content/PromptMessageTest.php @@ -47,14 +47,14 @@ public function testJsonSerializeIncludesResourceLinkContent(): void 'uri' => 'file:///a.png', 'name' => 'a.png', ], - ], json_decode(json_encode($message), true)); + ], json_decode(json_encode($message, \JSON_THROW_ON_ERROR), true)); } public function testRoundTripWithResourceLink(): void { $original = new PromptMessage(Role::User, new ResourceLink('file:///a.png', 'a.png', mimeType: 'image/png')); - $decoded = json_decode(json_encode($original), true); + $decoded = json_decode(json_encode($original, \JSON_THROW_ON_ERROR), true); $rehydrated = PromptMessage::fromArray($decoded); $this->assertSame(Role::User, $rehydrated->role); @@ -67,7 +67,6 @@ public function testFromArrayRejectsUnknownContentType(): void { $this->expectException(InvalidArgumentException::class); - /* @phpstan-ignore argument.type */ PromptMessage::fromArray([ 'role' => 'user', 'content' => ['type' => 'not-a-real-type'], diff --git a/tests/Unit/Schema/Content/ResourceLinkTest.php b/tests/Unit/Schema/Content/ResourceLinkTest.php index ca064558..4283a808 100644 --- a/tests/Unit/Schema/Content/ResourceLinkTest.php +++ b/tests/Unit/Schema/Content/ResourceLinkTest.php @@ -100,13 +100,13 @@ public function testJsonSerializeWithAllFields(): void $this->assertSame('resource_link', $data['type']); $this->assertSame(self::VALID_URI, $data['uri']); $this->assertSame('main.rs', $data['name']); - $this->assertSame('Main Source File', $data['title']); - $this->assertSame('Primary application entry point', $data['description']); - $this->assertSame('text/x-rust', $data['mimeType']); - $this->assertSame($annotations, $data['annotations']); - $this->assertSame(1024, $data['size']); - $this->assertSame($icons, $data['icons']); - $this->assertSame(['origin' => 'test'], $data['_meta']); + $this->assertSame('Main Source File', $data['title'] ?? null); + $this->assertSame('Primary application entry point', $data['description'] ?? null); + $this->assertSame('text/x-rust', $data['mimeType'] ?? null); + $this->assertSame($annotations, $data['annotations'] ?? null); + $this->assertSame(1024, $data['size'] ?? null); + $this->assertSame($icons, $data['icons'] ?? null); + $this->assertSame(['origin' => 'test'], $data['_meta'] ?? null); } public function testOptionalFieldsOmittedWhenNull(): void @@ -159,8 +159,9 @@ public function testFromArrayWithAllFields(): void $this->assertSame('text/x-rust', $link->mimeType); $this->assertInstanceOf(Annotations::class, $link->annotations); $this->assertSame(1024, $link->size); + $this->assertNotNull($link->icons); $this->assertCount(1, $link->icons); - $this->assertInstanceOf(Icon::class, $link->icons[0]); + $this->assertInstanceOf(Icon::class, $link->icons[0] ?? null); $this->assertSame(['origin' => 'test'], $link->meta); } @@ -178,7 +179,7 @@ public function testRoundTripThroughJsonSerializeAndFromArray(): void meta: ['origin' => 'test'], ); - $decoded = json_decode(json_encode($original), true); + $decoded = json_decode(json_encode($original, \JSON_THROW_ON_ERROR), true); $rehydrated = ResourceLink::fromArray($decoded); $this->assertSame($original->uri, $rehydrated->uri); @@ -196,7 +197,6 @@ public function testFromArrayRejectsWrongType(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid type for ResourceLink.'); - /* @phpstan-ignore argument.type */ ResourceLink::fromArray([ 'type' => 'resource', 'uri' => self::VALID_URI, diff --git a/tests/Unit/Schema/Content/SamplingMessageTest.php b/tests/Unit/Schema/Content/SamplingMessageTest.php index c464575b..e3e1b2e3 100644 --- a/tests/Unit/Schema/Content/SamplingMessageTest.php +++ b/tests/Unit/Schema/Content/SamplingMessageTest.php @@ -41,14 +41,15 @@ public function testToolLoopMessagesRoundTrip(): void ]], ]); - $this->assertInstanceOf(ToolUseContent::class, $assistant->content[1]); + $this->assertInstanceOf(ToolUseContent::class, $assistant->getContentBlocks()[1]); $this->assertSame(['provider' => 'test'], $assistant->meta); - $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta']); - $this->assertInstanceOf(ToolResultContent::class, $user->content[0]); - $this->assertSame(['temperature' => 21], $user->content[0]->structuredContent); + $this->assertSame(['provider' => 'test'], $assistant->jsonSerialize()['_meta'] ?? null); + $toolResult = $user->getContentBlocks()[0]; + $this->assertInstanceOf(ToolResultContent::class, $toolResult); + $this->assertSame(['temperature' => 21], $toolResult->structuredContent); - $this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant), true))); - $this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user), true))); + $this->assertEquals($assistant, SamplingMessage::fromArray(json_decode(json_encode($assistant, \JSON_THROW_ON_ERROR), true))); + $this->assertEquals($user, SamplingMessage::fromArray(json_decode(json_encode($user, \JSON_THROW_ON_ERROR), true))); } public function testSingleContentBlockKeepsItsShape(): void @@ -119,7 +120,6 @@ public function testUnknownRoleIsRejected(): void { $this->expectException(InvalidArgumentException::class); - /* @phpstan-ignore argument.type */ SamplingMessage::fromArray(['role' => 'system', 'content' => ['type' => 'text', 'text' => 'hi']]); } } diff --git a/tests/Unit/Schema/Content/ToolResultContentTest.php b/tests/Unit/Schema/Content/ToolResultContentTest.php index 3c45f664..51348730 100644 --- a/tests/Unit/Schema/Content/ToolResultContentTest.php +++ b/tests/Unit/Schema/Content/ToolResultContentTest.php @@ -43,7 +43,7 @@ public function testRoundTrip(): void $this->assertInstanceOf(TextContent::class, $textContent); $this->assertSame('21 C', $textContent->text); - $restored = ToolResultContent::fromArray(json_decode(json_encode($content), true)); + $restored = ToolResultContent::fromArray(json_decode(json_encode($content, \JSON_THROW_ON_ERROR), true)); $this->assertEquals($content, $restored); } @@ -56,7 +56,7 @@ public function testIsErrorIsOmittedWhenFalse(): void $this->assertArrayNotHasKey('isError', $serialized); $this->assertArrayNotHasKey('structuredContent', $serialized); $this->assertArrayNotHasKey('_meta', $serialized); - $this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content), true))->isError); + $this->assertFalse(ToolResultContent::fromArray(json_decode(json_encode($content, \JSON_THROW_ON_ERROR), true))->isError); } public function testAcceptsEveryCallToolResultContentBlock(): void diff --git a/tests/Unit/Schema/Content/ToolUseContentTest.php b/tests/Unit/Schema/Content/ToolUseContentTest.php index 686481ba..f40d36c7 100644 --- a/tests/Unit/Schema/Content/ToolUseContentTest.php +++ b/tests/Unit/Schema/Content/ToolUseContentTest.php @@ -49,7 +49,7 @@ public function testEmptyInputSerializesAsObject(): void public function testEmptyInputSurvivesRoundTrip(): void { - $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', [])), true); + $decoded = json_decode(json_encode(new ToolUseContent('call-1', 'ping', []), \JSON_THROW_ON_ERROR), true); $this->assertSame([], ToolUseContent::fromArray($decoded)->input); } diff --git a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php index a9e11732..71aa4d99 100644 --- a/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/BooleanSchemaDefinitionTest.php @@ -86,7 +86,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for boolean schema definition.'); - /* @phpstan-ignore argument.type */ BooleanSchemaDefinition::fromArray(['title' => 42]); } diff --git a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php index 3117de90..250dee6e 100644 --- a/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php +++ b/tests/Unit/Schema/Elicitation/ElicitationSchemaTest.php @@ -128,7 +128,6 @@ public function testFromArrayWithMissingProperties(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "properties"'); - /* @phpstan-ignore argument.type */ ElicitationSchema::fromArray([]); } @@ -167,7 +166,6 @@ public function testFromArrayWithMissingPropertyType(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "type"'); - /* @phpstan-ignore argument.type */ ElicitationSchema::fromArray([ 'properties' => [ 'name' => ['title' => 'Name'], @@ -283,7 +281,7 @@ public function testJsonSerializeWithRequiredFields(): void $result = $schema->jsonSerialize(); - $this->assertSame(['name'], $result['required']); + $this->assertSame(['name'], $result['required'] ?? null); } public function testJsonSerializeWithFullSchema(): void @@ -301,7 +299,7 @@ public function testJsonSerializeWithFullSchema(): void $this->assertSame('object', $result['type']); $this->assertCount(3, $result['properties']); - $this->assertSame(['name', 'age'], $result['required']); + $this->assertSame(['name', 'age'], $result['required'] ?? null); $this->assertSame('string', $result['properties']['name']['type']); $this->assertSame('Full Name', $result['properties']['name']['title']); diff --git a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php index ea200a86..2ae7046d 100644 --- a/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/EnumSchemaDefinitionTest.php @@ -129,7 +129,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for enum schema definition.'); - /* @phpstan-ignore argument.type */ EnumSchemaDefinition::fromArray(['title' => 42]); } @@ -138,7 +137,6 @@ public function testFromArrayWithMissingEnum(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "enum"'); - /* @phpstan-ignore argument.type */ EnumSchemaDefinition::fromArray(['title' => 'Test']); } diff --git a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php index 208f5d2c..a36de82f 100644 --- a/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/MultiSelectEnumSchemaDefinitionTest.php @@ -154,7 +154,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for multi-select enum schema definition.'); - /* @phpstan-ignore argument.type */ MultiSelectEnumSchemaDefinition::fromArray([ 'title' => 42, 'items' => ['type' => 'string', 'enum' => ['a']], @@ -166,7 +165,6 @@ public function testFromArrayWithMissingItemsEnum(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "items.enum"'); - /* @phpstan-ignore argument.type */ MultiSelectEnumSchemaDefinition::fromArray([ 'title' => 'Test', 'items' => ['type' => 'string'], diff --git a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php index ea040382..6398da5c 100644 --- a/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/NumberSchemaDefinitionTest.php @@ -148,7 +148,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for number schema definition.'); - /* @phpstan-ignore argument.type */ NumberSchemaDefinition::fromArray(['title' => 42]); } diff --git a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php index 02998cce..524f458a 100644 --- a/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/StringSchemaDefinitionTest.php @@ -129,7 +129,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for string schema definition.'); - /* @phpstan-ignore argument.type */ StringSchemaDefinition::fromArray(['title' => 42]); } diff --git a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php index 26502832..f7f3347e 100644 --- a/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledEnumSchemaDefinitionTest.php @@ -63,7 +63,6 @@ public function testConstructorWithMissingConst(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Each oneOf item must have a string "const" property'); - /* @phpstan-ignore argument.type */ new TitledEnumSchemaDefinition('Test', [['title' => 'A']]); } @@ -72,7 +71,6 @@ public function testConstructorWithMissingTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Each oneOf item must have a string "title" property'); - /* @phpstan-ignore argument.type */ new TitledEnumSchemaDefinition('Test', [['const' => 'a']]); } @@ -139,7 +137,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for titled enum schema definition.'); - /* @phpstan-ignore argument.type */ TitledEnumSchemaDefinition::fromArray(['title' => 42]); } @@ -148,7 +145,6 @@ public function testFromArrayWithMissingOneOf(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "oneOf"'); - /* @phpstan-ignore argument.type */ TitledEnumSchemaDefinition::fromArray(['title' => 'Test']); } diff --git a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php index 170893fb..6bf1853c 100644 --- a/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php +++ b/tests/Unit/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinitionTest.php @@ -70,7 +70,6 @@ public function testConstructorWithMissingConst(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Each anyOf item must have a string "const" property'); - /* @phpstan-ignore argument.type */ new TitledMultiSelectEnumSchemaDefinition('Test', [['title' => 'A']]); } @@ -79,7 +78,6 @@ public function testConstructorWithMissingTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Each anyOf item must have a string "title" property'); - /* @phpstan-ignore argument.type */ new TitledMultiSelectEnumSchemaDefinition('Test', [['const' => 'a']]); } @@ -183,7 +181,6 @@ public function testFromArrayRejectsNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" for titled multi-select enum schema definition.'); - /* @phpstan-ignore argument.type */ TitledMultiSelectEnumSchemaDefinition::fromArray([ 'title' => 42, 'items' => ['anyOf' => [['const' => 'a', 'title' => 'A']]], @@ -195,7 +192,6 @@ public function testFromArrayWithMissingItemsAnyOf(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "items.anyOf"'); - /* @phpstan-ignore argument.type */ TitledMultiSelectEnumSchemaDefinition::fromArray([ 'title' => 'Test', 'items' => [], diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index 6e2f60a0..30b8074f 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -42,10 +42,10 @@ public function testUiResourceCspSerialization(): void $serialized = $csp->jsonSerialize(); - $this->assertSame(['https://api.example.com'], $serialized['connectDomains']); - $this->assertSame(['https://cdn.example.com'], $serialized['resourceDomains']); - $this->assertSame(['https://embed.example.com'], $serialized['frameDomains']); - $this->assertSame(['https://example.com'], $serialized['baseUriDomains']); + $this->assertSame(['https://api.example.com'], $serialized['connectDomains'] ?? null); + $this->assertSame(['https://cdn.example.com'], $serialized['resourceDomains'] ?? null); + $this->assertSame(['https://embed.example.com'], $serialized['frameDomains'] ?? null); + $this->assertSame(['https://example.com'], $serialized['baseUriDomains'] ?? null); } public function testUiResourceCspOmitsNullFields(): void @@ -142,8 +142,8 @@ public function testUiResourceContentMetaSerialization(): void $this->assertArrayHasKey('csp', $serialized); $this->assertArrayHasKey('permissions', $serialized); - $this->assertSame('example.com', $serialized['domain']); - $this->assertTrue($serialized['prefersBorder']); + $this->assertSame('example.com', $serialized['domain'] ?? null); + $this->assertTrue($serialized['prefersBorder'] ?? null); } public function testUiResourceContentMetaOmitsNullFields(): void @@ -184,8 +184,8 @@ public function testUiToolMetaSerialization(): void $serialized = $meta->jsonSerialize(); - $this->assertSame('ui://my-app', $serialized['resourceUri']); - $this->assertSame(['model', 'app'], $serialized['visibility']); + $this->assertSame('ui://my-app', $serialized['resourceUri'] ?? null); + $this->assertSame(['model', 'app'], $serialized['visibility'] ?? null); } public function testUiToolMetaOmitsNullFields(): void diff --git a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php index dbb7e7e2..b118f50b 100644 --- a/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php +++ b/tests/Unit/Schema/Extension/CapabilitiesExtensionsTest.php @@ -54,7 +54,9 @@ public function testServerCapabilitiesJsonSerializeWithExtensions(): void $json = $caps->jsonSerialize(); $this->assertArrayHasKey('extensions', $json); - $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); + $extensions = $json['extensions'] ?? null; + $this->assertNotNull($extensions); + $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $extensions); } public function testServerCapabilitiesSerializeSettinglessExtensionAsObject(): void @@ -125,7 +127,7 @@ public function testServerCapabilitiesWithExtensionsMerges(): void $merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]); - $this->assertSame(['x' => 1], $merged->extensions['a']); + $this->assertSame(['x' => 1], $merged->extensions['a'] ?? null); $this->assertSame(['y' => 99], $merged->extensions['b'], 'new entry overrides existing id'); $this->assertSame(['z' => 3], $merged->extensions['c']); $this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged'); @@ -171,9 +173,12 @@ public function testClientCapabilitiesJsonSerializeWithExtensions(): void ); $json = $caps->jsonSerialize(); + $this->assertIsArray($json); $this->assertArrayHasKey('extensions', $json); - $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']); + $extensions = $json['extensions'] ?? null; + $this->assertNotNull($extensions); + $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $extensions); } public function testClientCapabilitiesJsonSerializeWithoutExtensions(): void @@ -256,6 +261,7 @@ public function testBackwardCompatibilityClientCapabilities(): void $this->assertNull($caps->extensions); $json = $caps->jsonSerialize(); + $this->assertIsArray($json); $this->assertArrayNotHasKey('extensions', $json); } } diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php index 65e00e24..4f7f1537 100644 --- a/tests/Unit/Schema/IconTest.php +++ b/tests/Unit/Schema/IconTest.php @@ -24,13 +24,14 @@ public function testValidConstructor(): void $this->assertSame('https://www.php.net/images/logos/php-logo-white.svg', $icon->src); $this->assertSame('image/svg+xml', $icon->mimeType); - $this->assertSame('any', $icon->sizes[0]); + $this->assertSame('any', $icon->sizes[0] ?? null); } public function testConstructorWithMultipleSizes(): void { $icon = new Icon('https://example.com/icon.png', 'image/png', ['48x48', '96x96']); + $this->assertNotNull($icon->sizes); $this->assertCount(2, $icon->sizes); $this->assertSame(['48x48', '96x96'], $icon->sizes); } @@ -92,7 +93,7 @@ public function testFromArrayReadsTheme(): void $icon = Icon::fromArray(['src' => 'https://example.com/icon.png', 'theme' => 'dark']); $this->assertSame(IconTheme::Dark, $icon->theme); - $this->assertSame('dark', $icon->jsonSerialize()['theme']); + $this->assertSame('dark', $icon->jsonSerialize()['theme'] ?? null); } public function testFromArrayRejectsUnknownTheme(): void diff --git a/tests/Unit/Schema/ImplementationTest.php b/tests/Unit/Schema/ImplementationTest.php index 3268b6b1..0da76c4a 100644 --- a/tests/Unit/Schema/ImplementationTest.php +++ b/tests/Unit/Schema/ImplementationTest.php @@ -93,7 +93,6 @@ public function testFromArrayThrowsOnMissingName(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['version' => '1.0.0']); } @@ -110,7 +109,6 @@ public function testFromArrayThrowsOnNonStringName(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid or missing "name" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 123, 'version' => '1.0.0']); } @@ -119,7 +117,6 @@ public function testFromArrayThrowsOnMissingVersion(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 'my-client']); } @@ -136,7 +133,6 @@ public function testFromArrayThrowsOnNonStringVersion(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid or missing "version" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 'my-client', 'version' => 1]); } @@ -145,7 +141,6 @@ public function testFromArrayThrowsOnNonArrayIcons(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "icons" in Implementation data; expected an array.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'icons' => 'nope']); } @@ -154,7 +149,6 @@ public function testFromArrayThrowsOnNonStringDescription(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "description" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'description' => 42]); } @@ -163,7 +157,6 @@ public function testFromArrayThrowsOnNonStringTitle(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "title" in Implementation data.'); - /* @phpstan-ignore argument.type */ Implementation::fromArray(['name' => 'my-client', 'version' => '1.0.0', 'title' => ['nope']]); } diff --git a/tests/Unit/Schema/NonObjectOutputSchemaTest.php b/tests/Unit/Schema/NonObjectOutputSchemaTest.php index 8e0fb0ba..c84420ef 100644 --- a/tests/Unit/Schema/NonObjectOutputSchemaTest.php +++ b/tests/Unit/Schema/NonObjectOutputSchemaTest.php @@ -70,7 +70,6 @@ public function testInputSchemaStillRequiresObjectRoot(): void { $this->expectException(InvalidArgumentException::class); - /* @phpstan-ignore-next-line argument.type (deliberately invalid: an array root must be rejected) */ Tool::fromArray([ 'name' => 'demo', 'inputSchema' => ['type' => 'array', 'properties' => [], 'required' => null], @@ -117,7 +116,7 @@ public function testNonNullValueIsSerialized(mixed $value): void $serialized = $result->jsonSerialize(); $this->assertArrayHasKey('structuredContent', $serialized); - $this->assertSame($value, $serialized['structuredContent']); + $this->assertSame($value, $serialized['structuredContent'] ?? null); } #[TestDox('a null structuredContent is omitted entirely')] @@ -149,7 +148,7 @@ public function testObjectRoundTrip(): void ]); $this->assertSame(['temperature' => 22.5], $result->structuredContent); - $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent']); + $this->assertSame(['temperature' => 22.5], $result->jsonSerialize()['structuredContent'] ?? null); } #[TestDox('the empty root schema serializes as {} rather than []')] @@ -161,6 +160,6 @@ public function testEmptyOutputSchemaSerializesAsObject(): void 'outputSchema' => [], ]); - $this->assertSame('{}', json_encode($tool->jsonSerialize()['outputSchema'])); + $this->assertSame('{}', json_encode($tool->jsonSerialize()['outputSchema'] ?? null)); } } diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php index fb065e61..ddc22717 100644 --- a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -65,11 +65,15 @@ public function testToolsAndToolChoiceRoundTrip(): void ); $payload = $request->withId(1)->jsonSerialize(); - $this->assertSame('weather', $payload['params']['tools'][0]->name); - $this->assertSame(ToolChoiceMode::Required, $payload['params']['toolChoice']->mode); + $params = $payload['params'] ?? null; + $this->assertIsArray($params); + $this->assertSame('weather', $params['tools'][0]->name); + $this->assertSame(ToolChoiceMode::Required, $params['toolChoice']->mode); $hydrated = CreateSamplingMessageRequest::fromArray(json_decode(json_encode($payload, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)); + $this->assertNotNull($hydrated->tools); $this->assertSame('weather', $hydrated->tools[0]->name); + $this->assertNotNull($hydrated->toolChoice); $this->assertSame(ToolChoiceMode::Required, $hydrated->toolChoice->mode); } diff --git a/tests/Unit/Schema/ResourceDefinitionTest.php b/tests/Unit/Schema/ResourceDefinitionTest.php index e6bbe56a..65241c89 100644 --- a/tests/Unit/Schema/ResourceDefinitionTest.php +++ b/tests/Unit/Schema/ResourceDefinitionTest.php @@ -102,7 +102,7 @@ public function testTitleSerialization(): void ); $data = $resource->jsonSerialize(); - $this->assertSame('Book Listing', $data['title']); + $this->assertSame('Book Listing', $data['title'] ?? null); } public function testTitleOmittedWhenNull(): void diff --git a/tests/Unit/Schema/ResourceTemplateTest.php b/tests/Unit/Schema/ResourceTemplateTest.php index b99c9d08..70adde4d 100644 --- a/tests/Unit/Schema/ResourceTemplateTest.php +++ b/tests/Unit/Schema/ResourceTemplateTest.php @@ -100,7 +100,7 @@ public function testTitleSerialization(): void ); $data = $resource->jsonSerialize(); - $this->assertSame('Book Listing', $data['title']); + $this->assertSame('Book Listing', $data['title'] ?? null); } public function testTitleOmittedWhenNull(): void diff --git a/tests/Unit/Schema/Result/CallToolResultTest.php b/tests/Unit/Schema/Result/CallToolResultTest.php index f7c2b6ff..42095d0c 100644 --- a/tests/Unit/Schema/Result/CallToolResultTest.php +++ b/tests/Unit/Schema/Result/CallToolResultTest.php @@ -95,7 +95,7 @@ public function testRoundTripWithResourceLinkAlongsideOtherContentTypes(): void EmbeddedResource::fromText('file:///readme.txt', 'hello'), ]); - $decoded = json_decode(json_encode($original), true); + $decoded = json_decode(json_encode($original, \JSON_THROW_ON_ERROR), true); $rehydrated = CallToolResult::fromArray($decoded); $this->assertCount(5, $rehydrated->content); diff --git a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php index 41961058..a65f6b6b 100644 --- a/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php +++ b/tests/Unit/Schema/Result/CreateSamplingMessageResultTest.php @@ -33,11 +33,11 @@ public function testArrayContentAndKnownStopReasonAreHydrated(): void '_meta' => ['traceId' => 'trace-1'], ]); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertInstanceOf(ToolUseContent::class, $result->content[1]); + $this->assertInstanceOf(TextContent::class, $result->getContentBlocks()[0]); + $this->assertInstanceOf(ToolUseContent::class, $result->getContentBlocks()[1]); $this->assertSame('toolUse', $result->stopReason); - $this->assertSame('toolUse', $result->jsonSerialize()['stopReason']); - $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta']); + $this->assertSame('toolUse', $result->jsonSerialize()['stopReason'] ?? null); + $this->assertSame(['traceId' => 'trace-1'], $result->jsonSerialize()['_meta'] ?? null); } public function testProviderSpecificStopReasonIsPreserved(): void @@ -50,7 +50,7 @@ public function testProviderSpecificStopReasonIsPreserved(): void ]); $this->assertSame('provider-specific', $result->stopReason); - $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason']); + $this->assertSame('provider-specific', $result->jsonSerialize()['stopReason'] ?? null); } public function testKnownStopReasonStaysAString(): void diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index b946c770..574203a0 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -80,7 +80,6 @@ public function testFromArrayWithMissingAction(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "action"'); - /* @phpstan-ignore argument.type */ ElicitResult::fromArray([]); } diff --git a/tests/Unit/Schema/Result/ListRootsResultTest.php b/tests/Unit/Schema/Result/ListRootsResultTest.php index 46729fbf..c3cfaf10 100644 --- a/tests/Unit/Schema/Result/ListRootsResultTest.php +++ b/tests/Unit/Schema/Result/ListRootsResultTest.php @@ -59,7 +59,6 @@ public function testFromArrayWithMissingRoots(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing or invalid "roots"'); - /* @phpstan-ignore argument.type */ ListRootsResult::fromArray([]); } @@ -68,7 +67,6 @@ public function testFromArrayWithNonArrayRoot(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid root in ListRootsResult data, expected an array.'); - /* @phpstan-ignore argument.type */ ListRootsResult::fromArray([ 'roots' => ['file:///tmp'], ]); @@ -96,7 +94,7 @@ public function testFromArrayRoundTrip(): void $result = ListRootsResult::fromArray($data); - $this->assertSame($data, json_decode(json_encode($result), true)); + $this->assertSame($data, json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true)); } public function testJsonSerializeWithoutMeta(): void @@ -105,6 +103,6 @@ public function testJsonSerializeWithoutMeta(): void $this->assertSame([ 'roots' => [['uri' => 'file:///tmp']], - ], json_decode(json_encode($result), true)); + ], json_decode(json_encode($result, \JSON_THROW_ON_ERROR), true)); } } diff --git a/tests/Unit/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php index 9d1562c1..dee99c4e 100644 --- a/tests/Unit/Schema/ServerCapabilitiesTest.php +++ b/tests/Unit/Schema/ServerCapabilitiesTest.php @@ -287,23 +287,29 @@ public function testJsonSerializeWithAllFeaturesEnabled(): void $json = $capabilities->jsonSerialize(); $this->assertArrayHasKey('logging', $json); - $this->assertEquals(new \stdClass(), $json['logging']); + $this->assertEquals(new \stdClass(), $json['logging'] ?? null); $this->assertArrayHasKey('completions', $json); - $this->assertEquals(new \stdClass(), $json['completions']); + $this->assertEquals(new \stdClass(), $json['completions'] ?? null); $this->assertArrayHasKey('prompts', $json); - $this->assertTrue($json['prompts']->listChanged); + $prompts = $json['prompts'] ?? null; + $this->assertNotNull($prompts); + $this->assertTrue($prompts->listChanged); $this->assertArrayHasKey('resources', $json); - $this->assertTrue($json['resources']->subscribe); - $this->assertTrue($json['resources']->listChanged); + $resources = $json['resources'] ?? null; + $this->assertNotNull($resources); + $this->assertTrue($resources->subscribe); + $this->assertTrue($resources->listChanged); $this->assertArrayHasKey('tools', $json); - $this->assertTrue($json['tools']->listChanged); + $tools = $json['tools'] ?? null; + $this->assertNotNull($tools); + $this->assertTrue($tools->listChanged); $this->assertArrayHasKey('experimental', $json); - $this->assertEquals((object) $experimental, $json['experimental']); + $this->assertEquals((object) $experimental, $json['experimental'] ?? null); } public function testJsonSerializeWithFalseValues(): void diff --git a/tests/Unit/Schema/ToolChoiceTest.php b/tests/Unit/Schema/ToolChoiceTest.php index 2b5e7cd3..0b19fd3b 100644 --- a/tests/Unit/Schema/ToolChoiceTest.php +++ b/tests/Unit/Schema/ToolChoiceTest.php @@ -57,7 +57,6 @@ public function testNonStringModeIsRejected(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); - /* @phpstan-ignore argument.type */ ToolChoice::fromArray(['mode' => 1]); } @@ -66,7 +65,6 @@ public function testExplicitNullModeIsRejected(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid "mode" in ToolChoice data.'); - /* @phpstan-ignore argument.type (deliberately null, as malformed wire data would be) */ ToolChoice::fromArray(['mode' => null]); } } diff --git a/tests/Unit/Schema/ToolTest.php b/tests/Unit/Schema/ToolTest.php index dc71189d..ad52b6c2 100644 --- a/tests/Unit/Schema/ToolTest.php +++ b/tests/Unit/Schema/ToolTest.php @@ -59,7 +59,7 @@ public function testSerializationPlacesTitleBetweenNameAndInputSchema(?string $t $this->assertSame($expectedKeys, array_keys($serialized)); if (null !== $title) { - $this->assertSame($title, $serialized['title']); + $this->assertSame($title, $serialized['title'] ?? null); } else { $this->assertArrayNotHasKey('title', $serialized); } @@ -108,7 +108,7 @@ public function testConstructorNormalizesEmptyInputSchemaPropertiesToObject(): v annotations: null, ); - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties'] ?? null); $this->assertSame('{"name":"no_params","inputSchema":{"type":"object","properties":{},"required":null}}', json_encode($tool)); } @@ -120,7 +120,7 @@ public function testConstructorNormalizesEmptyPropertiesAfterJsonDecodeRoundTrip $tool = new Tool('t', null, $schema, null, null); - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties'] ?? null); $this->assertStringContainsString('"properties":{}', (string) json_encode($tool)); } @@ -137,8 +137,12 @@ public function testFromArrayNormalizesNestedEmptyPropertiesRecursively(): void ], ]); - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['filter']['properties']); - $this->assertStringContainsString('"properties":{}', (string) json_encode($tool->inputSchema['properties']['filter'])); + $properties = $tool->inputSchema['properties'] ?? null; + $this->assertIsArray($properties); + $filter = $properties['filter'] ?? null; + $this->assertIsArray($filter); + $this->assertInstanceOf(\stdClass::class, $filter['properties'] ?? null); + $this->assertStringContainsString('"properties":{}', (string) json_encode($filter)); } public function testConstructorNormalizesEmptyOutputSchemaProperties(): void @@ -152,7 +156,7 @@ public function testConstructorNormalizesEmptyOutputSchemaProperties(): void outputSchema: ['type' => 'object', 'properties' => []], ); - $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties']); + $this->assertInstanceOf(\stdClass::class, $tool->outputSchema['properties'] ?? null); $this->assertStringContainsString('"outputSchema":{"type":"object","properties":{}}', (string) json_encode($tool)); } @@ -175,7 +179,13 @@ public function testConstructorNormalizesEmptyPropertiesInsideArrayItems(): void annotations: null, ); - $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']['rows']['items']['properties']); + $properties = $tool->inputSchema['properties'] ?? null; + $this->assertIsArray($properties); + $rows = $properties['rows'] ?? null; + $this->assertIsArray($rows); + $items = $rows['items'] ?? null; + $this->assertIsArray($items); + $this->assertInstanceOf(\stdClass::class, $items['properties'] ?? null); } /** diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 790af5f5..d5e2a7db 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -357,6 +357,8 @@ private function extractServerInfo(Server $server): Implementation foreach ($requestHandlers as $handler) { if ($handler instanceof InitializeHandler) { + $this->assertNotNull($handler->configuration); + return $handler->configuration->serverInfo; } } @@ -371,6 +373,8 @@ private function extractServerCapabilities(Server $server): ServerCapabilities foreach ($requestHandlers as $handler) { if ($handler instanceof InitializeHandler) { + $this->assertNotNull($handler->configuration); + return $handler->configuration->capabilities; } } diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 308c9809..9a530f42 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -554,6 +554,7 @@ public function testStructuredContentFollowsTheNegotiatedRevision(?string $negot $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(CallToolResult::class, $response->result); $this->assertSame($expected, $response->result->structuredContent); } @@ -659,6 +660,7 @@ public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(CallToolResult::class, $response->result); $this->assertNull($response->result->structuredContent); } @@ -715,6 +717,10 @@ private function createCallToolRequest(string $name, array $arguments): CallTool ]); } + /** + * @param array|null $outputSchema + * @param list $methodsToMock + */ private function createToolReference( string $name, callable $handler, diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 62c7eb79..bcfd27ee 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -296,8 +296,10 @@ public function testRemovedMethodsAreUnknown(string $method, array $params): voi private static function frames(StatelessResult $result): array { $frames = []; + $stream = $result->frames; + self::assertNotNull($stream); - foreach (($result->frames)() as $frame) { + foreach ($stream() as $frame) { if (null !== $frame) { $frames[] = $frame; } @@ -530,7 +532,7 @@ static function (RequestContext $context): string { $when = $gateway->elicit('When?', $schema, key: 'when'); $seat = $gateway->elicit('Seat?', $schema, key: 'seat'); - return $when->content['v'].'/'.$seat->content['v']; + return ($when->content['v'] ?? '').'/'.($seat->content['v'] ?? ''); }, name: 'books_flight', description: 'Asks twice before it answers', @@ -851,6 +853,7 @@ public function testUndeclaredInputRequestIsRefusedWhenStreamed(): void $this->assertTrue($result->isStream()); $frames = self::frames($result); + $this->assertNotEmpty($frames); $last = json_decode(json_encode($frames[array_key_last($frames)], \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR); $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $last['error']['code']); @@ -953,8 +956,10 @@ public function testListenStreamDeliversSubscribedNotifications(): void $frames = []; $published = false; + $stream = $result->frames; + $this->assertNotNull($stream); - foreach (($result->frames)() as $frame) { + foreach ($stream() as $frame) { if (null === $frame) { // Publish once the stream is established, so the notifications // arrive the way a concurrent request would deliver them. @@ -1023,7 +1028,10 @@ public function testAcknowledgmentReflectsWhatTheServerCanDo(): void ]); $first = null; - foreach (($result->frames)() as $frame) { + $stream = $result->frames; + $this->assertNotNull($stream); + + foreach ($stream() as $frame) { if (null !== $frame) { $first = $frame; break; diff --git a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php index 56364003..9e3b7d4b 100644 --- a/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php +++ b/tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php @@ -45,7 +45,7 @@ public function testRegistrationSuccess(): void $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') ->withHeader('Content-Type', 'application/json') - ->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']]))); + ->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']], \JSON_THROW_ON_ERROR))); $response = $middleware->process($request, $this->createPassthroughHandler(404)); @@ -139,7 +139,7 @@ public function testRegistrationWithNestedObjectsPassesAssociativeArrays(): void $body = json_encode([ 'redirect_uris' => ['https://example.com/callback'], 'jwks' => ['keys' => [['kty' => 'RSA', 'n' => 'abc', 'e' => 'AQAB']]], - ]); + ], \JSON_THROW_ON_ERROR); $request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register') ->withHeader('Content-Type', 'application/json') diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php index b6e518ac..d4d22dcd 100644 --- a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php +++ b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php @@ -578,6 +578,7 @@ private function createHttpClientMock(array $responses, ?int $expectedCalls = nu } else { // If expectedCalls > count(responses), keep returning the last response. $sequence = $responses; + $this->assertNotEmpty($responses); while (\count($sequence) < $expectedCalls) { $sequence[] = $responses[array_key_last($responses)]; } diff --git a/tests/Unit/Server/Transport/StdioTransportTest.php b/tests/Unit/Server/Transport/StdioTransportTest.php index dbfcec2f..094b714a 100644 --- a/tests/Unit/Server/Transport/StdioTransportTest.php +++ b/tests/Unit/Server/Transport/StdioTransportTest.php @@ -83,6 +83,7 @@ private function createTransport(string $input, array &$messages, int $maxLineBy private function stream(string $contents) { $stream = fopen('php://temp', 'r+'); + $this->assertNotFalse($stream); fwrite($stream, $contents); rewind($stream);