diff --git a/CHANGELOG.md b/CHANGELOG.md index 7041d07e..eb1ea471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `SchemaValidator::validateAgainstJsonSchema()` no longer validates an empty array as an object. Pass `new \stdClass()` for an empty object. * Add `Builder::setSchemaValidator()` to configure the validator used for `tools/call` input and output, e.g. with a resolver for external `$ref` schemas. * [BC Break] Remove the `providerClass` argument of `#[CompletionProvider]`. Use `provider:`, which takes the same class-string and is now the first positional argument. +* [BC Break] `StreamableHttpTransport::handleFiberTermination()` takes the terminated `\Fiber` as its first argument; a subclass overriding it has to accept it too. +* [BC Break] Reject a `Tool` input schema whose `properties` is not an object or whose `required` is neither a list nor `null`, instead of silently replacing the member. Reject a `completion/complete` whose `argument` is missing `name` or `value`, instead of completing against an empty prefix. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php index b3dee306..9e6c3834 100644 --- a/examples/client/stdio_elicitation.php +++ b/examples/client/stdio_elicitation.php @@ -43,10 +43,14 @@ 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); - $label = $this->labelFor($definition); + $label = $this->labelFor($definition, $name); if (null !== $default) { $display = is_bool($default) ? ($default ? 'true' : 'false') : (string) $default; @@ -76,9 +80,11 @@ private function defaultFor(object $definition): mixed }; } - private function labelFor(AbstractSchemaDefinition $definition): string + private function labelFor(AbstractSchemaDefinition $definition, string $name): string { - return $definition->title; + // A schema is not obliged to carry a title, and the field's own name is + // a far better prompt than a blank one. + return $definition->title ?? $name; } 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 d47a0e52..602797a3 100644 --- a/examples/server/oauth-microsoft/McpElements.php +++ b/examples/server/oauth-microsoft/McpElements.php @@ -103,7 +103,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/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 c70559fc..f0a212fd 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 { @@ -431,17 +423,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 9476d8f8..61215fdd 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -160,7 +160,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 { @@ -314,13 +314,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) ?: '{}') : (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 209644c7..2d6257f9 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); 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 5e40cad2..66507ffe 100644 --- a/src/Client.php +++ b/src/Client.php @@ -280,6 +280,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); @@ -373,13 +377,14 @@ public function sendRootsListChanged(): void */ private function sendRequest(Request $request, ?callable $onProgress = null, ?CancellationTokenInterface $cancellation = null, ?float $timeoutSeconds = 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, $cancellation, $timeoutSeconds)); - $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 8fd07be9..53596bc9 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -114,15 +114,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) { diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 314da718..c2be50e4 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -94,15 +94,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) { @@ -184,7 +185,7 @@ private function spawnProcess(): void $cmd .= ' '.escapeshellarg($arg); } - $this->process = proc_open( + $process = proc_open( $cmd, $descriptors, $pipes, @@ -192,10 +193,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..517be814 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -40,7 +40,7 @@ public function __construct( } /** - * @param AudioContentData $data + * @param AudioContentData|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..24c342ca 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -43,7 +43,7 @@ public function __construct( } /** - * @param BlobResourceContentsData $data + * @param BlobResourceContentsData|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..eb772c03 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -40,7 +40,7 @@ public function __construct( } /** - * @param EmbeddedResourceData $data + * @param EmbeddedResourceData|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..3f7ada58 100644 --- a/src/Schema/Content/ImageContent.php +++ b/src/Schema/Content/ImageContent.php @@ -44,7 +44,7 @@ public function __construct( } /** - * @param ImageContentData $data + * @param ImageContentData|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..e54f6583 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -46,7 +46,7 @@ public function __construct( } /** - * @param PromptMessageData $data + * @param PromptMessageData|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..6d8f0756 100644 --- a/src/Schema/Content/ResourceLink.php +++ b/src/Schema/Content/ResourceLink.php @@ -68,7 +68,7 @@ public function __construct( } /** - * @param ResourceLinkData $data + * @param ResourceLinkData|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..c4af551c 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 SamplingMessageData|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..54b1cde2 100644 --- a/src/Schema/Content/TextContent.php +++ b/src/Schema/Content/TextContent.php @@ -46,7 +46,7 @@ public function __construct( } /** - * @param TextContentData $data + * @param TextContentData|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..61493338 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -43,7 +43,7 @@ public function __construct( } /** - * @param TextResourceContentsData $data + * @param TextResourceContentsData|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Content/ToolResultContent.php b/src/Schema/Content/ToolResultContent.php index 7f2b11c1..87f46a29 100644 --- a/src/Schema/Content/ToolResultContent.php +++ b/src/Schema/Content/ToolResultContent.php @@ -99,7 +99,7 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $data = [ - 'type' => $this->type, + 'type' => 'tool_result', 'toolUseId' => $this->toolUseId, 'content' => $this->content, ]; 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 d202e068..f6698b2c 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 dfec206f..3c85e799 100644 --- a/src/Schema/Elicitation/BooleanSchemaDefinition.php +++ b/src/Schema/Elicitation/BooleanSchemaDefinition.php @@ -36,7 +36,7 @@ public function __construct( * title?: string, * description?: string, * default?: bool, - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index f41b9c27..720c0ca3 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -51,7 +51,7 @@ public function __construct( * type?: string, * properties: array, * required?: string[], - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/EnumSchemaDefinition.php b/src/Schema/Elicitation/EnumSchemaDefinition.php index 7a3149f1..6127fd99 100644 --- a/src/Schema/Elicitation/EnumSchemaDefinition.php +++ b/src/Schema/Elicitation/EnumSchemaDefinition.php @@ -64,7 +64,7 @@ public function __construct( * description?: string, * default?: string, * enumNames?: string[], - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php index cd68c6c4..996eced9 100644 --- a/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php +++ b/src/Schema/Elicitation/MultiSelectEnumSchemaDefinition.php @@ -79,7 +79,7 @@ public function __construct( * default?: string[], * minItems?: int, * maxItems?: int, - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/NumberSchemaDefinition.php b/src/Schema/Elicitation/NumberSchemaDefinition.php index 3375f206..18ddfc38 100644 --- a/src/Schema/Elicitation/NumberSchemaDefinition.php +++ b/src/Schema/Elicitation/NumberSchemaDefinition.php @@ -65,7 +65,7 @@ public function __construct( * default?: int|float, * minimum?: int|float, * maximum?: int|float, - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/StringSchemaDefinition.php b/src/Schema/Elicitation/StringSchemaDefinition.php index 3347ea30..e04f5c4d 100644 --- a/src/Schema/Elicitation/StringSchemaDefinition.php +++ b/src/Schema/Elicitation/StringSchemaDefinition.php @@ -67,7 +67,7 @@ public function __construct( * format?: string, * minLength?: int, * maxLength?: int, - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/TitledEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledEnumSchemaDefinition.php index 41c92422..334e0bcd 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,16 +47,20 @@ 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)); } @@ -63,7 +72,7 @@ public function __construct( * oneOf: list, * description?: string, * default?: string, - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php b/src/Schema/Elicitation/TitledMultiSelectEnumSchemaDefinition.php index 17f18995..ee7241d1 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.'); } @@ -84,7 +93,7 @@ public function __construct( * default?: string[], * minItems?: int, * maxItems?: int, - * } $data + * }|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..71bddfd7 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -63,7 +63,7 @@ public function __construct( } /** - * @param IconData $data + * @param IconData|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index b48be544..2cbeb4d3 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -44,7 +44,7 @@ public function __construct( * icons?: IconData[], * websiteUrl?: string, * title?: string, - * } $data + * }|array $data */ public static function fromArray(array $data): self { @@ -55,12 +55,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 +78,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..dea975c7 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -73,7 +73,7 @@ public function __construct( } /** - * @param ErrorData $data + * @param ErrorData|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..c3bcbdbc 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 ResponseData|array $data * * @return self> */ @@ -69,7 +69,10 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Response "result" must be an array.'); } - return new self($data['id'], $data['result']); + /** @var array $result A result decoded from a JSON object is string-keyed. */ + $result = $data['result']; + + return new self($data['id'], $result); } /** 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..ee362a21 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -45,8 +45,8 @@ protected static function fromParams(?array $params): static } $ref = match ($params['ref']['type'] ?? null) { - 'ref/prompt' => new PromptReference(self::refString($params['ref'], 'name')), - 'ref/resource' => new ResourceReference(self::refString($params['ref'], 'uri')), + 'ref/prompt' => new PromptReference(self::stringMember($params['ref'], 'ref', 'name')), + 'ref/resource' => new ResourceReference(self::stringMember($params['ref'], 'ref', 'uri')), default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), }; @@ -54,19 +54,22 @@ 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::stringMember($params['argument'], 'argument', 'name'), + 'value' => self::stringMember($params['argument'], 'argument', 'value'), + ]); } /** - * @param array $ref + * @param array $data */ - private static function refString(array $ref, string $key): string + private static function stringMember(array $data, string $member, string $key): string { - if (!isset($ref[$key]) || !\is_string($ref[$key])) { - throw new InvalidArgumentException(\sprintf('Missing or invalid "ref.%s" parameter for completion/complete.', $key)); + if (!isset($data[$key]) || !\is_string($data[$key])) { + throw new InvalidArgumentException(\sprintf('Missing or invalid "%s.%s" parameter for completion/complete.', $member, $key)); } - return $ref[$key]; + return $data[$key]; } /** diff --git a/src/Schema/Request/ElicitRequest.php b/src/Schema/Request/ElicitRequest.php index 15a74ee1..ae540064 100644 --- a/src/Schema/Request/ElicitRequest.php +++ b/src/Schema/Request/ElicitRequest.php @@ -12,6 +12,7 @@ namespace Mcp\Schema\Request; use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\LogicException; use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Enum\ElicitationMode; use Mcp\Schema\JsonRpc\Request; @@ -110,6 +111,10 @@ protected static function fromParams(?array $params): static protected function getParams(): array { if (ElicitationMode::Url === $this->mode) { + if (null === $this->url) { + throw new LogicException('A url-mode elicitation has no "url" to send.'); + } + return [ 'message' => $this->message, 'mode' => $this->mode->value, @@ -117,6 +122,10 @@ protected function getParams(): array ]; } + if (null === $this->requestedSchema) { + throw new LogicException('A form-mode elicitation has no "requestedSchema" to send.'); + } + // We don't need to send the mode if it's the default (form). return [ 'message' => $this->message, diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index 69c44d5f..1d2a1105 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -69,7 +69,7 @@ public function __construct( } /** - * @param ResourceDefinitionData $data + * @param ResourceDefinitionData|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46033975..2b7c6abb 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -62,7 +62,7 @@ public function __construct( } /** - * @param ResourceTemplateData $data + * @param ResourceTemplateData|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index b986becf..e2836a42 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -87,7 +87,7 @@ public static function error(array $content, ?array $meta = null): self * isError?: bool, * _meta?: array, * structuredContent?: mixed - * } $data + * }|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..371ae4cd 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{action: string, content?: array}|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..39e036d3 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -50,7 +50,7 @@ public function __construct( * serverInfo: array, * instructions?: string, * _meta?: array, - * } $data + * }|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..9a6d2904 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -42,7 +42,7 @@ public function __construct( * @param array{ * roots: array, * _meta?: ?array - * } $data + * }|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 97a2e3af..cad18f70 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -50,7 +50,7 @@ public function __construct( } /** - * @param RootData $data + * @param RootData|array $data */ public static function fromArray(array $data): self { diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index acc326c8..f398a458 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -146,14 +146,14 @@ public function withExtensions(array $extensions): self /** * @return array{ - * logging?: object, - * completions?: object, - * prompts?: object, - * resources?: object, - * tools?: object, - * experimental?: object, - * extensions?: object, - * tasks?: object, + * logging?: \stdClass, + * completions?: \stdClass, + * prompts?: \stdClass, + * resources?: \stdClass, + * tools?: \stdClass, + * experimental?: \stdClass, + * extensions?: \stdClass, + * tasks?: \stdClass, * } */ public function jsonSerialize(): array @@ -217,7 +217,7 @@ public function jsonSerialize(): array * * @param array $data */ - private static function toObject(array $data): object + private static function toObject(array $data): \stdClass { return (object) array_map( static fn (mixed $value): mixed => \is_array($value) ? self::toObject($value) : $value, diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 1f4d9cd1..97798834 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -23,16 +23,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 + * properties?: array|\stdClass, + * required?: array|null * } + * @phpstan-type ToolOutputSchema array * @phpstan-type ToolData array{ * name: string, * title?: string, @@ -97,20 +91,20 @@ 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 ?ToolExecution $execution optional execution-related properties, such as whether the - * tool can be called as a task + * @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. + * @param ?ToolExecution $execution optional execution-related properties, such as whether the + * tool can be called as a task */ public function __construct( public readonly string $name, @@ -123,13 +117,9 @@ public function __construct( ?array $outputSchema = null, public readonly ?ToolExecution $execution = null, ) { - if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { - throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); - } - // 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 @@ -142,7 +132,7 @@ public function __construct( } /** - * @param ToolData $data + * @param ToolData|array $data */ public static function fromArray(array $data): self { @@ -216,6 +206,42 @@ public function jsonSerialize(): array return $data; } + /** + * Normalize an input schema and refuse members that are not what they claim. + * + * `fromArray()` feeds this untrusted wire data, so a member of the wrong + * type is rejected rather than replaced: quietly turning `properties: "x"` + * into `{}` would publish a tool contract nobody declared. + * + * @param array $schema + * + * @return ToolInputSchema + */ + private static function normalizeInputSchema(array $schema): array + { + $normalized = self::normalizeSchema($schema); + + if (!isset($normalized['type']) || 'object' !== $normalized['type']) { + throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); + } + + if (\array_key_exists('properties', $normalized) + && !\is_array($normalized['properties']) + && !$normalized['properties'] instanceof \stdClass + ) { + throw new InvalidArgumentException('Tool inputSchema "properties" must be an object.'); + } + + if (\array_key_exists('required', $normalized) + && null !== $normalized['required'] + && !\is_array($normalized['required']) + ) { + throw new InvalidArgumentException('Tool inputSchema "required" must be a list of property names.'); + } + + 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..5c0f2bac 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{mode?: string}|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 f0fb238a..035074c9 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -242,9 +242,9 @@ final class Builder private array $discoveryExcludeDirs = []; /** - * @var string[]|null + * @var string[] */ - private ?array $discoveryNamePatterns = null; + private array $discoveryNamePatterns = DiscovererInterface::DEFAULT_NAME_PATERNS; private ?ServerCapabilities $serverCapabilities = null; 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 df4a11c5..19e88852 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -250,7 +250,24 @@ 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. + if (!$dispatched instanceof $event) { + $this->logger->debug('Event dispatcher returned a different object than it was given; keeping the original.', [ + 'event' => $event::class, + 'returned' => $dispatched::class, + ]); + + return $event; + } + + return $dispatched; } /** diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index d49fefc1..24798cd4 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 375469c8..4d6a8917 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -542,7 +542,24 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str */ 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. + if (!$dispatched instanceof $event) { + $this->logger->debug('Event dispatcher returned a different object than it was given; keeping the original.', [ + 'event' => $event::class, + 'returned' => $dispatched::class, + ]); + + return $event; + } + + return $dispatched; } /** @@ -570,7 +587,7 @@ private function dispatchError(Error $error, Request $request, Session $session, * 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/ManagesTransportCallbacks.php b/src/Server/Transport/ManagesTransportCallbacks.php index eae14fa9..765e2ef3 100644 --- a/src/Server/Transport/ManagesTransportCallbacks.php +++ b/src/Server/Transport/ManagesTransportCallbacks.php @@ -39,7 +39,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 dcd408f7..f156d97a 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -50,6 +50,8 @@ * * @extends BaseTransport * + * @phpstan-import-type McpFiber from TransportInterface + * * @author Kyrian Obikwelu */ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTransportInterface @@ -253,17 +255,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; } @@ -277,7 +285,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; @@ -285,7 +293,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; @@ -297,7 +305,7 @@ protected function createStreamedResponse(): ResponseInterface } // Prevent tight loop } - $this->handleFiberTermination(); + $this->handleFiberTermination($fiber); } finally { $this->sessionFiber = null; } @@ -318,9 +326,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/Integration/CancellationTest.php b/tests/Integration/CancellationTest.php index 434ee28c..1a9498b3 100644 --- a/tests/Integration/CancellationTest.php +++ b/tests/Integration/CancellationTest.php @@ -106,6 +106,16 @@ public function isCancellationRequested(): bool */ private function events(string $log): array { - return array_map(static fn (string $line): array => json_decode($line, true, flags: \JSON_THROW_ON_ERROR), file($log, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES)); + $lines = file($log, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES); + $this->assertNotFalse($lines); + + $events = []; + foreach ($lines as $line) { + $event = json_decode($line, true, flags: \JSON_THROW_ON_ERROR); + $this->assertIsArray($event); + $events[] = $event; + } + + return $events; } } diff --git a/tests/Integration/Fixture/cancellation.php b/tests/Integration/Fixture/cancellation.php index 249ac40b..a47beb4c 100644 --- a/tests/Integration/Fixture/cancellation.php +++ b/tests/Integration/Fixture/cancellation.php @@ -12,6 +12,10 @@ // This fixture keeps a tool request pending while it reads the next JSON-RPC line. // The SDK server's single fiber cannot read cancellations during a blocking tool. $log = getenv('MCP_FIXTURE_LOG'); +if (false === $log) { + fwrite(\STDERR, "MCP_FIXTURE_LOG is not set.\n"); + exit(1); +} while (false !== ($line = fgets(\STDIN))) { $message = json_decode($line, true, flags: \JSON_THROW_ON_ERROR); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 99ff4647..7cca7c8f 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/Interop/Client/EverythingServerTestCase.php b/tests/Interop/Client/EverythingServerTestCase.php index 002fa4c0..8cc4d5e1 100644 --- a/tests/Interop/Client/EverythingServerTestCase.php +++ b/tests/Interop/Client/EverythingServerTestCase.php @@ -139,7 +139,7 @@ public function __invoke(ListRootsRequest $request): ListRootsResult self::$client->connect(self::$transport); foreach (self::$transport->received() as $message) { - if (isset($message->result->serverInfo)) { + if (isset($message->result->serverInfo) && $message->result instanceof \stdClass) { self::$initializeResult = $message->result; } } @@ -282,7 +282,7 @@ private function losses(array $wire, mixed $parsed, array $received): array } $losses = array_values(array_unique(array_map( - static fn (string $loss): string => preg_replace('/\.\d+(?=\.|$)/', '.*', $loss), + static fn (string $loss): string => preg_replace('/\.\d+(?=\.|$)/', '.*', $loss) ?? $loss, $losses, ))); sort($losses); @@ -411,6 +411,7 @@ private function assertMatchesSnapshot(string $scenario, mixed $data): void // The dynamic resources stamp the time they were generated into their text. $json = preg_replace('/\d{1,2}:\d{2}:\d{2}(?:\s?[AP]M)?/u', '