diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c4c01b5f0..addd208c8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,6 +40,7 @@ /packages/log/ @brendt /packages/mail/ @innocenzi /packages/mapper/ @brendt +/packages/mcp/ @xHeaven /packages/process/ @innocenzi /packages/reflection/ @brendt @aidan-casey /packages/router/ @brendt @aidan-casey diff --git a/composer.json b/composer.json index ec21b056f..15591dd7a 100644 --- a/composer.json +++ b/composer.json @@ -118,6 +118,7 @@ "tempest/log": "self.version", "tempest/mail": "self.version", "tempest/mapper": "self.version", + "tempest/mcp": "self.version", "tempest/process": "self.version", "tempest/reflection": "self.version", "tempest/router": "self.version", @@ -160,6 +161,7 @@ "Tempest\\Log\\": "packages/log/src", "Tempest\\Mail\\": "packages/mail/src", "Tempest\\Mapper\\": "packages/mapper/src", + "Tempest\\Mcp\\": "packages/mcp/src", "Tempest\\Process\\": "packages/process/src", "Tempest\\Reflection\\": "packages/reflection/src", "Tempest\\Router\\": "packages/router/src", @@ -232,6 +234,7 @@ "Tempest\\Log\\Tests\\": "packages/log/tests", "Tempest\\Mail\\Tests\\": "packages/mail/tests", "Tempest\\Mapper\\Tests\\": "packages/mapper/tests", + "Tempest\\Mcp\\Tests\\": "packages/mcp/tests", "Tempest\\Process\\Tests\\": "packages/process/tests", "Tempest\\Rector\\": "utils/rector/src", "Tempest\\Reflection\\Tests\\": "packages/reflection/tests", diff --git a/docs/2-features/20-mcp.md b/docs/2-features/20-mcp.md new file mode 100644 index 000000000..a6f71d12a --- /dev/null +++ b/docs/2-features/20-mcp.md @@ -0,0 +1,150 @@ +--- +title: MCP +description: "Tempest's MCP component lets your application expose tools, prompts and resources to AI clients over the Model Context Protocol." +--- + +## Overview + +The [Model Context Protocol](https://modelcontextprotocol.io) is an open standard that lets AI clients, such as Claude or Cursor, interact with your application through tools, prompts and resources. + +Tempest ships with an MCP server framework in `tempest/mcp`. Servers are plain classes annotated with {b`Tempest\Mcp\McpServer`}, and their capabilities are methods annotated with {b`Tempest\Mcp\McpTool`}, {b`Tempest\Mcp\McpPrompt`} or {b`Tempest\Mcp\McpResource`}. Both are registered through [discovery](../1-essentials/05-discovery.md). + +## Defining a server + +An MCP server is a class with the {b`Tempest\Mcp\McpServer`} attribute. Its name is derived from the class name and its version defaults to `0.0.1`, unless specified in the attribute. When a `path` is provided, the server is exposed over HTTP; every server is also accessible through the `mcp:serve` command. + +```php app/DemoServer.php +use Tempest\Mcp\McpServer; +use Tempest\Mcp\McpTool; + +#[McpServer(path: '/mcp')] +final class DemoServer +{ + #[McpTool(description: 'Adds two numbers')] + public function add(int $a, int $b): int + { + return $a + $b; + } +} +``` + +Tool, prompt and resource methods defined inside an `#[McpServer]` class are automatically attached to that server. This includes public methods inherited from parent classes, so servers may share primitives through a common base class, which may be abstract. Methods in other classes may attach themselves to a server by specifying the `server` parameter: + +```php app/OrderTools.php +use Tempest\Mcp\McpTool; + +final readonly class OrderTools +{ + #[McpTool(server: DemoServer::class)] + public function countOrders(): int + { + // … + } +} +``` + +Handler classes and their methods are resolved through the [container](../1-essentials/05-container.md), so dependencies may be injected in the constructor or directly as method parameters. + +## Tools + +Tools are methods annotated with {b`Tempest\Mcp\McpTool`}. The tool name defaults to the snake-cased method name, and the input schema is derived from the method signature. + +Scalar parameters, backed enums and arrays become schema properties; parameters with default values or nullable types become optional; everything else is injected by the container and excluded from the schema. + +```php app/DemoServer.php +use Tempest\Mcp\Description; +use Tempest\Mcp\McpTool; +use Tempest\Validation\Rules\IsBetween; + +#[McpTool(description: 'Rates a product')] +public function rate( + ProductRepository $products, // injected, not part of the schema + #[Description('The identifier of the product')] + string $product, + #[IsBetween(min: 1, max: 5)] + int $rating, +): string { + // … +} +``` + +Validation attributes from `tempest/validation` double as schema constraints and are enforced at call time. For instance, `#[IsBetween]` becomes `minimum` and `maximum`, `#[HasLength]` becomes `minLength` and `maxLength`, and `#[IsIn]` becomes an `enum`. When validation fails, the client receives an error result containing the validation messages. + +Return values are normalized to MCP content: + +- A `string` or other scalar becomes text content; +- An associative array or object becomes JSON text alongside `structuredContent`; +- Instances of {b`Tempest\Mcp\Content\Text`}, {b`Tempest\Mcp\Content\Image`}, {b`Tempest\Mcp\Content\Audio`} and {b`Tempest\Mcp\Content\ResourceLink`}, or lists of them, are passed through as-is. + +Exceptions thrown by a tool handler are reported to the [exception handler](./14-exception-handling.md) and returned to the client as an error result. + +## Prompts + +Prompts are methods annotated with {b`Tempest\Mcp\McpPrompt`}. Their parameters are advertised as prompt arguments, and the return value becomes the prompt messages. + +```php app/DemoServer.php +use Tempest\Mcp\McpPrompt; + +#[McpPrompt(description: 'Generates a code review prompt')] +public function reviewCode(string $code): string +{ + return "Review the following code: {$code}"; +} +``` + +## Resources + +Resources are methods annotated with {b`Tempest\Mcp\McpResource`}, which requires a `uri`. A URI may contain template variables that correspond to method parameters, in which case the resource is advertised as a resource template and matched dynamically when read. + +```php app/DemoServer.php +use Tempest\Mcp\Content\Blob; +use Tempest\Mcp\McpResource; + +#[McpResource(uri: 'app://config', mimeType: 'application/json')] +public function config(): array +{ + return ['env' => 'production']; +} + +#[McpResource(uri: 'app://users/{id}')] +public function user(int $id): string +{ + return "User #{$id}"; +} +``` + +String return values become text resource contents, while {b`Tempest\Mcp\Content\Blob`} instances become binary blob contents. + +## Transports + +### HTTP + +When {b`Tempest\Mcp\McpServer`} specifies a `path`, a `POST` route is registered automatically. The HTTP transport is stateless: each request receives a single JSON response, notifications are acknowledged with `202 Accepted`, and other HTTP methods are answered with `405 Method Not Allowed`. You may protect the route like any other by adding middleware through a [route decorator](../1-essentials/01-routing.md), or by placing the server behind your existing authentication middleware. + +### Standard input/output + +Every discovered server can be served over standard input and output, which is the transport most local MCP clients use: + +```sh +{:hl-comment:# Serve a server by its name:} +./tempest mcp:serve demo-server +``` + +Use `mcp:list` to see all discovered servers, their transports, and how many tools, prompts and resources they expose. + +## Testing + +The framework provides a `mcp` property in tests that extend `IntegrationTest`. It drives the protocol in-process and performs the initialization handshake. + +```php tests/DemoServerTest.php +public function test_add(): void +{ + $this->mcp + ->onServer(DemoServer::class) + ->callTool('add', ['a' => 1, 'b' => 2]) + ->assertOk() + ->assertText('3'); +} +``` + +The connection object provides `callTool()`, `getPrompt()`, `readResource()`, `listTools()`, `listResources()`, `listPrompts()`, and a generic `send()` method for raw protocol requests. Responses may be asserted with `assertOk()`, `assertError()`, `assertText()`, `assertTextContains()`, `assertStructured()`, `assertToolListed()` and `assertSee()`. diff --git a/packages/mcp/.gitattributes b/packages/mcp/.gitattributes new file mode 100644 index 000000000..d7ad66f93 --- /dev/null +++ b/packages/mcp/.gitattributes @@ -0,0 +1,15 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore +PRD.md export-ignore + +# Configure diff output +*.view.php diff=html +*.php diff=php +*.css diff=css +*.html diff=html +*.md diff=markdown diff --git a/packages/mcp/LICENSE.md b/packages/mcp/LICENSE.md new file mode 100644 index 000000000..54215b726 --- /dev/null +++ b/packages/mcp/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/mcp/composer.json b/packages/mcp/composer.json new file mode 100644 index 000000000..ea632359a --- /dev/null +++ b/packages/mcp/composer.json @@ -0,0 +1,29 @@ +{ + "name": "tempest/mcp", + "description": "The PHP framework that gets out of your way.", + "require": { + "php": "^8.5", + "tempest/console": "3.x-dev", + "tempest/container": "3.x-dev", + "tempest/core": "3.x-dev", + "tempest/discovery": "3.x-dev", + "tempest/http": "3.x-dev", + "tempest/mapper": "3.x-dev", + "tempest/reflection": "3.x-dev", + "tempest/router": "3.x-dev", + "tempest/support": "3.x-dev", + "tempest/validation": "3.x-dev" + }, + "autoload": { + "psr-4": { + "Tempest\\Mcp\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tempest\\Mcp\\Tests\\": "tests" + } + }, + "license": "MIT", + "minimum-stability": "dev" +} diff --git a/packages/mcp/phpunit.xml b/packages/mcp/phpunit.xml new file mode 100644 index 000000000..504c66ffb --- /dev/null +++ b/packages/mcp/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/mcp/src/ArgumentBinder.php b/packages/mcp/src/ArgumentBinder.php new file mode 100644 index 000000000..c5c41f73a --- /dev/null +++ b/packages/mcp/src/ArgumentBinder.php @@ -0,0 +1,141 @@ + + */ + public function bind(MethodReflector $method, array $arguments): array + { + $parameters = $this->schemaGenerator->getSchemaParameters($method); + + foreach (array_keys($arguments) as $name) { + if (! isset($parameters[$name])) { + throw ParametersWereInvalid::becauseArgumentWasUnknown((string) $name); + } + } + + $values = []; + $rules = []; + + foreach ($parameters as $name => $parameter) { + if (! array_key_exists($name, $arguments)) { + if ($this->schemaGenerator->isRequired($parameter)) { + throw ParametersWereInvalid::becauseArgumentWasMissing($name); + } + + if (! $parameter->hasDefaultValue()) { + $values[$name] = null; + } + + continue; + } + + $values[$name] = $arguments[$name]; + $rules[$name] = $this->resolveRules($parameter); + } + + $this->validate($values, $rules); + + $bound = []; + + foreach ($values as $name => $value) { + $bound[$name] = $this->cast($parameters[$name], $value); + } + + return $bound; + } + + private function validate(array $values, array $rules): void + { + $failingRules = $this->validator->validateValues($values, $rules); + + if ($failingRules === []) { + return; + } + + $failures = []; + + foreach ($failingRules as $field => $failingRulesForField) { + foreach ($failingRulesForField as $failingRule) { + $failures[$field][] = $this->validator->getErrorMessage($failingRule, $field); + } + } + + throw new ArgumentsFailedValidation($failures); + } + + /** + * @return Rule[] + */ + private function resolveRules(ParameterReflector $parameter): array + { + $rules = $parameter->getAttributes(Rule::class); + $type = $this->schemaGenerator->resolveType($parameter); + $nullable = $parameter->getType()->isNullable(); + + if ($type->isBackedEnum()) { + $rules[] = new IsEnum($type->asEnum()->getName(), orNull: $nullable); + } else { + $rules[] = match (str_replace('?', '', $type->getName())) { + 'string' => new IsString(orNull: $nullable), + 'int' => new IsInteger(orNull: $nullable), + 'float' => new IsFloat(orNull: $nullable), + 'bool' => new IsBoolean(orNull: $nullable), + 'array' => new IsArray(orNull: $nullable), + default => null, + }; + } + + return array_filter($rules); + } + + private function cast(ParameterReflector $parameter, mixed $value): mixed + { + if ($value === null) { + return null; + } + + $type = $this->schemaGenerator->resolveType($parameter); + $nullable = $parameter->getType()->isNullable(); + + if ($type->isBackedEnum()) { + return new EnumCaster($type->asEnum()->getName(), $nullable)->cast($value); + } + + return match (str_replace('?', '', $type->getName())) { + 'int' => is_int($value) ? $value : new IntegerCaster($nullable)->cast($value), + 'float' => is_float($value) ? $value : new FloatCaster($nullable)->cast($value), + 'bool' => is_bool($value) ? $value : new BooleanCaster($nullable)->cast($value), + default => $value, + }; + } +} diff --git a/packages/mcp/src/Commands/McpListCommand.php b/packages/mcp/src/Commands/McpListCommand.php new file mode 100644 index 000000000..696bf1584 --- /dev/null +++ b/packages/mcp/src/Commands/McpListCommand.php @@ -0,0 +1,45 @@ +config->servers === []) { + $this->console->info('There are no MCP servers.'); + + return; + } + + $this->console->header('MCP servers'); + + foreach ($this->config->servers as $server) { + $transports = $server->path !== null + ? "stdio, http ({$server->path})" + : 'stdio'; + + $counts = sprintf( + '%d tools, %d prompts, %d resources', + count($server->tools), + count($server->prompts), + count($server->resources) + count($server->resourceTemplates), + ); + + $this->console->keyValue($server->name, "{$transports} — {$counts}"); + } + } +} diff --git a/packages/mcp/src/Commands/McpServeCommand.php b/packages/mcp/src/Commands/McpServeCommand.php new file mode 100644 index 000000000..acc2ad2c7 --- /dev/null +++ b/packages/mcp/src/Commands/McpServeCommand.php @@ -0,0 +1,40 @@ +config->getServerByName($server); + + if (! $definition instanceof McpServerDefinition) { + $this->console->error("There is no MCP server named `{$server}`."); + + return ExitCode::INVALID; + } + + $this->transport->run($definition, STDIN, STDOUT); + + return ExitCode::SUCCESS; + } +} diff --git a/packages/mcp/src/Content/Audio.php b/packages/mcp/src/Content/Audio.php new file mode 100644 index 000000000..7b119f9b0 --- /dev/null +++ b/packages/mcp/src/Content/Audio.php @@ -0,0 +1,35 @@ + 'audio', + 'data' => base64_encode($this->data), + 'mimeType' => $this->mimeType ?? 'audio/wav', + ]; + } + + public function toResourceContents(string $uri, ?string $mimeType): array + { + return [ + 'uri' => $uri, + 'mimeType' => $this->mimeType ?? $mimeType ?? 'audio/wav', + 'blob' => base64_encode($this->data), + ]; + } +} diff --git a/packages/mcp/src/Content/Blob.php b/packages/mcp/src/Content/Blob.php new file mode 100644 index 000000000..fd93fe34b --- /dev/null +++ b/packages/mcp/src/Content/Blob.php @@ -0,0 +1,31 @@ + $uri, + 'mimeType' => $mimeType ?? 'application/octet-stream', + 'blob' => base64_encode($this->contents), + ]; + } +} diff --git a/packages/mcp/src/Content/Content.php b/packages/mcp/src/Content/Content.php new file mode 100644 index 000000000..d588a3d26 --- /dev/null +++ b/packages/mcp/src/Content/Content.php @@ -0,0 +1,18 @@ + 'image', + 'data' => base64_encode($this->data), + 'mimeType' => $this->mimeType ?? 'image/png', + ]; + } + + public function toResourceContents(string $uri, ?string $mimeType): array + { + return [ + 'uri' => $uri, + 'mimeType' => $this->mimeType ?? $mimeType ?? 'image/png', + 'blob' => base64_encode($this->data), + ]; + } +} diff --git a/packages/mcp/src/Content/ResourceLink.php b/packages/mcp/src/Content/ResourceLink.php new file mode 100644 index 000000000..e28464cec --- /dev/null +++ b/packages/mcp/src/Content/ResourceLink.php @@ -0,0 +1,33 @@ + 'resource_link', + 'uri' => $this->uri, + 'name' => $this->name, + ...($this->description === null ? [] : ['description' => $this->description]), + ...($this->mimeType === null ? [] : ['mimeType' => $this->mimeType]), + ]; + } + + public function toResourceContents(string $uri, ?string $mimeType): array + { + throw ContentWasNotSupported::becauseResourceLinksCannotBeUsedInResources(); + } +} diff --git a/packages/mcp/src/Content/Text.php b/packages/mcp/src/Content/Text.php new file mode 100644 index 000000000..e32ae508f --- /dev/null +++ b/packages/mcp/src/Content/Text.php @@ -0,0 +1,29 @@ + 'text', + 'text' => $this->text, + ]; + } + + public function toResourceContents(string $uri, ?string $mimeType): array + { + return [ + 'uri' => $uri, + 'mimeType' => $mimeType ?? 'text/plain', + 'text' => $this->text, + ]; + } +} diff --git a/packages/mcp/src/Description.php b/packages/mcp/src/Description.php new file mode 100644 index 000000000..6c3530658 --- /dev/null +++ b/packages/mcp/src/Description.php @@ -0,0 +1,15 @@ + */ + public readonly array $failures, + ) { + parent::__construct(implode("\n", array_merge(...array_values($failures)))); + } +} diff --git a/packages/mcp/src/Exceptions/ContentWasNotSupported.php b/packages/mcp/src/Exceptions/ContentWasNotSupported.php new file mode 100644 index 000000000..1c9241222 --- /dev/null +++ b/packages/mcp/src/Exceptions/ContentWasNotSupported.php @@ -0,0 +1,25 @@ + JsonRpcErrorCode::PARSE_ERROR; + } + + public function __construct() + { + parent::__construct('The message could not be parsed as JSON.'); + } +} diff --git a/packages/mcp/src/Exceptions/McpException.php b/packages/mcp/src/Exceptions/McpException.php new file mode 100644 index 000000000..61d62ef84 --- /dev/null +++ b/packages/mcp/src/Exceptions/McpException.php @@ -0,0 +1,7 @@ + JsonRpcErrorCode::METHOD_NOT_FOUND; + } + + public function __construct( + public readonly string $method, + ) { + parent::__construct("The method `{$method}` is not supported."); + } +} diff --git a/packages/mcp/src/Exceptions/ParameterWasNotSupported.php b/packages/mcp/src/Exceptions/ParameterWasNotSupported.php new file mode 100644 index 000000000..d2300fa51 --- /dev/null +++ b/packages/mcp/src/Exceptions/ParameterWasNotSupported.php @@ -0,0 +1,36 @@ +getDeclaringClass()->getName(), + $handler->getName(), + )); + } + + public static function becauseUnionTypesAreNotSupported(MethodReflector $handler, string $parameter): self + { + return new self(sprintf( + 'The parameter `%s` of `%s::%s` has a union type, which cannot be represented in a schema.', + $parameter, + $handler->getDeclaringClass()->getName(), + $handler->getName(), + )); + } +} diff --git a/packages/mcp/src/Exceptions/ParametersWereInvalid.php b/packages/mcp/src/Exceptions/ParametersWereInvalid.php new file mode 100644 index 000000000..bc6183f52 --- /dev/null +++ b/packages/mcp/src/Exceptions/ParametersWereInvalid.php @@ -0,0 +1,45 @@ + JsonRpcErrorCode::INVALID_PARAMS; + } + + public function __construct(string $message) + { + parent::__construct($message); + } + + public static function becauseArgumentWasMissing(string $argument): self + { + return new self("The required argument `{$argument}` is missing."); + } + + public static function becauseArgumentWasUnknown(string $argument): self + { + return new self("The argument `{$argument}` is unknown."); + } + + public static function becauseArgumentWasNotAString(string $argument): self + { + return new self("The argument `{$argument}` must be a string."); + } + + public static function becauseArgumentsWereNotAnObject(): self + { + return new self('The `arguments` member must be an object.'); + } + + public static function becauseValidationFailed(ArgumentsFailedValidation $exception): self + { + return new self($exception->getMessage()); + } +} diff --git a/packages/mcp/src/Exceptions/PrimitiveWasAlreadyRegistered.php b/packages/mcp/src/Exceptions/PrimitiveWasAlreadyRegistered.php new file mode 100644 index 000000000..bd759d7af --- /dev/null +++ b/packages/mcp/src/Exceptions/PrimitiveWasAlreadyRegistered.php @@ -0,0 +1,30 @@ + JsonRpcErrorCode::INVALID_PARAMS; + } + + public function __construct( + public readonly string $prompt, + ) { + parent::__construct("The prompt `{$prompt}` does not exist."); + } +} diff --git a/packages/mcp/src/Exceptions/RequestWasInvalid.php b/packages/mcp/src/Exceptions/RequestWasInvalid.php new file mode 100644 index 000000000..35c073813 --- /dev/null +++ b/packages/mcp/src/Exceptions/RequestWasInvalid.php @@ -0,0 +1,45 @@ + JsonRpcErrorCode::INVALID_REQUEST; + } + + private function __construct(string $message) + { + parent::__construct($message); + } + + public static function becauseJsonRpcVersionWasMissing(): self + { + return new self('The message is not a valid JSON-RPC 2.0 message, the `jsonrpc` member must be `"2.0"`.'); + } + + public static function becauseMethodWasMissing(): self + { + return new self('The message is not a valid JSON-RPC 2.0 request, the `method` member must be a string.'); + } + + public static function becauseIdWasInvalid(): self + { + return new self('The message is not a valid JSON-RPC 2.0 request, the `id` member must be a string or an integer.'); + } + + public static function becauseParamsWereInvalid(): self + { + return new self('The message is not a valid JSON-RPC 2.0 request, the `params` member must be an object.'); + } + + public static function becauseBatchesAreNotSupported(): self + { + return new self('Batched JSON-RPC messages are not supported.'); + } +} diff --git a/packages/mcp/src/Exceptions/ResourceTemplateWasInvalid.php b/packages/mcp/src/Exceptions/ResourceTemplateWasInvalid.php new file mode 100644 index 000000000..738bd411c --- /dev/null +++ b/packages/mcp/src/Exceptions/ResourceTemplateWasInvalid.php @@ -0,0 +1,19 @@ + JsonRpcErrorCode::RESOURCE_NOT_FOUND; + } + + public function __construct( + public readonly string $uri, + ) { + parent::__construct("The resource `{$uri}` does not exist."); + } +} diff --git a/packages/mcp/src/Exceptions/ToolWasNotFound.php b/packages/mcp/src/Exceptions/ToolWasNotFound.php new file mode 100644 index 000000000..096d27b97 --- /dev/null +++ b/packages/mcp/src/Exceptions/ToolWasNotFound.php @@ -0,0 +1,21 @@ + JsonRpcErrorCode::INVALID_PARAMS; + } + + public function __construct( + public readonly string $tool, + ) { + parent::__construct("The tool `{$tool}` does not exist."); + } +} diff --git a/packages/mcp/src/JsonRpc/JsonRpcErrorCode.php b/packages/mcp/src/JsonRpc/JsonRpcErrorCode.php new file mode 100644 index 000000000..85c5ced1f --- /dev/null +++ b/packages/mcp/src/JsonRpc/JsonRpcErrorCode.php @@ -0,0 +1,15 @@ +errorCode, + message: $exception->getMessage(), + ); + } + + public function toArray(): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $this->id, + 'error' => [ + 'code' => $this->code->value, + 'message' => $this->message, + ...($this->data === null ? [] : ['data' => $this->data]), + ], + ]; + } +} diff --git a/packages/mcp/src/JsonRpc/JsonRpcMessage.php b/packages/mcp/src/JsonRpc/JsonRpcMessage.php new file mode 100644 index 000000000..1ec4a5ea8 --- /dev/null +++ b/packages/mcp/src/JsonRpc/JsonRpcMessage.php @@ -0,0 +1,14 @@ + '2.0', + 'method' => $this->method, + ...($this->params === [] ? [] : ['params' => $this->params]), + ]; + } +} diff --git a/packages/mcp/src/JsonRpc/JsonRpcRequest.php b/packages/mcp/src/JsonRpc/JsonRpcRequest.php new file mode 100644 index 000000000..41345a0d8 --- /dev/null +++ b/packages/mcp/src/JsonRpc/JsonRpcRequest.php @@ -0,0 +1,53 @@ + '2.0', + 'id' => $this->id, + 'method' => $this->method, + ...($this->params === [] ? [] : ['params' => $this->params]), + ]; + } +} diff --git a/packages/mcp/src/JsonRpc/JsonRpcResponse.php b/packages/mcp/src/JsonRpc/JsonRpcResponse.php new file mode 100644 index 000000000..6ba76eb4b --- /dev/null +++ b/packages/mcp/src/JsonRpc/JsonRpcResponse.php @@ -0,0 +1,10 @@ + '2.0', + 'id' => $this->id, + 'result' => $this->result === [] ? new stdClass() : $this->result, + ]; + } +} diff --git a/packages/mcp/src/McpConfig.php b/packages/mcp/src/McpConfig.php new file mode 100644 index 000000000..cef190a5a --- /dev/null +++ b/packages/mcp/src/McpConfig.php @@ -0,0 +1,174 @@ + */ + public array $servers = []; + + public function addServer(string $class, McpServer $attribute): void + { + $name = $attribute->name ?? str($class)->classBasename()->kebab()->toString(); + $path = $attribute->path !== null + ? '/' . trim($attribute->path, '/') + : null; + + foreach ($this->servers as $server) { + if ($server->name === $name) { + throw McpServerWasAlreadyRegistered::withName($name); + } + + if ($path !== null && $server->path === $path) { + throw McpServerWasAlreadyRegistered::withPath($path); + } + } + + $this->servers[$class] = new McpServerDefinition( + class: $class, + name: $name, + version: $attribute->version ?? '0.0.1', + instructions: $attribute->instructions, + path: $path, + ); + } + + public function addTool(MethodReflector $handler, McpTool $attribute, ?string $owner = null): void + { + $owner ??= $handler->getDeclaringClass()->getName(); + $server = $this->resolveServer($handler, $attribute->server, $owner); + + if (! $server instanceof McpServerDefinition) { + return; + } + + $name = $attribute->name ?? str($handler->getName())->snake()->toString(); + + if (isset($server->tools[$name])) { + throw PrimitiveWasAlreadyRegistered::tool($name, $server->name); + } + + $server->tools[$name] = new ToolDefinition( + name: $name, + description: $attribute->description, + class: $owner, + handler: $handler, + ); + } + + public function addPrompt(MethodReflector $handler, McpPrompt $attribute, ?string $owner = null): void + { + $owner ??= $handler->getDeclaringClass()->getName(); + $server = $this->resolveServer($handler, $attribute->server, $owner); + + if (! $server instanceof McpServerDefinition) { + return; + } + + $name = $attribute->name ?? str($handler->getName())->snake()->toString(); + + if (isset($server->prompts[$name])) { + throw PrimitiveWasAlreadyRegistered::prompt($name, $server->name); + } + + $server->prompts[$name] = new PromptDefinition( + name: $name, + description: $attribute->description, + class: $owner, + handler: $handler, + ); + } + + public function addResource(MethodReflector $handler, McpResource $attribute, ?string $owner = null): void + { + $owner ??= $handler->getDeclaringClass()->getName(); + $server = $this->resolveServer($handler, $attribute->server, $owner); + + if (! $server instanceof McpServerDefinition) { + return; + } + + $resource = new ResourceDefinition( + uri: $attribute->uri, + name: $attribute->name ?? str($handler->getName())->snake()->toString(), + description: $attribute->description, + mimeType: $attribute->mimeType, + class: $owner, + handler: $handler, + ); + + foreach ($resource->uriTemplate->variableNames as $variable) { + if (! $handler->getParameter($variable) instanceof ParameterReflector) { + throw new ResourceTemplateWasInvalid( + uri: $resource->uri, + variable: $variable, + class: $handler->getDeclaringClass()->getName(), + method: $handler->getName(), + ); + } + } + + if (isset($server->resources[$resource->uri]) || isset($server->resourceTemplates[$resource->uri])) { + throw PrimitiveWasAlreadyRegistered::resource($resource->uri, $server->name); + } + + if ($resource->isTemplated()) { + $server->resourceTemplates[$resource->uri] = $resource; + } else { + $server->resources[$resource->uri] = $resource; + } + } + + public function getServerByName(string $name): ?McpServerDefinition + { + return array_find($this->servers, static fn ($server) => $server->name === $name); + } + + public function getServerByPath(string $path): ?McpServerDefinition + { + $path = '/' . trim($path, '/'); + + return array_find($this->servers, static fn ($server) => $server->path === $path); + } + + /** + * Resolves the server a primitive belongs to. `$owner` is the class the method was discovered on, which + * may differ from its declaring class for inherited methods. Returns `null` when the primitive should be + * skipped: inherited copies of explicitly attached methods, methods inherited by non-server classes, and + * methods on abstract classes that are only exposed through concrete server subclasses. + */ + private function resolveServer(MethodReflector $handler, ?string $serverClass, string $owner): ?McpServerDefinition + { + $declaringClass = $handler->getDeclaringClass()->getName(); + + if ($serverClass !== null) { + if ($declaringClass !== $owner) { + return null; + } + + return $this->servers[$serverClass] ?? throw McpServerWasNotFound::forPrimitive($serverClass, $declaringClass, $handler->getName()); + } + + if (isset($this->servers[$owner])) { + return $this->servers[$owner]; + } + + if ($declaringClass !== $owner || new ClassReflector($owner)->getReflection()->isAbstract()) { + return null; + } + + throw McpServerWasNotFound::forUnattachedPrimitive($declaringClass, $handler->getName()); + } +} diff --git a/packages/mcp/src/McpDiscovery.php b/packages/mcp/src/McpDiscovery.php new file mode 100644 index 000000000..ed81fd91d --- /dev/null +++ b/packages/mcp/src/McpDiscovery.php @@ -0,0 +1,100 @@ +getAttribute(McpServer::class)) instanceof McpServer) { + $this->discoveryItems->add($location, [$class->getName(), $server]); + } + + foreach ($class->getPublicMethods() as $method) { + foreach ([McpTool::class, McpPrompt::class, McpResource::class] as $attributeClass) { + if (! ($attribute = $method->getAttribute($attributeClass))) { + continue; + } + + $this->discoveryItems->add($location, [$class->getName(), $method, $attribute]); + } + } + } + + public function apply(): void + { + $servers = []; + $primitives = []; + + foreach ($this->discoveryItems as $item) { + if ($item[1] instanceof McpServer) { + $servers[] = $item; + } else { + $primitives[] = $item; + } + } + + foreach ($servers as [$class, $attribute]) { + $this->mcpConfig->addServer($class, $attribute); + } + + foreach ($primitives as [$owner, $method, $attribute]) { + $this->schemaGenerator->assertSupported($method); + + match (true) { + $attribute instanceof McpTool => $this->mcpConfig->addTool($method, $attribute, $owner), + $attribute instanceof McpPrompt => $this->mcpConfig->addPrompt($method, $attribute, $owner), + $attribute instanceof McpResource => $this->mcpConfig->addResource($method, $attribute, $owner), + default => null, + }; + } + + $this->registerRoutes(); + } + + private function registerRoutes(): void + { + foreach ($this->mcpConfig->servers as $server) { + if ($server->path === null) { + continue; + } + + foreach ([new Post($server->path), new Get($server->path), new Delete($server->path)] as $route) { + $this->routeConfigurator->addRoute(DiscoveredRoute::fromRoute( + $route, + [new WithoutMiddleware(PreventCrossSiteRequestsMiddleware::class)], + MethodReflector::fromParts(McpHttpController::class, '__invoke'), + )); + } + } + + if ($this->routeConfigurator->isDirty()) { + $this->routeConfig->apply($this->routeConfigurator->toRouteConfig()); + } + } +} diff --git a/packages/mcp/src/McpHttpController.php b/packages/mcp/src/McpHttpController.php new file mode 100644 index 000000000..f08316a08 --- /dev/null +++ b/packages/mcp/src/McpHttpController.php @@ -0,0 +1,45 @@ +config->getServerByPath($request->path); + + if (! $server instanceof McpServerDefinition) { + return new NotFound(); + } + + if ($request->method !== Method::POST) { + return new GenericResponse(Status::METHOD_NOT_ALLOWED); + } + + $response = $this->requestHandler->handle($server, $request->raw ?? encode($request->body)); + + if (! $response instanceof JsonRpcResponse) { + return new GenericResponse(Status::ACCEPTED); + } + + return new Json($response->toArray()); + } +} diff --git a/packages/mcp/src/McpPrompt.php b/packages/mcp/src/McpPrompt.php new file mode 100644 index 000000000..61c71dccd --- /dev/null +++ b/packages/mcp/src/McpPrompt.php @@ -0,0 +1,20 @@ +handleNotification($decoded); + } + + $id = is_string($decoded['id']) || is_int($decoded['id']) ? $decoded['id'] : null; + + try { + $request = JsonRpcRequest::fromArray($decoded); + } catch (McpProtocolException $exception) { + return JsonRpcErrorResponse::fromException($exception, $id); + } + + try { + $result = match ($request->method) { + 'initialize' => $this->initialize($server, $request), + 'ping' => [], + 'tools/list' => $this->listTools($server), + 'tools/call' => $this->callTool($server, $request), + 'resources/list' => $this->listResources($server), + 'resources/templates/list' => $this->listResourceTemplates($server), + 'resources/read' => $this->readResource($server, $request), + 'prompts/list' => $this->listPrompts($server), + 'prompts/get' => $this->getPrompt($server, $request), + default => throw new MethodWasNotFound($request->method), + }; + + return new JsonRpcSuccessResponse($request->id, $result); + } catch (McpProtocolException $exception) { + return JsonRpcErrorResponse::fromException($exception, $request->id); + } catch (Throwable $throwable) { + $this->exceptionProcessor->process($throwable); + + return new JsonRpcErrorResponse( + id: $request->id, + code: JsonRpcErrorCode::INTERNAL_ERROR, + message: 'An internal error occurred while processing the request.', + ); + } + } + + private function handleNotification(array $decoded): ?JsonRpcErrorResponse + { + try { + JsonRpcNotification::fromArray($decoded); + } catch (McpProtocolException $exception) { + return JsonRpcErrorResponse::fromException($exception); + } + + return null; + } + + private function initialize(McpServerDefinition $server, JsonRpcRequest $request): array + { + $requestedVersion = $request->params['protocolVersion'] ?? null; + $version = ProtocolVersion::negotiate(is_string($requestedVersion) ? $requestedVersion : null); + + $capabilities = []; + + if ($server->tools !== []) { + $capabilities['tools'] = ['listChanged' => false]; + } + + if ($server->resources !== [] || $server->resourceTemplates !== []) { + $capabilities['resources'] = ['listChanged' => false]; + } + + if ($server->prompts !== []) { + $capabilities['prompts'] = ['listChanged' => false]; + } + + return [ + 'protocolVersion' => $version->value, + 'capabilities' => $capabilities === [] ? new stdClass() : $capabilities, + 'serverInfo' => [ + 'name' => $server->name, + 'version' => $server->version, + ], + ...($server->instructions === null ? [] : ['instructions' => $server->instructions]), + ]; + } + + private function listTools(McpServerDefinition $server): array + { + $tools = []; + + foreach ($server->tools as $tool) { + $tools[] = [ + 'name' => $tool->name, + ...($tool->description === null ? [] : ['description' => $tool->description]), + 'inputSchema' => $this->schemaGenerator->createInputSchema($tool->handler), + ]; + } + + return ['tools' => $tools]; + } + + private function callTool(McpServerDefinition $server, JsonRpcRequest $request): array + { + $name = $this->resolveStringParam($request, 'name'); + $tool = $server->tools[$name] ?? throw new ToolWasNotFound($name); + $arguments = $this->resolveArguments($request); + + try { + $boundArguments = $this->argumentBinder->bind($tool->handler, $arguments); + + return $this->createToolResult($this->invoke($tool->class, $tool->handler, $boundArguments)); + } catch (ArgumentsFailedValidation $exception) { + return $this->createToolError($exception); + } catch (McpProtocolException $exception) { + throw $exception; + } catch (Throwable $throwable) { + $this->exceptionProcessor->process($throwable); + + return $this->createToolError($throwable); + } + } + + private function listResources(McpServerDefinition $server): array + { + $resources = []; + + foreach ($server->resources as $resource) { + $resources[] = [ + 'uri' => $resource->uri, + 'name' => $resource->name, + ...($resource->description === null ? [] : ['description' => $resource->description]), + ...($resource->mimeType === null ? [] : ['mimeType' => $resource->mimeType]), + ]; + } + + return ['resources' => $resources]; + } + + private function listResourceTemplates(McpServerDefinition $server): array + { + $resourceTemplates = []; + + foreach ($server->resourceTemplates as $resource) { + $resourceTemplates[] = [ + 'uriTemplate' => $resource->uri, + 'name' => $resource->name, + ...($resource->description === null ? [] : ['description' => $resource->description]), + ...($resource->mimeType === null ? [] : ['mimeType' => $resource->mimeType]), + ]; + } + + return ['resourceTemplates' => $resourceTemplates]; + } + + private function readResource(McpServerDefinition $server, JsonRpcRequest $request): array + { + $uri = $this->resolveStringParam($request, 'uri'); + + [$resource, $variables] = $this->resolveResource($server, $uri); + + try { + $boundArguments = $this->argumentBinder->bind($resource->handler, $variables); + } catch (ArgumentsFailedValidation $exception) { + throw ParametersWereInvalid::becauseValidationFailed($exception); + } + + $result = $this->invoke($resource->class, $resource->handler, $boundArguments); + + return ['contents' => $this->createResourceContents($resource, $uri, $result)]; + } + + private function listPrompts(McpServerDefinition $server): array + { + $prompts = []; + + foreach ($server->prompts as $prompt) { + $arguments = $this->schemaGenerator->createPromptArguments($prompt->handler); + + $prompts[] = [ + 'name' => $prompt->name, + ...($prompt->description === null ? [] : ['description' => $prompt->description]), + ...($arguments === [] ? [] : ['arguments' => $arguments]), + ]; + } + + return ['prompts' => $prompts]; + } + + private function getPrompt(McpServerDefinition $server, JsonRpcRequest $request): array + { + $name = $this->resolveStringParam($request, 'name'); + $prompt = $server->prompts[$name] ?? throw new PromptWasNotFound($name); + $arguments = $this->resolveArguments($request); + + try { + $boundArguments = $this->argumentBinder->bind($prompt->handler, $arguments); + } catch (ArgumentsFailedValidation $exception) { + throw ParametersWereInvalid::becauseValidationFailed($exception); + } + + $result = $this->invoke($prompt->class, $prompt->handler, $boundArguments); + + return [ + ...($prompt->description === null ? [] : ['description' => $prompt->description]), + 'messages' => $this->createPromptMessages($result), + ]; + } + + private function invoke(string $class, MethodReflector $handler, array $boundArguments): mixed + { + $arguments = []; + + foreach ($handler->getParameters() as $parameter) { + $name = $parameter->getName(); + + if (array_key_exists($name, $boundArguments)) { + $arguments[$name] = $boundArguments[$name]; + + continue; + } + + if ($parameter->hasDefaultValue()) { + continue; + } + + $arguments[$name] = $this->container->get( + $parameter->getType()->getName(), + $parameter->getAttribute(Tag::class)?->name, + ); + } + + return $handler->invokeArgs( + $this->container->get($class), + $arguments, + ); + } + + private function resolveStringParam(JsonRpcRequest $request, string $param): string + { + $value = $request->params[$param] ?? null; + + if ($value === null) { + throw ParametersWereInvalid::becauseArgumentWasMissing($param); + } + + if (! is_string($value)) { + throw ParametersWereInvalid::becauseArgumentWasNotAString($param); + } + + return $value; + } + + private function resolveArguments(JsonRpcRequest $request): array + { + $arguments = $request->params['arguments'] ?? []; + + if (! is_array($arguments) || $arguments !== [] && array_is_list($arguments)) { + throw ParametersWereInvalid::becauseArgumentsWereNotAnObject(); + } + + return $arguments; + } + + /** + * @return array{ResourceDefinition, array} + */ + private function resolveResource(McpServerDefinition $server, string $uri): array + { + if (isset($server->resources[$uri])) { + return [$server->resources[$uri], []]; + } + + foreach ($server->resourceTemplates as $resource) { + if (($variables = $resource->uriTemplate->match($uri)) !== null) { + return [$resource, $variables]; + } + } + + throw new ResourceWasNotFound($uri); + } + + private function createToolResult(mixed $result): array + { + [$content, $structuredContent] = $this->normalizeContent($result); + + return [ + 'content' => $content, + 'isError' => false, + ...($structuredContent === null ? [] : ['structuredContent' => $structuredContent]), + ]; + } + + private function createToolError(Throwable $throwable): array + { + return [ + 'content' => [new Text($throwable->getMessage())->toContent()], + 'isError' => true, + ]; + } + + /** + * @return array{array[], mixed} + */ + private function normalizeContent(mixed $result): array + { + if ($result === null) { + return [[], null]; + } + + if ($result instanceof Content) { + return [[$result->toContent()], null]; + } + + if (is_array($result) && $result !== [] && array_is_list($result) && array_all($result, static fn (mixed $item) => $item instanceof Content)) { + return [array_map(static fn (Content $item) => $item->toContent(), $result), null]; + } + + if (is_string($result)) { + return [[new Text($result)->toContent()], null]; + } + + if (is_scalar($result)) { + return [[new Text(encode($result))->toContent()], null]; + } + + $structuredContent = is_array($result) && ! array_is_list($result) || is_object($result) + ? $result + : null; + + return [[new Text(encode($result))->toContent()], $structuredContent]; + } + + private function createResourceContents(ResourceDefinition $resource, string $uri, mixed $result): array + { + if ($result instanceof Content) { + return [$result->toResourceContents($uri, $resource->mimeType)]; + } + + if (is_array($result) && $result !== [] && array_is_list($result) && array_all($result, static fn (mixed $item) => $item instanceof Content)) { + return array_map(static fn (Content $item) => $item->toResourceContents($uri, $resource->mimeType), $result); + } + + if (is_string($result)) { + return [new Text($result)->toResourceContents($uri, $resource->mimeType)]; + } + + return [ + [ + 'uri' => $uri, + 'mimeType' => $resource->mimeType ?? 'application/json', + 'text' => encode($result), + ], + ]; + } + + private function createPromptMessages(mixed $result): array + { + $contents = match (true) { + $result instanceof Content => [$result->toContent()], + is_array($result) && $result !== [] && array_is_list($result) && array_all($result, static fn (mixed $item) => $item instanceof Content) => array_map( + static fn (Content $item) => $item->toContent(), + $result, + ), + is_string($result) => [new Text($result)->toContent()], + default => [new Text(encode($result))->toContent()], + }; + + return array_map(static fn (array $content) => [ + 'role' => 'user', + 'content' => $content, + ], $contents); + } +} diff --git a/packages/mcp/src/McpResource.php b/packages/mcp/src/McpResource.php new file mode 100644 index 000000000..866557642 --- /dev/null +++ b/packages/mcp/src/McpResource.php @@ -0,0 +1,23 @@ + */ + public array $tools = []; + + /** @var array */ + public array $prompts = []; + + /** @var array */ + public array $resources = []; + + /** @var array */ + public array $resourceTemplates = []; + + public function __construct( + public readonly string $class, + public readonly string $name, + public readonly string $version, + public readonly ?string $instructions, + public readonly ?string $path, + ) {} +} diff --git a/packages/mcp/src/McpTool.php b/packages/mcp/src/McpTool.php new file mode 100644 index 000000000..075eb97de --- /dev/null +++ b/packages/mcp/src/McpTool.php @@ -0,0 +1,20 @@ +uriTemplate = new UriTemplate($uri); + } + + public function isTemplated(): bool + { + return $this->uriTemplate->isTemplated(); + } +} diff --git a/packages/mcp/src/SchemaGenerator.php b/packages/mcp/src/SchemaGenerator.php new file mode 100644 index 000000000..de06ee029 --- /dev/null +++ b/packages/mcp/src/SchemaGenerator.php @@ -0,0 +1,212 @@ + + */ + public function getSchemaParameters(MethodReflector $method): array + { + $parameters = []; + + foreach ($method->getParameters() as $parameter) { + if (! $this->resolveType($parameter) instanceof TypeReflector) { + continue; + } + + $parameters[$parameter->getName()] = $parameter; + } + + return $parameters; + } + + /** + * Asserts that every parameter of the given handler can either be represented in a schema or be resolved + * through the container, so unsupported signatures fail at discovery time instead of at call time. + */ + public function assertSupported(MethodReflector $method): void + { + foreach ($method->getParameters() as $parameter) { + if ($parameter->isVariadic()) { + throw ParameterWasNotSupported::becauseItWasVariadic($method, $parameter->getName()); + } + + $type = $parameter->getType(); + + if (! $type->isUnion()) { + continue; + } + + $members = array_filter($type->split(), static fn (TypeReflector $member) => $member->getName() !== 'null'); + + if (count($members) > 1) { + throw ParameterWasNotSupported::becauseUnionTypesAreNotSupported($method, $parameter->getName()); + } + } + } + + /** + * Creates the JSON schema describing the input of a tool handler. + */ + public function createInputSchema(MethodReflector $method): array + { + $properties = []; + $required = []; + + foreach ($this->getSchemaParameters($method) as $name => $parameter) { + $properties[$name] = $this->createParameterSchema($parameter); + + if ($this->isRequired($parameter)) { + $required[] = $name; + } + } + + return [ + 'type' => 'object', + 'properties' => $properties === [] ? new stdClass() : $properties, + ...($required === [] ? [] : ['required' => $required]), + ]; + } + + /** + * Creates the MCP argument definitions describing the input of a prompt handler. + */ + public function createPromptArguments(MethodReflector $method): array + { + $arguments = []; + + foreach ($this->getSchemaParameters($method) as $name => $parameter) { + $description = $parameter->getAttribute(Description::class)?->description; + + $arguments[] = [ + 'name' => $name, + ...($description === null ? [] : ['description' => $description]), + 'required' => $this->isRequired($parameter), + ]; + } + + return $arguments; + } + + public function isRequired(ParameterReflector $parameter): bool + { + return ! $parameter->hasDefaultValue() && ! $parameter->getType()->isNullable(); + } + + /** + * Resolves the schema type of a parameter, or `null` when the parameter is not part of the schema. + */ + public function resolveType(ParameterReflector $parameter): ?TypeReflector + { + $type = $parameter->getType(); + + if ($type->isUnion()) { + $members = array_values(array_filter( + $type->split(), + static fn (TypeReflector $member) => $member->getName() !== 'null', + )); + + if (count($members) !== 1) { + return null; + } + + $type = $members[0]; + } + + if ($type->isScalar() || $type->isBackedEnum() || $type->getName() === 'array' || $type->getName() === '?array') { + return $type; + } + + return null; + } + + private function createParameterSchema(ParameterReflector $parameter): array + { + $type = $this->resolveType($parameter); + $nullable = $parameter->getType()->isNullable(); + + if ($type->isBackedEnum()) { + $enum = $type->asEnum(); + $jsonType = $enum->getBackingType()?->getName() === 'int' ? 'integer' : 'string'; + + $values = array_column($enum->getCases(), 'value'); + + $schema = [ + 'type' => $nullable ? [$jsonType, 'null'] : $jsonType, + 'enum' => $nullable ? [...$values, null] : $values, + ]; + } else { + $jsonType = match (str_replace('?', '', $type->getName())) { + 'int' => 'integer', + 'float' => 'number', + 'bool' => 'boolean', + 'array' => 'array', + default => 'string', + }; + + $schema = [ + 'type' => $nullable ? [$jsonType, 'null'] : $jsonType, + ]; + } + + if (($description = $parameter->getAttribute(Description::class)) instanceof Description) { + $schema['description'] = $description->description; + } + + foreach ($parameter->getAttributes(Rule::class) as $rule) { + $schema = [...$schema, ...$this->createConstraints($rule)]; + } + + if ($parameter->hasDefaultValue()) { + $default = $parameter->getDefaultValue(); + + $schema['default'] = $default instanceof BackedEnum ? $default->value : $default; + } + + return $schema; + } + + private function createConstraints(Rule $rule): array + { + $variables = $rule instanceof HasTranslationVariables ? $rule->getTranslationVariables() : []; + + return match ($rule::class) { + HasLength::class => [ + ...($variables['min'] === null ? [] : ['minLength' => $variables['min']]), + ...($variables['max'] === null ? [] : ['maxLength' => $variables['max']]), + ], + IsBetween::class => [ + 'minimum' => $variables['min'], + 'maximum' => $variables['max'], + ], + IsIn::class => $variables['not'] ? [] : ['enum' => $variables['values']], + IsNotEmptyString::class => ['minLength' => 1], + IsEmail::class => ['format' => 'email'], + IsUrl::class => ['format' => 'uri'], + IsUuid::class => ['format' => 'uuid'], + default => [], + }; + } +} diff --git a/packages/mcp/src/StdioTransport.php b/packages/mcp/src/StdioTransport.php new file mode 100644 index 000000000..54df7e0a0 --- /dev/null +++ b/packages/mcp/src/StdioTransport.php @@ -0,0 +1,39 @@ +requestHandler->handle($server, $line); + + if ($response instanceof JsonRpcResponse) { + fwrite($output, encode($response->toArray()) . PHP_EOL); + } + } + } +} diff --git a/packages/mcp/src/Testing/McpTestConnection.php b/packages/mcp/src/Testing/McpTestConnection.php new file mode 100644 index 000000000..a6d166016 --- /dev/null +++ b/packages/mcp/src/Testing/McpTestConnection.php @@ -0,0 +1,106 @@ +send('initialize', [ + 'protocolVersion' => ProtocolVersion::LATEST->value, + 'capabilities' => new stdClass(), + 'clientInfo' => [ + 'name' => 'tempest-test-client', + 'version' => '1.0.0', + ], + ]); + + $this->notify('notifications/initialized'); + + return $response; + } + + public function send(string $method, array|object $params = []): McpTestResponse + { + $response = $this->requestHandler->handle($this->server, encode([ + 'jsonrpc' => '2.0', + 'id' => ++$this->lastId, + 'method' => $method, + ...($params === [] ? [] : ['params' => $params]), + ])); + + Assert::assertNotNull($response, "Expected a response for the `{$method}` request, but none was returned."); + + return new McpTestResponse(decode(encode($response->toArray()))); + } + + public function notify(string $method, array|object $params = []): void + { + $response = $this->requestHandler->handle($this->server, encode([ + 'jsonrpc' => '2.0', + 'method' => $method, + ...($params === [] ? [] : ['params' => $params]), + ])); + + Assert::assertNull($response, "Expected no response for the `{$method}` notification, but one was returned."); + } + + public function callTool(string $name, array $arguments = []): McpTestResponse + { + return $this->send('tools/call', [ + 'name' => $name, + ...($arguments === [] ? [] : ['arguments' => $arguments]), + ]); + } + + public function getPrompt(string $name, array $arguments = []): McpTestResponse + { + return $this->send('prompts/get', [ + 'name' => $name, + ...($arguments === [] ? [] : ['arguments' => $arguments]), + ]); + } + + public function readResource(string $uri): McpTestResponse + { + return $this->send('resources/read', ['uri' => $uri]); + } + + public function listTools(): McpTestResponse + { + return $this->send('tools/list'); + } + + public function listResources(): McpTestResponse + { + return $this->send('resources/list'); + } + + public function listResourceTemplates(): McpTestResponse + { + return $this->send('resources/templates/list'); + } + + public function listPrompts(): McpTestResponse + { + return $this->send('prompts/list'); + } +} diff --git a/packages/mcp/src/Testing/McpTestResponse.php b/packages/mcp/src/Testing/McpTestResponse.php new file mode 100644 index 000000000..749c7072b --- /dev/null +++ b/packages/mcp/src/Testing/McpTestResponse.php @@ -0,0 +1,120 @@ +response['result'] ?? null; + } + + public function error(): ?array + { + return $this->response['error'] ?? null; + } + + public function assertOk(): self + { + Assert::assertArrayHasKey('result', $this->response, sprintf( + 'Expected a successful result, but got the error: %s', + $this->response['error']['message'] ?? 'unknown', + )); + + Assert::assertNotTrue( + $this->result()['isError'] ?? false, + sprintf('Expected a successful result, but the call errored: %s', encode($this->result())), + ); + + return $this; + } + + public function assertError(?string $message = null): self + { + $isProtocolError = isset($this->response['error']); + $isToolError = ($this->result()['isError'] ?? false) === true; + + Assert::assertTrue($isProtocolError || $isToolError, 'Expected an error, but the call succeeded.'); + + if ($message !== null) { + $haystack = $isProtocolError + ? $this->response['error']['message'] ?? '' + : implode("\n", $this->texts()); + + Assert::assertStringContainsString($message, $haystack); + } + + return $this; + } + + public function assertText(string $text): self + { + Assert::assertContains($text, $this->texts()); + + return $this; + } + + public function assertTextContains(string $needle): self + { + Assert::assertStringContainsString($needle, implode("\n", $this->texts())); + + return $this; + } + + public function assertStructured(array $expected): self + { + Assert::assertSame($expected, $this->result()['structuredContent'] ?? null); + + return $this; + } + + public function assertSee(string $needle): self + { + Assert::assertStringContainsString($needle, encode($this->response)); + + return $this; + } + + public function assertToolListed(string $name): self + { + $tools = array_column($this->result()['tools'] ?? [], 'name'); + + Assert::assertContains($name, $tools); + + return $this; + } + + /** + * Collects all text values from tool content, prompt messages and resource contents. + * + * @return string[] + */ + private function texts(): array + { + $result = $this->result() ?? []; + + $texts = array_column($result['content'] ?? [], 'text'); + + foreach ($result['messages'] ?? [] as $message) { + if (! isset($message['content']['text'])) { + continue; + } + + $texts[] = $message['content']['text']; + } + + $texts = [...$texts, ...array_column($result['contents'] ?? [], 'text')]; + + return array_filter($texts, is_string(...)); + } +} diff --git a/packages/mcp/src/Testing/McpTester.php b/packages/mcp/src/Testing/McpTester.php new file mode 100644 index 000000000..833210a92 --- /dev/null +++ b/packages/mcp/src/Testing/McpTester.php @@ -0,0 +1,41 @@ +container->get(McpConfig::class); + + $definition = + $config->servers[$server] ?? $config->getServerByName($server) ?? throw class_exists($server) + ? McpServerWasNotFound::withClass($server) + : McpServerWasNotFound::withName($server); + + $connection = new McpTestConnection( + requestHandler: $this->container->get(McpRequestHandler::class), + server: $definition, + ); + + $connection->initialize(); + + return $connection; + } +} diff --git a/packages/mcp/src/ToolDefinition.php b/packages/mcp/src/ToolDefinition.php new file mode 100644 index 000000000..3b62987c5 --- /dev/null +++ b/packages/mcp/src/ToolDefinition.php @@ -0,0 +1,17 @@ +orderedVariableNames = $matches[1]; + $this->variableNames = array_values(array_unique($matches[1])); + } + + public function isTemplated(): bool + { + return $this->variableNames !== []; + } + + /** + * Matches the given URI against this template, returning the extracted template variables, or `null` when the URI does not match. + * + * @return array|null + */ + public function match(string $uri): ?array + { + $this->pattern ??= $this->compilePattern(); + + if (! preg_match($this->pattern, $uri, $matches)) { + return null; + } + + $variables = []; + + foreach ($this->orderedVariableNames as $index => $name) { + $variables[$name] = rawurldecode($matches[$index + 1] ?? ''); + } + + return $variables; + } + + public function __toString(): string + { + return $this->template; + } + + private function compilePattern(): string + { + $segments = preg_split('/(\{\w+})/', $this->template, flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + $pattern = ''; + + foreach ($segments as $segment) { + $pattern .= preg_match('/^\{\w+}$/', $segment) + ? '([^/]+)' + : preg_quote($segment, '#'); + } + + return "#^{$pattern}$#"; + } +} diff --git a/packages/mcp/src/mcp.config.php b/packages/mcp/src/mcp.config.php new file mode 100644 index 000000000..789c90c99 --- /dev/null +++ b/packages/mcp/src/mcp.config.php @@ -0,0 +1,7 @@ +binder()->bind($this->method('scalars'), [ + 'a' => 'text', + 'b' => 42, + 'c' => 1.5, + 'd' => true, + 'e' => ['x'], + ]); + + $this->assertSame(['a' => 'text', 'b' => 42, 'c' => 1.5, 'd' => true, 'e' => ['x']], $bound); + } + + #[Test] + public function casts_enum_arguments_to_enum_instances(): void + { + $bound = $this->binder()->bind($this->method('enums'), ['suit' => 'hearts']); + + $this->assertSame(['suit' => Suit::HEARTS], $bound); + } + + #[Test] + public function omits_absent_arguments_with_defaults(): void + { + $bound = $this->binder()->bind($this->method('optionals'), []); + + $this->assertSame(['filter' => null], $bound); + $this->assertArrayNotHasKey('limit', $bound); + $this->assertArrayNotHasKey('sort', $bound); + } + + #[Test] + public function preserves_explicitly_provided_null_values(): void + { + $bound = $this->binder()->bind($this->method('optionals'), ['limit' => 5, 'sort' => null]); + + $this->assertSame(['filter' => null, 'limit' => 5, 'sort' => null], $bound); + } + + #[Test] + public function injected_parameters_are_not_part_of_the_binding(): void + { + $bound = $this->binder()->bind($this->method('injected'), ['name' => 'a']); + + $this->assertSame(['name' => 'a'], $bound); + } + + #[Test] + public function injected_parameters_cannot_be_provided_by_the_client(): void + { + $this->expectException(ParametersWereInvalid::class); + $this->expectExceptionMessage('The argument `service` is unknown'); + + $this->binder()->bind($this->method('injected'), ['name' => 'a', 'service' => 'spoofed']); + } + + #[Test] + public function rejects_unknown_arguments(): void + { + $this->expectException(ParametersWereInvalid::class); + $this->expectExceptionMessage('The argument `extra` is unknown'); + + $this->binder()->bind($this->method('enums'), ['suit' => 'hearts', 'extra' => 1]); + } + + #[Test] + public function rejects_missing_required_arguments(): void + { + $this->expectException(ParametersWereInvalid::class); + $this->expectExceptionMessage('The required argument `suit` is missing'); + + $this->binder()->bind($this->method('enums'), []); + } + + private function binder(): ArgumentBinder + { + return new ArgumentBinder(new SchemaGenerator(), new Validator()); + } + + private function method(string $name): MethodReflector + { + return MethodReflector::fromParts(SchemaFixture::class, $name); + } +} diff --git a/packages/mcp/tests/ContentTest.php b/packages/mcp/tests/ContentTest.php new file mode 100644 index 000000000..d8f5b850b --- /dev/null +++ b/packages/mcp/tests/ContentTest.php @@ -0,0 +1,117 @@ +assertSame(['type' => 'text', 'text' => 'hello'], $text->toContent()); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'text/plain', 'text' => 'hello'], + $text->toResourceContents('demo://a', null), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'text/html', 'text' => 'hello'], + $text->toResourceContents('demo://a', 'text/html'), + ); + } + + #[Test] + public function image_content(): void + { + $image = new Image('raw-bytes', 'image/jpeg'); + + $this->assertSame( + ['type' => 'image', 'data' => base64_encode('raw-bytes'), 'mimeType' => 'image/jpeg'], + $image->toContent(), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'image/jpeg', 'blob' => base64_encode('raw-bytes')], + $image->toResourceContents('demo://a', 'application/octet-stream'), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'image/webp', 'blob' => base64_encode('raw-bytes')], + new Image('raw-bytes')->toResourceContents('demo://a', 'image/webp'), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'image/png', 'blob' => base64_encode('raw-bytes')], + new Image('raw-bytes')->toResourceContents('demo://a', null), + ); + } + + #[Test] + public function audio_content(): void + { + $audio = new Audio('raw-bytes'); + + $this->assertSame( + ['type' => 'audio', 'data' => base64_encode('raw-bytes'), 'mimeType' => 'audio/wav'], + $audio->toContent(), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'audio/mpeg', 'blob' => base64_encode('raw-bytes')], + $audio->toResourceContents('demo://a', 'audio/mpeg'), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'audio/flac', 'blob' => base64_encode('raw-bytes')], + new Audio('raw-bytes', 'audio/flac')->toResourceContents('demo://a', 'audio/mpeg'), + ); + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'audio/wav', 'blob' => base64_encode('raw-bytes')], + $audio->toResourceContents('demo://a', null), + ); + } + + #[Test] + public function blob_content(): void + { + $blob = new Blob('raw-bytes'); + + $this->assertSame( + ['uri' => 'demo://a', 'mimeType' => 'application/octet-stream', 'blob' => base64_encode('raw-bytes')], + $blob->toResourceContents('demo://a', null), + ); + + $this->expectException(ContentWasNotSupported::class); + + $blob->toContent(); + } + + #[Test] + public function resource_link_content(): void + { + $link = new ResourceLink(uri: 'demo://a', name: 'a', description: 'A resource', mimeType: 'text/plain'); + + $this->assertSame( + ['type' => 'resource_link', 'uri' => 'demo://a', 'name' => 'a', 'description' => 'A resource', 'mimeType' => 'text/plain'], + $link->toContent(), + ); + + $this->assertSame( + ['type' => 'resource_link', 'uri' => 'demo://b', 'name' => 'b'], + new ResourceLink(uri: 'demo://b', name: 'b')->toContent(), + ); + + $this->expectException(ContentWasNotSupported::class); + + $link->toResourceContents('demo://a', null); + } +} diff --git a/packages/mcp/tests/Fixtures/ExtendingFixtureServer.php b/packages/mcp/tests/Fixtures/ExtendingFixtureServer.php new file mode 100644 index 000000000..f13324494 --- /dev/null +++ b/packages/mcp/tests/Fixtures/ExtendingFixtureServer.php @@ -0,0 +1,7 @@ + '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'add'], + ]; + + $request = JsonRpcRequest::fromArray($raw); + + $this->assertSame(1, $request->id); + $this->assertSame('tools/call', $request->method); + $this->assertSame(['name' => 'add'], $request->params); + $this->assertSame($raw, $request->toArray()); + } + + #[Test] + public function requests_without_params_omit_them(): void + { + $request = JsonRpcRequest::fromArray(['jsonrpc' => '2.0', 'id' => 'a', 'method' => 'ping']); + + $this->assertSame(['jsonrpc' => '2.0', 'id' => 'a', 'method' => 'ping'], $request->toArray()); + } + + #[Test] + public function notifications_round_trip(): void + { + $raw = [ + 'jsonrpc' => '2.0', + 'method' => 'notifications/initialized', + 'params' => ['_meta' => ['key' => 'value']], + ]; + + $notification = JsonRpcNotification::fromArray($raw); + + $this->assertSame('notifications/initialized', $notification->method); + $this->assertSame($raw, $notification->toArray()); + } + + #[Test] + public function missing_jsonrpc_versions_are_rejected(): void + { + $this->expectException(RequestWasInvalid::class); + + JsonRpcRequest::fromArray(['id' => 1, 'method' => 'ping']); + } + + #[Test] + public function non_string_methods_are_rejected(): void + { + $this->expectException(RequestWasInvalid::class); + + JsonRpcRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 42]); + } + + #[Test] + public function invalid_ids_are_rejected(): void + { + $this->expectException(RequestWasInvalid::class); + + JsonRpcRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1.5, 'method' => 'ping']); + } + + #[Test] + public function list_params_are_rejected(): void + { + $this->expectException(RequestWasInvalid::class); + + JsonRpcRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping', 'params' => [1, 2]]); + } + + #[Test] + public function success_responses_render_spec_compliant_json(): void + { + $response = new JsonRpcSuccessResponse(1, ['tools' => []]); + + $this->assertSame( + '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', + json_encode($response->toArray()), + ); + } + + #[Test] + public function empty_results_serialize_as_objects(): void + { + $response = new JsonRpcSuccessResponse(1, []); + + $this->assertSame( + '{"jsonrpc":"2.0","id":1,"result":{}}', + json_encode($response->toArray()), + ); + } + + #[Test] + public function error_responses_render_spec_compliant_json(): void + { + $response = new JsonRpcErrorResponse( + id: null, + code: JsonRpcErrorCode::PARSE_ERROR, + message: 'Parse error', + data: ['detail' => 'unexpected token'], + ); + + $this->assertSame( + '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error","data":{"detail":"unexpected token"}}}', + json_encode($response->toArray()), + ); + } + + #[Test] + public function error_responses_without_data_omit_the_data_key(): void + { + $response = new JsonRpcErrorResponse( + id: 1, + code: JsonRpcErrorCode::INTERNAL_ERROR, + message: 'boom', + ); + + $this->assertSame( + '{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"boom"}}', + json_encode($response->toArray()), + ); + } + + #[Test] + public function notifications_without_a_method_are_rejected(): void + { + $this->expectException(RequestWasInvalid::class); + + JsonRpcNotification::fromArray(['jsonrpc' => '2.0']); + } + + #[Test] + public function error_responses_are_created_from_exceptions(): void + { + $response = JsonRpcErrorResponse::fromException(RequestWasInvalid::becauseBatchesAreNotSupported(), id: 3); + + $this->assertSame(3, $response->toArray()['id']); + $this->assertSame(-32_600, $response->toArray()['error']['code']); + $this->assertSame('Batched JSON-RPC messages are not supported.', $response->toArray()['error']['message']); + } +} diff --git a/packages/mcp/tests/McpConfigTest.php b/packages/mcp/tests/McpConfigTest.php new file mode 100644 index 000000000..9dbb84689 --- /dev/null +++ b/packages/mcp/tests/McpConfigTest.php @@ -0,0 +1,205 @@ +addServer(RegistryFixtureServer::class, new McpServer()); + + $server = $config->servers[RegistryFixtureServer::class]; + + $this->assertSame('registry-fixture-server', $server->name); + $this->assertSame('0.0.1', $server->version); + $this->assertNull($server->path); + } + + #[Test] + public function normalizes_paths(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer(path: 'mcp/fixture/')); + + $this->assertSame('/mcp/fixture', $config->servers[RegistryFixtureServer::class]->path); + $this->assertSame(RegistryFixtureServer::class, $config->getServerByPath('/mcp/fixture')?->class); + $this->assertSame(RegistryFixtureServer::class, $config->getServerByPath('/mcp/fixture/')?->class); + $this->assertSame(RegistryFixtureServer::class, $config->getServerByPath('mcp/fixture')?->class); + } + + #[Test] + public function rejects_duplicate_server_names(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer(name: 'duplicate')); + + $this->expectException(McpServerWasAlreadyRegistered::class); + + $config->addServer(SomeService::class, new McpServer(name: 'duplicate')); + } + + #[Test] + public function rejects_duplicate_server_paths(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer(name: 'a', path: '/mcp')); + + $this->expectException(McpServerWasAlreadyRegistered::class); + + $config->addServer(SomeService::class, new McpServer(name: 'b', path: '/mcp')); + } + + #[Test] + public function derives_tool_names_from_method_names(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + $config->addTool(MethodReflector::fromParts(RegistryFixtureServer::class, 'multiWord'), new McpTool()); + + $this->assertArrayHasKey('multi_word', $config->servers[RegistryFixtureServer::class]->tools); + } + + #[Test] + public function rejects_duplicate_tool_names(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + $config->addTool(MethodReflector::fromParts(RegistryFixtureServer::class, 'multiWord'), new McpTool()); + + $this->expectException(PrimitiveWasAlreadyRegistered::class); + + $config->addTool(MethodReflector::fromParts(RegistryFixtureServer::class, 'withoutParameters'), new McpTool(name: 'multi_word')); + } + + #[Test] + public function rejects_unknown_servers(): void + { + $config = new McpConfig(); + + $this->expectException(McpServerWasNotFound::class); + + $config->addTool( + MethodReflector::fromParts(RegistryFixtureServer::class, 'multiWord'), + new McpTool(server: SomeService::class), + ); + } + + #[Test] + public function rejects_unattached_primitives(): void + { + $config = new McpConfig(); + + $this->expectException(McpServerWasNotFound::class); + + $config->addTool(MethodReflector::fromParts(RegistryFixtureServer::class, 'multiWord'), new McpTool()); + } + + #[Test] + public function attaches_inherited_methods_to_the_extending_server(): void + { + $config = new McpConfig(); + $config->addServer(ExtendingFixtureServer::class, new McpServer()); + $config->addTool( + MethodReflector::fromParts(ExtendingFixtureServer::class, 'sharedTool'), + new McpTool(), + owner: ExtendingFixtureServer::class, + ); + + $tool = $config->servers[ExtendingFixtureServer::class]->tools['shared_tool']; + + $this->assertSame(ExtendingFixtureServer::class, $tool->class); + } + + #[Test] + public function skips_primitives_inherited_by_non_server_classes(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + $config->addTool( + MethodReflector::fromParts(ExtendingFixtureServer::class, 'sharedTool'), + new McpTool(), + owner: ExtendingFixtureServer::class, + ); + + $this->assertSame([], $config->servers[RegistryFixtureServer::class]->tools); + } + + #[Test] + public function skips_unattached_primitives_on_abstract_classes(): void + { + $config = new McpConfig(); + $config->addTool( + MethodReflector::fromParts(SharedToolsFixture::class, 'sharedTool'), + new McpTool(), + owner: SharedToolsFixture::class, + ); + + $this->assertSame([], $config->servers); + } + + #[Test] + public function registers_explicitly_attached_primitives_only_from_their_declaring_class(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + + $attribute = new McpTool(server: RegistryFixtureServer::class); + + $config->addTool(MethodReflector::fromParts(ExtendingFixtureServer::class, 'sharedTool'), $attribute, owner: ExtendingFixtureServer::class); + $config->addTool(MethodReflector::fromParts(SharedToolsFixture::class, 'sharedTool'), $attribute, owner: SharedToolsFixture::class); + + $this->assertArrayHasKey('shared_tool', $config->servers[RegistryFixtureServer::class]->tools); + } + + #[Test] + public function rejects_template_variables_without_matching_parameters(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + + $this->expectException(ResourceTemplateWasInvalid::class); + + $config->addResource( + MethodReflector::fromParts(RegistryFixtureServer::class, 'withoutParameters'), + new McpResource(uri: 'fixture://things/{thing}'), + ); + } + + #[Test] + public function separates_resources_from_resource_templates(): void + { + $config = new McpConfig(); + $config->addServer(RegistryFixtureServer::class, new McpServer()); + $config->addResource(MethodReflector::fromParts(RegistryFixtureServer::class, 'item'), new McpResource(uri: 'fixture://items/{id}')); + $config->addResource(MethodReflector::fromParts(RegistryFixtureServer::class, 'withoutParameters'), new McpResource(uri: 'fixture://static')); + + $server = $config->servers[RegistryFixtureServer::class]; + + $this->assertArrayHasKey('fixture://items/{id}', $server->resourceTemplates); + $this->assertArrayHasKey('fixture://static', $server->resources); + $this->assertArrayNotHasKey('fixture://items/{id}', $server->resources); + } +} diff --git a/packages/mcp/tests/ProtocolVersionTest.php b/packages/mcp/tests/ProtocolVersionTest.php new file mode 100644 index 000000000..09d953d5c --- /dev/null +++ b/packages/mcp/tests/ProtocolVersionTest.php @@ -0,0 +1,49 @@ +assertSame($version, ProtocolVersion::negotiate($version)->value); + } + + public static function supported_versions(): iterable + { + yield '2024-11-05' => ['2024-11-05']; + yield '2025-03-26' => ['2025-03-26']; + yield '2025-06-18' => ['2025-06-18']; + yield '2025-11-25' => ['2025-11-25']; + } + + #[Test] + public function unsupported_versions_negotiate_to_the_latest(): void + { + $this->assertSame(ProtocolVersion::LATEST, ProtocolVersion::negotiate(null)); + $this->assertSame(ProtocolVersion::LATEST, ProtocolVersion::negotiate('1999-01-01')); + $this->assertSame(ProtocolVersion::LATEST, ProtocolVersion::negotiate('not-a-version')); + } + + #[Test] + public function supports_exactly_the_documented_versions(): void + { + $this->assertSame('2025-11-25', ProtocolVersion::LATEST->value); + $this->assertEqualsCanonicalizing( + ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'], + array_column(ProtocolVersion::supported(), 'value'), + ); + } +} diff --git a/packages/mcp/tests/SchemaGeneratorTest.php b/packages/mcp/tests/SchemaGeneratorTest.php new file mode 100644 index 000000000..9cc43624a --- /dev/null +++ b/packages/mcp/tests/SchemaGeneratorTest.php @@ -0,0 +1,155 @@ +createInputSchema(MethodReflector::fromParts(SchemaFixture::class, $method)); + + $this->assertEquals($expected, $schema); + } + + public static function schemas(): iterable + { + yield 'scalars' => [ + 'scalars', + [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'string'], + 'b' => ['type' => 'integer'], + 'c' => ['type' => 'number'], + 'd' => ['type' => 'boolean'], + 'e' => ['type' => 'array'], + ], + 'required' => ['a', 'b', 'c', 'd', 'e'], + ], + ]; + + yield 'optionals' => [ + 'optionals', + [ + 'type' => 'object', + 'properties' => [ + 'filter' => ['type' => ['string', 'null']], + 'limit' => ['type' => 'integer', 'default' => 10], + 'sort' => ['type' => ['string', 'null'], 'default' => null], + ], + ], + ]; + + yield 'enums' => [ + 'enums', + [ + 'type' => 'object', + 'properties' => [ + 'suit' => ['type' => 'string', 'enum' => ['hearts', 'spades']], + 'fallback' => ['type' => ['string', 'null'], 'enum' => ['hearts', 'spades', null], 'default' => null], + ], + 'required' => ['suit'], + ], + ]; + + yield 'injected services are excluded' => [ + 'injected', + [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + ], + ]; + + yield 'validation attributes become constraints' => [ + 'constrained', + [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 10], + 'score' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], + 'letter' => ['type' => 'string', 'enum' => ['a', 'b']], + 'described' => ['type' => 'string', 'description' => 'The described parameter'], + ], + 'required' => ['name', 'score', 'letter', 'described'], + ], + ]; + + yield 'no schema parameters' => [ + 'nothing', + [ + 'type' => 'object', + 'properties' => new stdClass(), + ], + ]; + } + + #[Test] + public function derives_prompt_arguments(): void + { + $arguments = new SchemaGenerator()->createPromptArguments(MethodReflector::fromParts(SchemaFixture::class, 'optionals')); + + $this->assertSame( + [ + ['name' => 'filter', 'required' => false], + ['name' => 'limit', 'required' => false], + ['name' => 'sort', 'required' => false], + ], + $arguments, + ); + } + + #[Test] + public function prompt_arguments_include_descriptions(): void + { + $arguments = new SchemaGenerator()->createPromptArguments(MethodReflector::fromParts(SchemaFixture::class, 'constrained')); + + $this->assertSame(['name' => 'described', 'description' => 'The described parameter', 'required' => true], $arguments[3]); + } + + #[Test] + public function variadic_parameters_are_not_supported(): void + { + $this->expectException(ParameterWasNotSupported::class); + $this->expectExceptionMessage('is variadic'); + + new SchemaGenerator()->assertSupported(MethodReflector::fromParts(SchemaFixture::class, 'variadic')); + } + + #[Test] + public function union_parameters_are_not_supported(): void + { + $this->expectException(ParameterWasNotSupported::class); + $this->expectExceptionMessage('union type'); + + new SchemaGenerator()->assertSupported(MethodReflector::fromParts(SchemaFixture::class, 'union')); + } + + #[Test] + public function supported_signatures_pass_the_guard(): void + { + $this->expectNotToPerformAssertions(); + + foreach (['scalars', 'optionals', 'enums', 'injected', 'constrained', 'nothing'] as $method) { + new SchemaGenerator()->assertSupported(MethodReflector::fromParts(SchemaFixture::class, $method)); + } + } +} diff --git a/packages/mcp/tests/UriTemplateTest.php b/packages/mcp/tests/UriTemplateTest.php new file mode 100644 index 000000000..4b94d67aa --- /dev/null +++ b/packages/mcp/tests/UriTemplateTest.php @@ -0,0 +1,77 @@ +assertTrue($template->isTemplated()); + $this->assertSame(['id', 'postId'], $template->variableNames); + } + + #[Test] + public function plain_uris_are_not_templated(): void + { + $template = new UriTemplate('demo://config'); + + $this->assertFalse($template->isTemplated()); + $this->assertSame([], $template->variableNames); + } + + #[Test] + public function matches_uris_against_the_template(): void + { + $template = new UriTemplate('demo://users/{id}/posts/{postId}'); + + $this->assertSame(['id' => '42', 'postId' => '7'], $template->match('demo://users/42/posts/7')); + $this->assertNull($template->match('demo://users/42')); + $this->assertNull($template->match('demo://users/42/posts/7/comments')); + $this->assertNull($template->match('other://users/42/posts/7')); + } + + #[Test] + public function variables_do_not_match_across_segments(): void + { + $template = new UriTemplate('demo://users/{id}'); + + $this->assertNull($template->match('demo://users/42/posts')); + $this->assertSame(['id' => 'jon-doe'], $template->match('demo://users/jon-doe')); + } + + #[Test] + public function percent_encoded_variables_are_decoded(): void + { + $template = new UriTemplate('demo://users/{id}'); + + $this->assertSame(['id' => 'jon doe'], $template->match('demo://users/jon%20doe')); + $this->assertSame(['id' => 'a/b'], $template->match('demo://users/a%2Fb')); + } + + #[Test] + public function special_characters_in_literals_are_escaped(): void + { + $template = new UriTemplate('demo://search.json?q={query}'); + + $this->assertSame(['query' => 'tempest'], $template->match('demo://search.json?q=tempest')); + $this->assertNull($template->match('demo://searchXjson?q=tempest')); + } + + #[Test] + public function converts_to_string(): void + { + $this->assertSame('demo://users/{id}', (string) new UriTemplate('demo://users/{id}')); + } +} diff --git a/packages/validation/src/Rules/IsArray.php b/packages/validation/src/Rules/IsArray.php new file mode 100644 index 000000000..7f00321e1 --- /dev/null +++ b/packages/validation/src/Rules/IsArray.php @@ -0,0 +1,36 @@ +orNull && $value === null) { + return true; + } + + return is_array($value); + } + + public function getTranslationVariables(): array + { + return [ + 'or_null' => $this->orNull, + ]; + } +} diff --git a/packages/validation/src/Rules/IsEnum.php b/packages/validation/src/Rules/IsEnum.php index cf318c927..79677b19b 100644 --- a/packages/validation/src/Rules/IsEnum.php +++ b/packages/validation/src/Rules/IsEnum.php @@ -8,6 +8,7 @@ use BackedEnum; use Tempest\Validation\HasTranslationVariables; use Tempest\Validation\Rule; +use TypeError; use UnexpectedValueException; use UnitEnum; @@ -114,7 +115,19 @@ private function retrieveEnumValue(mixed $value): mixed } if (method_exists($this->enum, 'tryFrom')) { - return $this->enum::tryFrom($value); + if (! is_string($value) && ! is_int($value)) { + return null; + } + + try { + return $this->enum::tryFrom($value); + } catch (TypeError) { + return null; + } + } + + if (! is_string($value)) { + return null; } return defined("{$this->enum}::{$value}") ? $this->enum::{$value} : null; diff --git a/packages/validation/src/localization.en.yml b/packages/validation/src/localization.en.yml index 4ce3310eb..e82b393eb 100644 --- a/packages/validation/src/localization.en.yml +++ b/packages/validation/src/localization.en.yml @@ -23,6 +23,12 @@ validation_error: is_alpha_numeric: | .input {$field :string} {$field} must contain only alphanumeric characters + is_array: | + .input {$field :string} + .input {$or_null :boolean} + .match $or_null + true {{{$field} must be an array if specified}} + false {{{$field} must be an array}} is_array_list: | .input {$field :string} {$field} must be a list diff --git a/packages/validation/tests/Rules/IsArrayTest.php b/packages/validation/tests/Rules/IsArrayTest.php new file mode 100644 index 000000000..0789ad3d3 --- /dev/null +++ b/packages/validation/tests/Rules/IsArrayTest.php @@ -0,0 +1,38 @@ +assertTrue($rule->isValid([])); + $this->assertTrue($rule->isValid(['a', 'b'])); + $this->assertTrue($rule->isValid(['key' => 'value'])); + $this->assertFalse($rule->isValid('nope')); + $this->assertFalse($rule->isValid(1)); + $this->assertFalse($rule->isValid(null)); + } + + #[Test] + public function validating_with_or_null(): void + { + $rule = new IsArray(orNull: true); + + $this->assertTrue($rule->isValid(null)); + $this->assertTrue($rule->isValid([])); + $this->assertFalse($rule->isValid('nope')); + } +} diff --git a/packages/validation/tests/Rules/IsEnumTest.php b/packages/validation/tests/Rules/IsEnumTest.php index 6201a7ff3..e4d8e5048 100644 --- a/packages/validation/tests/Rules/IsEnumTest.php +++ b/packages/validation/tests/Rules/IsEnumTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use stdClass; use Tempest\Validation\Rules\IsEnum; use Tempest\Validation\Tests\Rules\Fixtures\SomeBackedEnum; use Tempest\Validation\Tests\Rules\Fixtures\SomeEnum; @@ -38,6 +39,23 @@ public function validating_backed_enums(): void $this->assertTrue($rule->isValid('two')); } + #[Test] + public function validating_values_with_non_matching_types(): void + { + $backed = new IsEnum(SomeBackedEnum::class); + + $this->assertFalse($backed->isValid(5)); + $this->assertFalse($backed->isValid(1.5)); + $this->assertFalse($backed->isValid(true)); + $this->assertFalse($backed->isValid([])); + $this->assertFalse($backed->isValid(new stdClass())); + + $pure = new IsEnum(SomeEnum::class); + + $this->assertFalse($pure->isValid(5)); + $this->assertFalse($pure->isValid([])); + } + #[Test] public function enum_has_to_exist(): void { diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php index e52dfcc63..f75538533 100644 --- a/src/Tempest/Framework/Testing/IntegrationTest.php +++ b/src/Tempest/Framework/Testing/IntegrationTest.php @@ -32,6 +32,7 @@ use Tempest\Http\Request; use Tempest\Mail\Testing\MailTester; use Tempest\Mail\Testing\TestingMailer; +use Tempest\Mcp\Testing\McpTester; use Tempest\Process\Testing\ProcessTester; use Tempest\Storage\Testing\StorageTester; use Throwable; @@ -118,6 +119,11 @@ abstract class IntegrationTest extends TestCase */ protected ViewTester $view; + /** + * Provides utilities for testing MCP servers. + */ + protected McpTester $mcp; + protected function setUp(): void { parent::setUp(); @@ -198,6 +204,7 @@ protected function setupTesters(): self $this->oauth = new OAuthTester($this->container); $this->database = new DatabaseTester($this->container); $this->view = new ViewTester($this->container); + $this->mcp = new McpTester($this->container); return $this; } diff --git a/tests/Fixtures/Mcp/AbstractMcpTools.php b/tests/Fixtures/Mcp/AbstractMcpTools.php new file mode 100644 index 000000000..7d847e9d0 --- /dev/null +++ b/tests/Fixtures/Mcp/AbstractMcpTools.php @@ -0,0 +1,16 @@ +value) . ", {$name}!"; + } + + #[McpTool] + public function rate( + #[IsBetween(min: 1, max: 5)] + int $rating, + ): string { + return "You rated {$rating} stars"; + } + + #[McpTool] + public function report(): array + { + return ['status' => 'ok', 'uptime' => 123]; + } + + #[McpTool] + public function fail(): string + { + throw new Exception('Something went wrong in the tool'); + } + + #[McpTool] + public function shout(string $phrase, StringManipulator $manipulator): string + { + return $manipulator->upper($phrase); + } + + #[McpTool] + public function multi(): array + { + return [new Text('first'), new Text('second')]; + } + + #[McpTool] + public function window(?int $limit = 10): string + { + return 'limit: ' . var_export($limit, true); + } + + #[McpTool] + public function tally(array $entries): int + { + return count($entries); + } + + #[McpPrompt(description: 'Generates a code review prompt')] + public function reviewCode(string $code): string + { + return "Review the following code: {$code}"; + } + + #[McpResource(uri: 'demo://config', description: 'The demo configuration', mimeType: 'application/json')] + public function config(): array + { + return ['env' => 'testing']; + } + + #[McpResource(uri: 'demo://users/{id}')] + public function user(int $id): string + { + return "User #{$id}"; + } + + #[McpResource(uri: 'demo://logo')] + public function logo(): Blob + { + return new Blob('logo-bytes'); + } + + #[McpPrompt] + public function failingPrompt(): string + { + throw new Exception('Prompt failure'); + } +} diff --git a/tests/Fixtures/Mcp/ExtendedMcpServer.php b/tests/Fixtures/Mcp/ExtendedMcpServer.php new file mode 100644 index 000000000..14e1b0558 --- /dev/null +++ b/tests/Fixtures/Mcp/ExtendedMcpServer.php @@ -0,0 +1,7 @@ +container->get(McpConfig::class); + + $demo = $config->servers[DemoMcpServer::class]; + + $this->assertSame('demo-mcp-server', $demo->name); + $this->assertSame('1.2.3', $demo->version); + $this->assertSame('A demo MCP server for testing.', $demo->instructions); + $this->assertSame('/mcp/demo', $demo->path); + + $stdio = $config->servers[StdioMcpServer::class]; + + $this->assertSame('stdio-demo', $stdio->name); + $this->assertSame('0.0.1', $stdio->version); + $this->assertNull($stdio->instructions); + $this->assertNull($stdio->path); + } + + #[Test] + public function discovers_primitives_on_server_classes(): void + { + $config = $this->container->get(McpConfig::class); + + $demo = $config->servers[DemoMcpServer::class]; + + $this->assertArrayHasKey('add', $demo->tools); + $this->assertArrayHasKey('greet', $demo->tools); + $this->assertArrayHasKey('rate', $demo->tools); + $this->assertSame('Adds two numbers', $demo->tools['add']->description); + + $this->assertArrayHasKey('review_code', $demo->prompts); + $this->assertSame('Generates a code review prompt', $demo->prompts['review_code']->description); + + $this->assertArrayHasKey('demo://config', $demo->resources); + $this->assertArrayHasKey('demo://logo', $demo->resources); + $this->assertArrayHasKey('demo://users/{id}', $demo->resourceTemplates); + $this->assertSame('application/json', $demo->resources['demo://config']->mimeType); + $this->assertSame('user', $demo->resourceTemplates['demo://users/{id}']->name); + } + + #[Test] + public function discovers_external_primitives(): void + { + $config = $this->container->get(McpConfig::class); + + $demo = $config->servers[DemoMcpServer::class]; + + $this->assertArrayHasKey('repeat', $demo->tools); + $this->assertSame('Repeats a message', $demo->tools['repeat']->description); + } + + #[Test] + public function servers_inherit_primitives_from_parent_classes(): void + { + $config = $this->container->get(McpConfig::class); + + $base = $config->servers[BaseMcpServer::class]; + + $this->assertEqualsCanonicalizing(['base_tool', 'shared'], array_keys($base->tools)); + $this->assertSame(BaseMcpServer::class, $base->tools['shared']->class); + + $inherited = $config->servers[InheritedMcpServer::class]; + + $this->assertEqualsCanonicalizing(['base_tool', 'own_tool', 'shared'], array_keys($inherited->tools)); + $this->assertSame(InheritedMcpServer::class, $inherited->tools['base_tool']->class); + } + + #[Test] + public function subclasses_without_the_server_attribute_are_not_servers(): void + { + $config = $this->container->get(McpConfig::class); + + $this->assertArrayNotHasKey(ExtendedMcpServer::class, $config->servers); + $this->assertArrayNotHasKey(AbstractMcpTools::class, $config->servers); + } + + #[Test] + public function servers_are_resolvable_by_name_and_path(): void + { + $config = $this->container->get(McpConfig::class); + + $this->assertSame(DemoMcpServer::class, $config->getServerByName('demo-mcp-server')?->class); + $this->assertSame(DemoMcpServer::class, $config->getServerByPath('/mcp/demo')?->class); + $this->assertSame(StdioMcpServer::class, $config->getServerByName('stdio-demo')?->class); + $this->assertNull($config->getServerByName('unknown')); + $this->assertNull($config->getServerByPath('/unknown')); + } +} diff --git a/tests/Integration/Mcp/McpHttpTest.php b/tests/Integration/Mcp/McpHttpTest.php new file mode 100644 index 000000000..4ebc84bff --- /dev/null +++ b/tests/Integration/Mcp/McpHttpTest.php @@ -0,0 +1,109 @@ +post(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + + $response->assertOk()->assertHeaderContains('Content-Type', 'application/json'); + + $body = $this->body($response); + + $this->assertSame('2.0', $body['jsonrpc']); + $this->assertSame(1, $body['id']); + $this->assertContains('add', array_column($body['result']['tools'], 'name')); + } + + #[Test] + public function trailing_slashes_resolve_the_same_server(): void + { + $response = $this->http->post('/mcp/demo/', encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping']), headers: ['Content-Type' => 'application/json']); + + $response->assertOk(); + + $body = $this->body($response); + + $this->assertSame(1, $body['id']); + $this->assertArrayHasKey('result', $body); + } + + #[Test] + public function non_post_requests_are_method_not_allowed(): void + { + $this->http->get('/mcp/demo')->assertStatus(Status::METHOD_NOT_ALLOWED); + $this->http->delete('/mcp/demo')->assertStatus(Status::METHOD_NOT_ALLOWED); + } + + #[Test] + public function calls_a_tool_over_http(): void + { + $response = $this->post([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'add', + 'arguments' => ['a' => 20, 'b' => 22], + ], + ]); + + $response->assertOk(); + + $this->assertSame('42', $this->body($response)['result']['content'][0]['text']); + } + + #[Test] + public function notifications_are_accepted_without_content(): void + { + $this + ->post(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']) + ->assertStatus(Status::ACCEPTED); + } + + #[Test] + public function invalid_json_is_a_parse_error(): void + { + $response = $this->http->post('/mcp/demo', 'not json', headers: ['Content-Type' => 'application/json']); + + $response->assertOk(); + + $this->assertSame(-32_700, $this->body($response)['error']['code']); + } + + #[Test] + public function servers_without_a_path_have_no_route(): void + { + $this->http + ->post('/mcp/stdio-demo', encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'])) + ->assertNotFound(); + } + + private function post(array $message): TestResponseHelper + { + return $this->http->post('/mcp/demo', encode($message), headers: ['Content-Type' => 'application/json']); + } + + private function body(TestResponseHelper $response): array + { + $body = $response->response->body; + + return is_string($body) ? decode($body) : $body; + } +} diff --git a/tests/Integration/Mcp/McpProtocolTest.php b/tests/Integration/Mcp/McpProtocolTest.php new file mode 100644 index 000000000..ea4fc3ba5 --- /dev/null +++ b/tests/Integration/Mcp/McpProtocolTest.php @@ -0,0 +1,547 @@ +mcp->onServer(DemoMcpServer::class); + + $response = $connection->send('initialize', [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => [], + 'clientInfo' => ['name' => 'test', 'version' => '1.0.0'], + ]); + + $response->assertOk(); + + $this->assertSame('2025-06-18', $response->result()['protocolVersion']); + } + + #[Test] + public function initialize_falls_back_to_the_latest_version_for_unsupported_versions(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $response = $connection->send('initialize', [ + 'protocolVersion' => '1999-01-01', + ]); + + $this->assertSame('2025-11-25', $response->result()['protocolVersion']); + } + + #[Test] + public function initialize_describes_the_server(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $result = $connection->send('initialize', ['protocolVersion' => '2025-11-25'])->result(); + + $this->assertSame(['name' => 'demo-mcp-server', 'version' => '1.2.3'], $result['serverInfo']); + $this->assertSame('A demo MCP server for testing.', $result['instructions']); + $this->assertSame(['listChanged' => false], $result['capabilities']['tools']); + $this->assertSame(['listChanged' => false], $result['capabilities']['resources']); + $this->assertSame(['listChanged' => false], $result['capabilities']['prompts']); + } + + #[Test] + public function ping_returns_an_empty_result(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $response = $connection->send('ping'); + + $response->assertOk(); + + $this->assertSame([], $response->result()); + } + + #[Test] + public function lists_tools_with_their_input_schemas(): void + { + $response = $this->mcp->onServer(DemoMcpServer::class)->listTools(); + + $response + ->assertOk() + ->assertToolListed('add') + ->assertToolListed('greet') + ->assertToolListed('repeat'); + + $tools = array_column($response->result()['tools'], null, 'name'); + + $this->assertSame('Adds two numbers', $tools['add']['description']); + $this->assertSame( + [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'integer'], + 'b' => ['type' => 'integer'], + ], + 'required' => ['a', 'b'], + ], + $tools['add']['inputSchema'], + ); + + $this->assertSame( + [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the person to greet', + ], + 'greeting' => [ + 'type' => 'string', + 'enum' => ['hello', 'goodbye'], + 'default' => 'hello', + ], + ], + 'required' => ['name'], + ], + $tools['greet']['inputSchema'], + ); + + $this->assertSame( + [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 5, + ], + $tools['rate']['inputSchema']['properties']['rating'], + ); + + $this->assertSame(['type' => 'object', 'properties' => []], [ + 'type' => $tools['multi']['inputSchema']['type'], + 'properties' => $tools['multi']['inputSchema']['properties'], + ]); + } + + #[Test] + public function calls_a_tool(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('add', ['a' => 1, 'b' => 2]) + ->assertOk() + ->assertText('3'); + } + + #[Test] + public function casts_enum_arguments_and_applies_defaults(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $connection + ->callTool('greet', ['name' => 'Brent']) + ->assertOk() + ->assertText('Hello, Brent!'); + + $connection + ->callTool('greet', ['name' => 'Brent', 'greeting' => 'goodbye']) + ->assertOk() + ->assertText('Goodbye, Brent!'); + } + + #[Test] + public function injects_services_into_tool_handlers(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('shout', ['phrase' => 'quiet']) + ->assertOk() + ->assertText('QUIET'); + } + + #[Test] + public function explicit_null_arguments_are_preserved(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $connection->callTool('window')->assertOk()->assertText('limit: 10'); + $connection->callTool('window', ['limit' => 5])->assertOk()->assertText('limit: 5'); + $connection->callTool('window', ['limit' => null])->assertOk()->assertText('limit: NULL'); + } + + #[Test] + public function array_arguments_are_validated(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $connection + ->callTool('tally', ['entries' => ['a', 'b']]) + ->assertOk() + ->assertText('2'); + $connection->callTool('tally', ['entries' => 'nope'])->assertError('must be an array'); + $connection->callTool('tally', ['entries' => null])->assertError('must be an array'); + } + + #[Test] + public function inherited_tools_are_callable_on_extending_servers(): void + { + $connection = $this->mcp->onServer(InheritedMcpServer::class); + + $connection->callTool('base_tool')->assertOk()->assertText('base'); + $connection->callTool('shared')->assertOk()->assertText('shared'); + $connection->callTool('own_tool')->assertOk()->assertText('own'); + } + + #[Test] + public function returns_structured_content_for_array_results(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('report') + ->assertOk() + ->assertStructured(['status' => 'ok', 'uptime' => 123]) + ->assertTextContains('"status"'); + } + + #[Test] + public function returns_multiple_content_items(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('multi') + ->assertOk() + ->assertText('first') + ->assertText('second'); + } + + #[Test] + public function tool_exceptions_become_error_results(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('fail') + ->assertError('Something went wrong in the tool'); + + $this->exceptions->assertProcessed(Exception::class); + } + + #[Test] + public function validation_failures_become_error_results(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('rate', ['rating' => 10]) + ->assertError('rating must be between 1 and 5'); + } + + #[Test] + public function external_tools_are_invoked_through_the_container(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('repeat', ['message' => 'echo']) + ->assertOk() + ->assertText('echo'); + } + + #[Test] + public function prompt_handler_exceptions_are_internal_errors(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->getPrompt('failing_prompt'); + + $response->assertError('An internal error occurred'); + + $this->assertSame(-32_603, $response->error()['code']); + + $this->exceptions->assertProcessed(Exception::class); + } + + #[Test] + public function non_object_arguments_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->send('tools/call', ['name' => 'add', 'arguments' => [1, 2]]); + + $response->assertError('The `arguments` member must be an object'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function valid_arguments_pass_validation(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('rate', ['rating' => 4]) + ->assertOk() + ->assertText('You rated 4 stars'); + } + + #[Test] + public function missing_required_arguments_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('add', ['a' => 1]); + + $response->assertError('The required argument `b` is missing'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function unknown_arguments_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('add', ['a' => 1, 'b' => 2, 'c' => 3]); + + $response->assertError('The argument `c` is unknown'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function missing_tool_names_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->send('tools/call'); + + $response->assertError('The required argument `name` is missing'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function non_string_tool_names_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->send('tools/call', ['name' => 42]); + + $response->assertError('The argument `name` must be a string'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function unknown_tools_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('nope'); + + $response->assertError('The tool `nope` does not exist'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function unknown_methods_are_method_not_found(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->send('completion/complete'); + + $response->assertError('The method `completion/complete` is not supported'); + + $this->assertSame(-32_601, $response->error()['code']); + } + + #[Test] + public function lists_resources_and_resource_templates(): void + { + $connection = $this->mcp->onServer(DemoMcpServer::class); + + $resources = $connection->listResources()->assertOk()->result()['resources']; + $resourceUris = array_column($resources, 'uri'); + + $this->assertContains('demo://config', $resourceUris); + $this->assertContains('demo://logo', $resourceUris); + $this->assertNotContains('demo://users/{id}', $resourceUris); + + $templates = $connection->listResourceTemplates()->assertOk()->result()['resourceTemplates']; + + $this->assertSame('demo://users/{id}', $templates[0]['uriTemplate']); + $this->assertSame('user', $templates[0]['name']); + } + + #[Test] + public function reads_a_resource(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->readResource('demo://config'); + + $response->assertOk(); + + [$contents] = $response->result()['contents']; + + $this->assertSame('demo://config', $contents['uri']); + $this->assertSame('application/json', $contents['mimeType']); + $this->assertSame('{"env":"testing"}', $contents['text']); + } + + #[Test] + public function reads_a_templated_resource(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->readResource('demo://users/42'); + + $response->assertOk()->assertText('User #42'); + + $this->assertSame('demo://users/42', $response->result()['contents'][0]['uri']); + $this->assertSame('text/plain', $response->result()['contents'][0]['mimeType']); + } + + #[Test] + public function reads_a_blob_resource(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->readResource('demo://logo'); + + $response->assertOk(); + + [$contents] = $response->result()['contents']; + + $this->assertSame(base64_encode('logo-bytes'), $contents['blob']); + $this->assertSame('application/octet-stream', $contents['mimeType']); + } + + #[Test] + public function unknown_resources_are_resource_not_found(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->readResource('demo://nope'); + + $response->assertError('The resource `demo://nope` does not exist'); + + $this->assertSame(-32_002, $response->error()['code']); + } + + #[Test] + public function lists_prompts_with_their_arguments(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->listPrompts(); + + $response->assertOk(); + + $prompts = $response->result()['prompts']; + + $this->assertSame('review_code', $prompts[0]['name']); + $this->assertSame('Generates a code review prompt', $prompts[0]['description']); + $this->assertSame([['name' => 'code', 'required' => true]], $prompts[0]['arguments']); + } + + #[Test] + public function gets_a_prompt(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->getPrompt('review_code', ['code' => 'echo 1;']); + + $response->assertOk()->assertText('Review the following code: echo 1;'); + + $this->assertSame('user', $response->result()['messages'][0]['role']); + $this->assertSame('text', $response->result()['messages'][0]['content']['type']); + } + + #[Test] + public function unknown_prompts_are_invalid_params(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->getPrompt('nope'); + + $response->assertError('The prompt `nope` does not exist'); + + $this->assertSame(-32_602, $response->error()['code']); + } + + #[Test] + public function malformed_json_is_a_parse_error(): void + { + $response = $this->handler()->handle($this->server(), 'not json'); + + $this->assertSame(-32_700, $response->toArray()['error']['code']); + } + + #[Test] + public function batches_are_rejected(): void + { + $batch = encode([ + ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'], + ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'ping'], + ]); + + $response = $this->handler()->handle($this->server(), $batch); + + $this->assertSame(-32_600, $response->toArray()['error']['code']); + } + + #[Test] + public function invalid_jsonrpc_versions_are_rejected(): void + { + $response = $this->handler()->handle($this->server(), encode([ + 'jsonrpc' => '1.0', + 'id' => 1, + 'method' => 'ping', + ])); + + $this->assertSame(-32_600, $response->toArray()['error']['code']); + } + + #[Test] + public function explicit_null_ids_are_rejected(): void + { + $response = $this->handler()->handle($this->server(), encode([ + 'jsonrpc' => '2.0', + 'id' => null, + 'method' => 'ping', + ])); + + $this->assertSame(-32_600, $response->toArray()['error']['code']); + $this->assertNull($response->toArray()['id']); + } + + #[Test] + public function notifications_produce_no_response(): void + { + $response = $this->handler()->handle($this->server(), encode([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/cancelled', + ])); + + $this->assertNull($response); + } + + private function handler(): McpRequestHandler + { + return $this->container->get(McpRequestHandler::class); + } + + private function server(): McpServerDefinition + { + return $this->container->get(McpConfig::class)->servers[DemoMcpServer::class]; + } +} diff --git a/tests/Integration/Mcp/McpStdioTest.php b/tests/Integration/Mcp/McpStdioTest.php new file mode 100644 index 000000000..061d7c07e --- /dev/null +++ b/tests/Integration/Mcp/McpStdioTest.php @@ -0,0 +1,73 @@ + '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-11-25']]) . PHP_EOL); + fwrite($input, encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']) . PHP_EOL); + fwrite($input, encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'pong']]) . PHP_EOL); + rewind($input); + + $server = $this->container->get(McpConfig::class)->servers[StdioMcpServer::class]; + + $this->container->get(StdioTransport::class)->run($server, $input, $output); + + rewind($output); + + $responses = array_values(array_filter(explode(PHP_EOL, stream_get_contents($output)))); + + $this->assertCount(2, $responses); + + $initialize = decode($responses[0]); + + $this->assertSame(1, $initialize['id']); + $this->assertSame('stdio-demo', $initialize['result']['serverInfo']['name']); + + $call = decode($responses[1]); + + $this->assertSame(2, $call['id']); + $this->assertSame('pong', $call['result']['content'][0]['text']); + } + + #[Test] + public function serve_command_fails_for_unknown_servers(): void + { + $this->console + ->call('mcp:serve', ['server' => 'nope']) + ->assertExitCode(ExitCode::INVALID) + ->assertContains('There is no MCP server named `nope`'); + } + + #[Test] + public function list_command_shows_discovered_servers(): void + { + $this->console + ->call('mcp:list') + ->assertSuccess() + ->assertContains('demo-mcp-server') + ->assertContains('stdio-demo') + ->assertContains('http (/mcp/demo)'); + } +} diff --git a/tests/Integration/Mcp/McpTesterTest.php b/tests/Integration/Mcp/McpTesterTest.php new file mode 100644 index 000000000..89cb2e879 --- /dev/null +++ b/tests/Integration/Mcp/McpTesterTest.php @@ -0,0 +1,78 @@ +mcp->onServer(DemoMcpServer::class)->send('ping')->assertOk(); + $this->mcp->onServer('demo-mcp-server')->send('ping')->assertOk(); + } + + #[Test] + public function fails_for_unknown_servers(): void + { + $this->expectException(McpServerWasNotFound::class); + $this->expectExceptionMessage('There is no MCP server named `unknown-server`.'); + + $this->mcp->onServer('unknown-server'); + } + + #[Test] + public function fails_for_classes_without_the_server_attribute(): void + { + $this->expectException(McpServerWasNotFound::class); + $this->expectExceptionMessage('The class `' . self::class . '` is not annotated with `#[McpServer]`.'); + + $this->mcp->onServer(self::class); + } + + #[Test] + public function assertions_detect_content(): void + { + $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('greet', ['name' => 'Brent']) + ->assertOk() + ->assertText('Hello, Brent!') + ->assertTextContains('Brent') + ->assertSee('Hello'); + } + + #[Test] + public function assert_ok_fails_on_tool_errors(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('fail'); + + $this->expectException(AssertionFailedError::class); + + $response->assertOk(); + } + + #[Test] + public function assert_error_fails_on_successful_calls(): void + { + $response = $this->mcp + ->onServer(DemoMcpServer::class) + ->callTool('add', ['a' => 1, 'b' => 2]); + + $this->expectException(AssertionFailedError::class); + + $response->assertError(); + } +}