From 952cd2420bc1353dc44b50ca379796ba65698cea Mon Sep 17 00:00:00 2001 From: Sapayth Hossain Date: Sun, 7 Sep 2025 01:21:32 +0600 Subject: [PATCH 01/66] Update README for clarity and consistency (#38) Improved capitalization and wording in the roadmap and credits sections for better readability and consistency. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ccbf750d..95019d77 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Until the first major release, this SDK is considered ## 🚧 Roadmap Features -- [x] bring back php-mcp examples +- [x] Bring back PHP-MCP examples - [ ] Glue handler, registry and reference handlers - [ ] Revive `ServerBuilder` - [ ] Revive transports @@ -29,7 +29,7 @@ Features - [ ] Http/SSE-based Transport https://github.com/modelcontextprotocol/php-sdk/issues/8 - [ ] Support pagination - [ ] Support Schema validation -- [ ] Support Multiple Versions of MCP Specification https://github.com/modelcontextprotocol/php-sdk/issues/14 +- [ ] Support multiple versions of the MCP specification https://github.com/modelcontextprotocol/php-sdk/issues/14 - [ ] (Re-)Implement missing Notification & Request Handlers https://github.com/modelcontextprotocol/php-sdk/issues/9 --- @@ -143,7 +143,7 @@ the project. See the [contributing guide](CONTRIBUTING.md) to get started before [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits -The starting point for this SDK was [PHP-MCP](https://github.com/php-mcp/server) project, initiated by [Kyrian Obikwelu](https://github.com/CodeWithKyrian). We are grateful for the work done by Kyrian and other contributors to that repository, which created a solid foundation for this SDK. +The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by [Kyrian Obikwelu](https://github.com/CodeWithKyrian). We are grateful for the work done by Kyrian and other contributors to that repository, which created a solid foundation for this SDK. ## License From f457802371c8eda6782f5f99d48e96db463fa46a Mon Sep 17 00:00:00 2001 From: Adebayo120 <54323098+Adebayo120@users.noreply.github.com> Date: Sat, 6 Sep 2025 21:34:51 +0100 Subject: [PATCH 02/66] Updated README.md to clarify grammar in "Important" section (#41) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 95019d77..84db63ea 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ The official PHP SDK for Model Context Protocol (MCP). It provides a framework-a > [!IMPORTANT] > Currently, we are still in the process of merging [Symfony's MCP SDK](https://github.com/symfony/mcp-sdk) and -> [PHP-MCP](https://github.com/php-mcp) components. Not all code paths are fully tested, complete, or this package -> may contain duplicate functionality or dead code. +> [PHP-MCP](https://github.com/php-mcp) components. Not all code paths are fully tested or complete, and this package +> may still contain duplicate functionality or dead code. > > If you want to help us stabilize the SDK, please see the > [issue tracker](https://github.com/modelcontextprotocol/php-sdk/issues). From 4b667327e7a2516c81951b55c19488bfc469e52a Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Sun, 7 Sep 2025 01:47:53 +0200 Subject: [PATCH 03/66] Remove --no-scripts option from composer install (#30) --- .github/workflows/pipeline.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index e40cf0d6..98e173a3 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -30,9 +30,6 @@ jobs: with: dependency-versions: "${{ matrix.dependencies }}" - - name: Install PHP Dependencies - run: composer install --no-scripts - - name: Tests run: vendor/bin/phpunit --exclude-group inspector @@ -54,9 +51,6 @@ jobs: - name: Install Composer uses: "ramsey/composer-install@v3" - - name: Install PHP Dependencies - run: composer install --no-scripts - - name: Code Style PHP run: vendor/bin/php-cs-fixer fix --dry-run From 20e789d4024119ae3cf3868a59c1c6e0601cb39c Mon Sep 17 00:00:00 2001 From: Igor Markin Date: Tue, 9 Sep 2025 19:22:29 +0300 Subject: [PATCH 04/66] Fix return type in abstract methods from `self` to `static` (#53) * Change return type of abstract request methods * Change return type of `fromParams` method to `static` in request classes (cherry picked from commit 94799ab66ff60d4b2305aadb6b73aaa4c805221e) * Make request classes final and update `getParams` return types for improved type safety (cherry picked from commit d6be496f89cc0e6d30f519792d0dc902685c4178) --- src/Schema/JsonRpc/Request.php | 6 +++--- src/Schema/Request/CallToolRequest.php | 9 ++++++--- .../Request/CompletionCompleteRequest.php | 12 +++++++++--- .../Request/CreateSamplingMessageRequest.php | 18 +++++++++++++++--- src/Schema/Request/GetPromptRequest.php | 9 ++++++--- src/Schema/Request/InitializeRequest.php | 9 ++++++--- src/Schema/Request/ListPromptsRequest.php | 7 +++++-- .../Request/ListResourceTemplatesRequest.php | 7 +++++-- src/Schema/Request/ListResourcesRequest.php | 7 +++++-- src/Schema/Request/ListRootsRequest.php | 4 ++-- src/Schema/Request/ListToolsRequest.php | 7 +++++-- src/Schema/Request/PingRequest.php | 4 ++-- src/Schema/Request/ReadResourceRequest.php | 11 +++++++---- .../Request/ResourceSubscribeRequest.php | 11 +++++++---- .../Request/ResourceUnsubscribeRequest.php | 11 +++++++---- src/Schema/Request/SetLogLevelRequest.php | 9 ++++++--- tests/Schema/JsonRpc/RequestTest.php | 2 +- 17 files changed, 97 insertions(+), 46 deletions(-) diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index a4d0c0f4..22083c75 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -36,7 +36,7 @@ abstract public static function getMethod(): string; /** * @param RequestData $data */ - public static function fromArray(array $data): self + public static function fromArray(array $data): static { if (($data['jsonrpc'] ?? null) !== MessageInterface::JSONRPC_VERSION) { throw new InvalidArgumentException('Invalid or missing "jsonrpc" version for Request.'); @@ -68,7 +68,7 @@ public static function fromArray(array $data): self /** * @param array|null $params */ - abstract protected static function fromParams(?array $params): self; + abstract protected static function fromParams(?array $params): static; public function getId(): string|int { @@ -97,7 +97,7 @@ public function jsonSerialize(): array } /** - * @return array|null + * @return array|null */ abstract protected function getParams(): ?array; } diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 0bc7fcf3..49bcd0d8 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -19,7 +19,7 @@ * * @author Kyrian Obikwelu */ -class CallToolRequest extends Request +final class CallToolRequest extends Request { /** * @param string $name the name of the tool to invoke @@ -36,7 +36,7 @@ public static function getMethod(): string return 'tools/call'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['name']) || !\is_string($params['name'])) { throw new InvalidArgumentException('Missing or invalid "name" parameter for tools/call.'); @@ -58,7 +58,10 @@ protected static function fromParams(?array $params): Request ); } - protected function getParams(): ?array + /** + * @return array{name: string, arguments: array} + */ + protected function getParams(): array { return [ 'name' => $this->name, diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php index 324b0041..467bb97c 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -21,7 +21,7 @@ * * @author Kyrian Obikwelu */ -class CompletionCompleteRequest extends Request +final class CompletionCompleteRequest extends Request { /** * @param PromptReference|ResourceReference $ref the prompt or resource to complete @@ -38,7 +38,7 @@ public static function getMethod(): string return 'completion/complete'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['ref']) || !\is_array($params['ref'])) { throw new InvalidArgumentException('Missing or invalid "ref" parameter for completion/complete.'); @@ -57,7 +57,13 @@ protected static function fromParams(?array $params): Request return new self($ref, $params['argument']); } - protected function getParams(): ?array + /** + * @return array{ + * ref: PromptReference|ResourceReference, + * argument: array{ name: string, value: string } + * } + */ + protected function getParams(): array { return [ 'ref' => $this->ref, diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index ae9ea39c..f07a632b 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -23,7 +23,7 @@ * * @author Kyrian Obikwelu */ -class CreateSamplingMessageRequest extends Request +final class CreateSamplingMessageRequest extends Request { /** * @param SamplingMessage[] $messages the messages to send to the model @@ -59,7 +59,7 @@ public static function getMethod(): string return 'sampling/createMessage'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['messages']) || !\is_array($params['messages'])) { throw new InvalidArgumentException('Missing or invalid "messages" parameter for sampling/createMessage.'); @@ -86,7 +86,19 @@ protected static function fromParams(?array $params): Request ); } - protected function getParams(): ?array + /** + * @return array{ + * messages: SamplingMessage[], + * maxTokens: int, + * preferences?: ModelPreferences, + * systemPrompt?: string, + * includeContext?: string, + * temperature?: float, + * stopSequences?: string[], + * metadata?: array + * } + */ + protected function getParams(): array { $params = [ 'messages' => $this->messages, diff --git a/src/Schema/Request/GetPromptRequest.php b/src/Schema/Request/GetPromptRequest.php index 68954e9c..bc6903f3 100644 --- a/src/Schema/Request/GetPromptRequest.php +++ b/src/Schema/Request/GetPromptRequest.php @@ -19,7 +19,7 @@ * * @author Kyrian Obikwelu */ -class GetPromptRequest extends Request +final class GetPromptRequest extends Request { /** * @param string $name the name of the prompt to get @@ -36,7 +36,7 @@ public static function getMethod(): string return 'prompts/get'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['name']) || !\is_string($params['name']) || empty($params['name'])) { throw new InvalidArgumentException('Missing or invalid "name" parameter for prompts/get.'); @@ -55,7 +55,10 @@ protected static function fromParams(?array $params): Request return new self($params['name'], $arguments); } - protected function getParams(): ?array + /** + * @return array{name: string, arguments?: array} + */ + protected function getParams(): array { $params = ['name' => $this->name]; diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php index 702aa7b3..04db317c 100644 --- a/src/Schema/Request/InitializeRequest.php +++ b/src/Schema/Request/InitializeRequest.php @@ -21,7 +21,7 @@ * * @author Kyrian Obikwelu */ -class InitializeRequest extends Request +final class InitializeRequest extends Request { /** * @param string $protocolVersion The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. @@ -40,7 +40,7 @@ public static function getMethod(): string return 'initialize'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['protocolVersion'])) { throw new InvalidArgumentException('protocolVersion is required'); @@ -59,7 +59,10 @@ protected static function fromParams(?array $params): Request return new self($params['protocolVersion'], $capabilities, $clientInfo); } - protected function getParams(): ?array + /** + * @return array{protocolVersion: string, capabilities: ClientCapabilities, clientInfo: Implementation} + */ + protected function getParams(): array { return [ 'protocolVersion' => $this->protocolVersion, diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php index 5f388d66..8c627999 100644 --- a/src/Schema/Request/ListPromptsRequest.php +++ b/src/Schema/Request/ListPromptsRequest.php @@ -18,7 +18,7 @@ * * @author Kyrian Obikwelu */ -class ListPromptsRequest extends Request +final class ListPromptsRequest extends Request { /** * If provided, the server should return results starting after this cursor. @@ -35,11 +35,14 @@ public static function getMethod(): string return 'prompts/list'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self($params['cursor'] ?? null); } + /** + * @return array{cursor:string}|null + */ protected function getParams(): ?array { $params = []; diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php index bf6da924..4ce4dfd9 100644 --- a/src/Schema/Request/ListResourceTemplatesRequest.php +++ b/src/Schema/Request/ListResourceTemplatesRequest.php @@ -18,7 +18,7 @@ * * @author Kyrian Obikwelu */ -class ListResourceTemplatesRequest extends Request +final class ListResourceTemplatesRequest extends Request { /** * @param string|null $cursor An opaque token representing the current pagination position. @@ -35,11 +35,14 @@ public static function getMethod(): string return 'resources/templates/list'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self($params['cursor'] ?? null); } + /** + * @return array{cursor:string}|null + */ protected function getParams(): ?array { $params = []; diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php index 85527dff..30c00418 100644 --- a/src/Schema/Request/ListResourcesRequest.php +++ b/src/Schema/Request/ListResourcesRequest.php @@ -18,7 +18,7 @@ * * @author Kyrian Obikwelu */ -class ListResourcesRequest extends Request +final class ListResourcesRequest extends Request { /** * @param string|null $cursor An opaque token representing the current pagination position. @@ -35,11 +35,14 @@ public static function getMethod(): string return 'resources/list'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self($params['cursor'] ?? null); } + /** + * @return array{cursor:string}|null + */ protected function getParams(): ?array { $params = []; diff --git a/src/Schema/Request/ListRootsRequest.php b/src/Schema/Request/ListRootsRequest.php index 3e8fd9fe..35c8a009 100644 --- a/src/Schema/Request/ListRootsRequest.php +++ b/src/Schema/Request/ListRootsRequest.php @@ -24,7 +24,7 @@ * * @author Kyrian Obikwelu */ -class ListRootsRequest extends Request +final class ListRootsRequest extends Request { public function __construct( ) { @@ -35,7 +35,7 @@ public static function getMethod(): string return 'roots/list'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self(); } diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php index 56ed591a..00af2863 100644 --- a/src/Schema/Request/ListToolsRequest.php +++ b/src/Schema/Request/ListToolsRequest.php @@ -18,7 +18,7 @@ * * @author Kyrian Obikwelu */ -class ListToolsRequest extends Request +final class ListToolsRequest extends Request { /** * @param string|null $cursor An opaque token representing the current pagination position. @@ -35,11 +35,14 @@ public static function getMethod(): string return 'tools/list'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self($params['cursor'] ?? null); } + /** + * @return array{cursor:string}|null + */ protected function getParams(): ?array { $params = []; diff --git a/src/Schema/Request/PingRequest.php b/src/Schema/Request/PingRequest.php index 13e13202..31fe64bb 100644 --- a/src/Schema/Request/PingRequest.php +++ b/src/Schema/Request/PingRequest.php @@ -19,14 +19,14 @@ * * @author Kyrian Obikwelu */ -class PingRequest extends Request +final class PingRequest extends Request { public static function getMethod(): string { return 'ping'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { return new self(); } diff --git a/src/Schema/Request/ReadResourceRequest.php b/src/Schema/Request/ReadResourceRequest.php index 4aadbe8d..69523eff 100644 --- a/src/Schema/Request/ReadResourceRequest.php +++ b/src/Schema/Request/ReadResourceRequest.php @@ -19,10 +19,10 @@ * * @author Kyrian Obikwelu */ -class ReadResourceRequest extends Request +final class ReadResourceRequest extends Request { /** - * @param string $uri the URI of the resource to read + * @param non-empty-string $uri the URI of the resource to read */ public function __construct( public readonly string $uri, @@ -34,7 +34,7 @@ public static function getMethod(): string return 'resources/read'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/read.'); @@ -43,7 +43,10 @@ protected static function fromParams(?array $params): Request return new self($params['uri']); } - protected function getParams(): ?array + /** + * @return array{uri: non-empty-string} + */ + protected function getParams(): array { return [ 'uri' => $this->uri, diff --git a/src/Schema/Request/ResourceSubscribeRequest.php b/src/Schema/Request/ResourceSubscribeRequest.php index 0b17b6c1..036785a2 100644 --- a/src/Schema/Request/ResourceSubscribeRequest.php +++ b/src/Schema/Request/ResourceSubscribeRequest.php @@ -20,10 +20,10 @@ * * @author Kyrian Obikwelu */ -class ResourceSubscribeRequest extends Request +final class ResourceSubscribeRequest extends Request { /** - * @param string $uri the URI of the resource to subscribe to + * @param non-empty-string $uri the URI of the resource to subscribe to */ public function __construct( public readonly string $uri, @@ -35,7 +35,7 @@ public static function getMethod(): string return 'resources/subscribe'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/subscribe.'); @@ -44,7 +44,10 @@ protected static function fromParams(?array $params): Request return new self($params['uri']); } - protected function getParams(): ?array + /** + * @return array{uri: non-empty-string} + */ + protected function getParams(): array { return ['uri' => $this->uri]; } diff --git a/src/Schema/Request/ResourceUnsubscribeRequest.php b/src/Schema/Request/ResourceUnsubscribeRequest.php index 54349ae8..fd93727b 100644 --- a/src/Schema/Request/ResourceUnsubscribeRequest.php +++ b/src/Schema/Request/ResourceUnsubscribeRequest.php @@ -20,10 +20,10 @@ * * @author Kyrian Obikwelu */ -class ResourceUnsubscribeRequest extends Request +final class ResourceUnsubscribeRequest extends Request { /** - * @param string $uri the URI of the resource to unsubscribe from + * @param non-empty-string $uri the URI of the resource to unsubscribe from */ public function __construct( public readonly string $uri, @@ -35,7 +35,7 @@ public static function getMethod(): string return 'resources/unsubscribe'; } - protected static function fromParams(?array $params): Request + protected static function fromParams(?array $params): static { if (!isset($params['uri']) || !\is_string($params['uri']) || empty($params['uri'])) { throw new InvalidArgumentException('Missing or invalid "uri" parameter for resources/unsubscribe.'); @@ -44,7 +44,10 @@ protected static function fromParams(?array $params): Request return new self($params['uri']); } - protected function getParams(): ?array + /** + * @return array{uri: non-empty-string} + */ + protected function getParams(): array { return ['uri' => $this->uri]; } diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index 610c241d..ad7bee68 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -20,7 +20,7 @@ * * @author Kyrian Obikwelu */ -class SetLogLevelRequest extends Request +final class SetLogLevelRequest extends Request { /** * @param LoggingLevel $level The level of logging that the client wants to receive from the server. The server @@ -37,7 +37,7 @@ public static function getMethod(): string return 'logging/setLevel'; } - protected static function fromParams(?array $params): self + protected static function fromParams(?array $params): static { if (!isset($params['level']) || !\is_string($params['level']) || empty($params['level'])) { throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); @@ -46,7 +46,10 @@ protected static function fromParams(?array $params): self return new self(LoggingLevel::from($params['level'])); } - protected function getParams(): ?array + /** + * @return array{level: value-of} + */ + protected function getParams(): array { return [ 'level' => $this->level->value, diff --git a/tests/Schema/JsonRpc/RequestTest.php b/tests/Schema/JsonRpc/RequestTest.php index a22b9272..279d0953 100644 --- a/tests/Schema/JsonRpc/RequestTest.php +++ b/tests/Schema/JsonRpc/RequestTest.php @@ -24,7 +24,7 @@ public static function getMethod(): string return 'foo/bar'; } - public static function fromParams(?array $params): self + public static function fromParams(?array $params): static { return new self(); } From bfc3b82d198b31d22039e40ecf4199350b761f29 Mon Sep 17 00:00:00 2001 From: Aleksei Gagarin Date: Wed, 10 Sep 2025 00:32:02 +0400 Subject: [PATCH 05/66] Add Makefile to export-ignore in .gitattributes (#56) * Add Makefile to export-ignore in .gitattributes * Update .gitattributes to export-ignore all phpstan configuration files --- .gitattributes | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 749150c5..0b2043be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,6 @@ /examples export-ignore /tests export-ignore /.php-cs-fixer.dist.php export-ignore -/phpstan.dist.neon export-ignore +/phpstan* export-ignore /phpunit.xml.dist export-ignore +/Makefile export-ignore From 99bf56eef8102e56a34a51abd136b233f6d0d884 Mon Sep 17 00:00:00 2001 From: Pavel Buchnev Date: Sun, 14 Sep 2025 22:44:18 +0400 Subject: [PATCH 06/66] Registry Architecture Refactoring - Enhanced Separation of Concerns (#46) * refactor: Separate Registry concerns following SOLID principles [WIP] - Extract ReferenceProvider and ReferenceRegistryInterface interfaces - Create DefaultToolExecutor with ReferenceHandlerInterface - Remove execution responsibility from Registry class - Enable custom handler and executor implementations * refactor: implement SOLID principles with separated execution concerns * Create DefaultResourceReader, DefaultPromptGetter * Refactor JsonRpc Handler and RequestHandlers to use dedicated executors * Update ServerBuilder to support custom executors via dependency injection * chore: remove extra comments * feat: introduce DispatchableRegistry for enhanced tool and resource management * refactor: use proper interfaces * refactor: implement HandlerInterface for improved abstraction and flexibility * test: add unit tests for DispatchableRegistry and Registry classes * refactor: cover with unit tests Resource reader, Prompt getter and Tool executor * cs fix * phpstan fix * test: add unit tests for CallToolHandler, GetPromptHandler, PingHandler, and ReadResourceHandler * refactor: revert Handler * chore: remove style guide for tests * refactor: remove "Default" prefix from classes * refactor: remove DispatchableRegistry * refactor: use package specific exception classes * refactor: remove DispatchableRegistry * refactor: add logger support to PromptGetter and ResourceReader classes * Update src/Capability/Prompt/PromptGetter.php Co-authored-by: Christopher Hertel * refactor: Use FQN for Resource class to avoid cs-fixer misinterpreting it as `resource` * refactor: rename ToolExecutor to ToolCaller and related classes for clarity and consistency * refactor: add missed docblock * refactor: rename ToolExecutor references to ToolCaller * refactor: rename ToolCallerTest property and variable references * cs fix * ignore some phpstan errors --------- Co-authored-by: Christopher Hertel --- .../09-standalone-cli/src/ExampleTool.php | 4 +- phpstan-baseline.neon | 140 +--- src/Capability/Discovery/Discoverer.php | 4 +- src/Capability/Prompt/PromptGetter.php | 69 ++ src/Capability/Registry.php | 143 ++-- src/Capability/Registry/ReferenceHandler.php | 2 +- .../Registry/ReferenceHandlerInterface.php | 34 + .../Registry/ReferenceProviderInterface.php | 78 +++ .../Registry/ReferenceRegistryInterface.php | 79 +++ .../Registry/ResourceTemplateReference.php | 5 +- .../Resource/ResourceReadResult.php | 30 - src/Capability/Resource/ResourceReader.php | 68 ++ src/Capability/Tool/ToolCaller.php | 81 +++ ...rInterface.php => ToolCallerInterface.php} | 8 +- src/Capability/ToolChain.php | 10 +- ...ionException.php => ToolCallException.php} | 4 +- src/JsonRpc/Handler.php | 37 +- src/Schema/ServerCapabilities.php | 4 +- src/Server/RequestHandler/CallToolHandler.php | 20 +- .../RequestHandler/GetPromptHandler.php | 9 +- .../RequestHandler/ListPromptsHandler.php | 4 +- .../RequestHandler/ListResourcesHandler.php | 4 +- .../RequestHandler/ListToolsHandler.php | 4 +- .../RequestHandler/ReadResourceHandler.php | 9 +- src/Server/ServerBuilder.php | 144 +++- tests/Capability/Prompt/PromptGetterTest.php | 638 ++++++++++++++++++ .../Registry/RegistryProviderTest.php | 313 +++++++++ tests/Capability/Registry/RegistryTest.php | 354 ++++++++++ .../Resource/ResourceReaderTest.php | 522 ++++++++++++++ tests/Capability/Tool/ToolCallerTest.php | 628 +++++++++++++++++ tests/Schema/ServerCapabilitiesTest.php | 406 +++++++++++ .../RequestHandler/CallToolHandlerTest.php | 294 ++++++++ .../RequestHandler/GetPromptHandlerTest.php | 342 ++++++++++ .../Server/RequestHandler/PingHandlerTest.php | 147 ++++ .../ReadResourceHandlerTest.php | 351 ++++++++++ tests/ServerTest.php | 1 + 36 files changed, 4656 insertions(+), 334 deletions(-) create mode 100644 src/Capability/Prompt/PromptGetter.php create mode 100644 src/Capability/Registry/ReferenceHandlerInterface.php create mode 100644 src/Capability/Registry/ReferenceProviderInterface.php create mode 100644 src/Capability/Registry/ReferenceRegistryInterface.php delete mode 100644 src/Capability/Resource/ResourceReadResult.php create mode 100644 src/Capability/Resource/ResourceReader.php create mode 100644 src/Capability/Tool/ToolCaller.php rename src/Capability/Tool/{ToolExecutorInterface.php => ToolCallerInterface.php} (73%) rename src/Exception/{ToolExecutionException.php => ToolCallException.php} (67%) create mode 100644 tests/Capability/Prompt/PromptGetterTest.php create mode 100644 tests/Capability/Registry/RegistryProviderTest.php create mode 100644 tests/Capability/Registry/RegistryTest.php create mode 100644 tests/Capability/Resource/ResourceReaderTest.php create mode 100644 tests/Capability/Tool/ToolCallerTest.php create mode 100644 tests/Schema/ServerCapabilitiesTest.php create mode 100644 tests/Server/RequestHandler/CallToolHandlerTest.php create mode 100644 tests/Server/RequestHandler/GetPromptHandlerTest.php create mode 100644 tests/Server/RequestHandler/PingHandlerTest.php create mode 100644 tests/Server/RequestHandler/ReadResourceHandlerTest.php diff --git a/examples/09-standalone-cli/src/ExampleTool.php b/examples/09-standalone-cli/src/ExampleTool.php index 0eb9010a..559de51d 100644 --- a/examples/09-standalone-cli/src/ExampleTool.php +++ b/examples/09-standalone-cli/src/ExampleTool.php @@ -12,7 +12,7 @@ namespace App; use Mcp\Capability\Tool\MetadataInterface; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -20,7 +20,7 @@ /** * @author Tobias Nyholm */ -class ExampleTool implements MetadataInterface, ToolExecutorInterface +class ExampleTool implements MetadataInterface, ToolCallerInterface { public function call(CallToolRequest $request): CallToolResult { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c550bc03..1ac806b3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -367,124 +367,46 @@ parameters: path: examples/08-schema-showcase-streamable/server.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\CallToolHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\GetPromptHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ResourceChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\PromptChain given\.$#' + message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ToolChain given\.$#' identifier: argument.type count: 1 path: examples/09-standalone-cli/src/Builder.php - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ToolChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ReadResourceHandler constructor expects Mcp\\Capability\\Registry, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Call to protected method formatResult\(\) of class Mcp\\Capability\\Registry\\ResourceReference\.$#' - identifier: method.protected - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Cannot import type alias CallableArray\: type alias does not exist in Mcp\\Capability\\Registry\\ElementReference\.$#' - identifier: typeAlias.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleCallTool\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:handleGetPrompt\(\) has parameter \$arguments with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:registerTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Capability/Registry.php - - - - message: '#^PHPDoc tag @param for parameter \$handler with type \(callable\)\|Mcp\\Capability\\CallableArray\|string is not subtype of native type array\|\(callable\)\|string\.$#' - identifier: parameter.phpDocType - count: 4 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerPrompt\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' + identifier: return.phpDocType count: 1 - path: src/Capability/Registry.php + path: src/Schema/Result/EmptyResult.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' + identifier: return.type count: 1 - path: src/Capability/Registry.php + path: src/Schema/Result/ReadResourceResult.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse count: 1 - path: src/Capability/Registry.php + path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Capability/Registry.php + path: src/Server/RequestHandler/ListPromptsHandler.php - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' @@ -492,24 +414,6 @@ parameters: count: 1 path: src/Capability/Registry/ResourceTemplateReference.php - - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' - identifier: return.phpDocType - count: 1 - path: src/Schema/Result/EmptyResult.php - - - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' - identifier: return.type - count: 1 - path: src/Schema/Result/ReadResourceResult.php - - - - message: '#^Method Mcp\\Capability\\Registry\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -523,16 +427,16 @@ parameters: path: src/Server/RequestHandler/ListPromptsHandler.php - - message: '#^Method Mcp\\Capability\\Registry\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' identifier: arguments.count count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php + path: src/Server/RequestHandler/ListToolsHandler.php - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' @@ -540,12 +444,6 @@ parameters: count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php - - - message: '#^Method Mcp\\Capability\\Registry\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 4d9651cb..a2bdd3cd 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -19,7 +19,7 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\Completion\ProviderInterface; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; @@ -44,7 +44,7 @@ class Discoverer { public function __construct( - private readonly Registry $registry, + private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger = new NullLogger(), private ?DocBlockParser $docBlockParser = null, private ?SchemaGenerator $schemaGenerator = null, diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php new file mode 100644 index 00000000..ec5ac31a --- /dev/null +++ b/src/Capability/Prompt/PromptGetter.php @@ -0,0 +1,69 @@ + + */ +final class PromptGetter implements PromptGetterInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function get(GetPromptRequest $request): GetPromptResult + { + $promptName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Getting prompt', ['name' => $promptName, 'arguments' => $arguments]); + + $reference = $this->referenceProvider->getPrompt($promptName); + + if (null === $reference) { + $this->logger->warning('Prompt not found', ['name' => $promptName]); + throw new PromptNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($reference, $arguments); + $formattedResult = $reference->formatResult($result); + + $this->logger->debug('Prompt retrieved successfully', [ + 'name' => $promptName, + 'result_type' => \gettype($result), + ]); + + return new GetPromptResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Prompt retrieval failed', [ + 'name' => $promptName, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new PromptGetException($request, $e); + } + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f0db6549..f9e65823 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,9 +11,9 @@ namespace Mcp\Capability; -use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; @@ -21,9 +21,6 @@ use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; -use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Content\PromptMessage; -use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -34,11 +31,14 @@ use Psr\Log\NullLogger; /** - * @phpstan-import-type CallableArray from ElementReference + * Registry implementation that manages MCP element registration and access. + * Implements both ReferenceProvider (for access) and ReferenceRegistry (for registration) + * following the Interface Segregation Principle. * * @author Kyrian Obikwelu + * @author Pavel Buchnev */ -class Registry +final class Registry implements ReferenceProviderInterface, ReferenceRegistryInterface { /** * @var array @@ -61,7 +61,6 @@ class Registry private array $resourceTemplates = []; public function __construct( - private readonly ReferenceHandler $referenceHandler = new ReferenceHandler(), private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -74,28 +73,27 @@ public function getCapabilities(): ServerCapabilities } return new ServerCapabilities( - tools: true, // [] !== $this->tools, + tools: [] !== $this->tools, toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: [] !== $this->resources || [] !== $this->resourceTemplates, resourcesSubscribe: false, resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: [] !== $this->prompts, promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, - logging: false, // true, + logging: false, completions: true, ); } - /** - * @param callable|CallableArray|string $handler - */ public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void { $toolName = $tool->name; $existing = $this->tools[$toolName] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.", + ); return; } @@ -105,16 +103,15 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - */ public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void { $uri = $resource->uri; $existing = $this->resources[$uri] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.", + ); return; } @@ -124,10 +121,6 @@ public function registerResource(Resource $resource, callable|array|string $hand $this->eventDispatcher?->dispatch(new ResourceListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerResourceTemplate( ResourceTemplate $template, callable|array|string $handler, @@ -138,20 +131,23 @@ public function registerResourceTemplate( $existing = $this->resourceTemplates[$uriTemplate] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.", + ); return; } - $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference($template, $handler, $isManual, $completionProviders); + $this->resourceTemplates[$uriTemplate] = new ResourceTemplateReference( + $template, + $handler, + $isManual, + $completionProviders, + ); $this->eventDispatcher?->dispatch(new ResourceTemplateListChangedEvent()); } - /** - * @param callable|CallableArray|string $handler - * @param array $completionProviders - */ public function registerPrompt( Prompt $prompt, callable|array|string $handler, @@ -162,7 +158,9 @@ public function registerPrompt( $existing = $this->prompts[$promptName] ?? null; if ($existing && !$isManual && $existing->isManual) { - $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); + $this->logger->debug( + "Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.", + ); return; } @@ -172,20 +170,6 @@ public function registerPrompt( $this->eventDispatcher?->dispatch(new PromptListChangedEvent()); } - /** - * Checks if any elements (manual or discovered) are currently registered. - */ - public function hasElements(): bool - { - return !empty($this->tools) - || !empty($this->resources) - || !empty($this->prompts) - || !empty($this->resourceTemplates); - } - - /** - * Clear discovered elements from registry. - */ public function clear(): void { $clearCount = 0; @@ -220,43 +204,15 @@ public function clear(): void } } - public function handleCallTool(string $name, array $arguments): array - { - $reference = $this->getTool($name); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Tool "%s" is not registered.', $name)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) - ); - } - public function getTool(string $name): ?ToolReference { return $this->tools[$name] ?? null; } - /** - * @return ResourceContents[] - */ - public function handleReadResource(string $uri): array - { - $reference = $this->getResource($uri); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $uri)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, ['uri' => $uri]), - $uri, - ); - } - - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null - { + public function getResource( + string $uri, + bool $includeTemplates = true, + ): ResourceReference|ResourceTemplateReference|null { $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; @@ -282,54 +238,37 @@ public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateRefer return $this->resourceTemplates[$uriTemplate] ?? null; } - /** - * @return PromptMessage[] - */ - public function handleGetPrompt(string $name, ?array $arguments): array - { - $reference = $this->getPrompt($name); - - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $name)); - } - - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) - ); - } - public function getPrompt(string $name): ?PromptReference { return $this->prompts[$name] ?? null; } - /** - * @return array - */ public function getTools(): array { return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); } - /** - * @return array - */ public function getResources(): array { return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); } - /** - * @return array - */ public function getPrompts(): array { return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); } - /** @return array */ public function getResourceTemplates(): array { - return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates); + return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate, + $this->resourceTemplates); + } + + public function hasElements(): bool + { + return !empty($this->tools) + || !empty($this->resources) + || !empty($this->prompts) + || !empty($this->resourceTemplates); } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index d4af3169..b0333788 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -18,7 +18,7 @@ /** * @author Kyrian Obikwelu */ -class ReferenceHandler +final class ReferenceHandler implements ReferenceHandlerInterface { public function __construct( private readonly ?ContainerInterface $container = null, diff --git a/src/Capability/Registry/ReferenceHandlerInterface.php b/src/Capability/Registry/ReferenceHandlerInterface.php new file mode 100644 index 00000000..b8f565a4 --- /dev/null +++ b/src/Capability/Registry/ReferenceHandlerInterface.php @@ -0,0 +1,34 @@ + + */ +interface ReferenceHandlerInterface +{ + /** + * Handles execution of an MCP element reference. + * + * @param ElementReference $reference the element reference to execute + * @param array $arguments arguments to pass to the handler + * + * @return mixed the result of the element execution + * + * @throws \Mcp\Exception\InvalidArgumentException if the handler is invalid + * @throws \Mcp\Exception\RegistryException if execution fails + */ + public function handle(ElementReference $reference, array $arguments): mixed; +} diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php new file mode 100644 index 00000000..6b264001 --- /dev/null +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -0,0 +1,78 @@ + + */ +interface ReferenceProviderInterface +{ + /** + * Gets a tool reference by name. + */ + public function getTool(string $name): ?ToolReference; + + /** + * Gets a resource reference by URI (includes template matching if enabled). + */ + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null; + + /** + * Gets a resource template reference by URI template. + */ + public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference; + + /** + * Gets a prompt reference by name. + */ + public function getPrompt(string $name): ?PromptReference; + + /** + * Gets all registered tools. + * + * @return array + */ + public function getTools(): array; + + /** + * Gets all registered resources. + * + * @return array + */ + public function getResources(): array; + + /** + * Gets all registered prompts. + * + * @return array + */ + public function getPrompts(): array; + + /** + * Gets all registered resource templates. + * + * @return array + */ + public function getResourceTemplates(): array; + + /** + * Checks if any elements (manual or discovered) are currently registered. + */ + public function hasElements(): bool; +} diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php new file mode 100644 index 00000000..2d90de71 --- /dev/null +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -0,0 +1,79 @@ + + */ +interface ReferenceRegistryInterface +{ + /** + * Gets server capabilities based on registered elements. + */ + public function getCapabilities(): ServerCapabilities; + + /** + * Registers a tool with its handler. + * + * @param Handler $handler + */ + public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void; + + /** + * Registers a resource with its handler. + * + * @param Handler $handler + */ + public function registerResource(Resource $resource, callable|array|string $handler, bool $isManual = false): void; + + /** + * Registers a resource template with its handler and completion providers. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerResourceTemplate( + ResourceTemplate $template, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void; + + /** + * Registers a prompt with its handler and completion providers. + * + * @param Handler $handler + * @param array $completionProviders + */ + public function registerPrompt( + Prompt $prompt, + callable|array|string $handler, + array $completionProviders = [], + bool $isManual = false, + ): void; + + /** + * Clear discovered elements from registry. + */ + public function clear(): void; +} diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 3ef1d6cb..322e6a57 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -55,7 +55,8 @@ public function __construct( } /** - * Gets the resource template. + * @deprecated + * Gets the resource template * * @return array array of ResourceContents objects */ @@ -162,7 +163,7 @@ private function compileTemplate(): void * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array + public function formatResult(mixed $readResult, string $uri, ?string $mimeType = null): array { if ($readResult instanceof ResourceContents) { return [$readResult]; diff --git a/src/Capability/Resource/ResourceReadResult.php b/src/Capability/Resource/ResourceReadResult.php deleted file mode 100644 index b352cf42..00000000 --- a/src/Capability/Resource/ResourceReadResult.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ -final class ResourceReadResult -{ - public function __construct( - public readonly string $result, - public readonly string $uri, - - /** - * @var "text"|"blob" - */ - public readonly string $type = 'text', - public readonly string $mimeType = 'text/plain', - ) { - } -} diff --git a/src/Capability/Resource/ResourceReader.php b/src/Capability/Resource/ResourceReader.php new file mode 100644 index 00000000..2496cfaf --- /dev/null +++ b/src/Capability/Resource/ResourceReader.php @@ -0,0 +1,68 @@ + + */ +final class ResourceReader implements ResourceReaderInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function read(ReadResourceRequest $request): ReadResourceResult + { + $uri = $request->uri; + + $this->logger->debug('Reading resource', ['uri' => $uri]); + + $reference = $this->referenceProvider->getResource($uri); + + if (null === $reference) { + $this->logger->warning('Resource not found', ['uri' => $uri]); + throw new ResourceNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); + $formattedResult = $reference->formatResult($result, $uri); + + $this->logger->debug('Resource read successfully', [ + 'uri' => $uri, + 'result_type' => \gettype($result), + ]); + + return new ReadResourceResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Resource read failed', [ + 'uri' => $uri, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new ResourceReadException($request, $e); + } + } +} diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php new file mode 100644 index 00000000..24bc3995 --- /dev/null +++ b/src/Capability/Tool/ToolCaller.php @@ -0,0 +1,81 @@ + + */ +final class ToolCaller implements ToolCallerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @throws ToolCallException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found + */ + public function call(CallToolRequest $request): CallToolResult + { + $toolName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + + $toolReference = $this->referenceProvider->getTool($toolName); + + if (null === $toolReference) { + $this->logger->warning('Tool not found', ['name' => $toolName]); + throw new ToolNotFoundException($request); + } + + try { + $result = $this->referenceHandler->handle($toolReference, $arguments); + /** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */ + $formattedResult = $toolReference->formatResult($result); + + $this->logger->debug('Tool executed successfully', [ + 'name' => $toolName, + 'result_type' => \gettype($result), + ]); + + return new CallToolResult($formattedResult); + } catch (\Throwable $e) { + $this->logger->error('Tool execution failed', [ + 'name' => $toolName, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new ToolCallException($request, $e); + } + } +} diff --git a/src/Capability/Tool/ToolExecutorInterface.php b/src/Capability/Tool/ToolCallerInterface.php similarity index 73% rename from src/Capability/Tool/ToolExecutorInterface.php rename to src/Capability/Tool/ToolCallerInterface.php index c72b134a..1ef7ffea 100644 --- a/src/Capability/Tool/ToolExecutorInterface.php +++ b/src/Capability/Tool/ToolCallerInterface.php @@ -11,7 +11,7 @@ namespace Mcp\Capability\Tool; -use Mcp\Exception\ToolExecutionException; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -19,11 +19,11 @@ /** * @author Tobias Nyholm */ -interface ToolExecutorInterface +interface ToolCallerInterface { /** - * @throws ToolExecutionException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found + * @throws ToolCallException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found */ public function call(CallToolRequest $request): CallToolResult; } diff --git a/src/Capability/ToolChain.php b/src/Capability/ToolChain.php index 7baeee67..e500ff00 100644 --- a/src/Capability/ToolChain.php +++ b/src/Capability/ToolChain.php @@ -14,9 +14,9 @@ use Mcp\Capability\Tool\CollectionInterface; use Mcp\Capability\Tool\IdentifierInterface; use Mcp\Capability\Tool\MetadataInterface; -use Mcp\Capability\Tool\ToolExecutorInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Exception\ToolExecutionException; +use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; @@ -26,7 +26,7 @@ * * @author Tobias Nyholm */ -class ToolChain implements ToolExecutorInterface, CollectionInterface +class ToolChain implements ToolCallerInterface, CollectionInterface { public function __construct( /** @@ -63,11 +63,11 @@ public function getMetadata(int $count, ?string $lastIdentifier = null): iterabl public function call(CallToolRequest $request): CallToolResult { foreach ($this->items as $item) { - if ($item instanceof ToolExecutorInterface && $request->name === $item->getName()) { + if ($item instanceof ToolCallerInterface && $request->name === $item->getName()) { try { return $item->call($request); } catch (\Throwable $e) { - throw new ToolExecutionException($request, $e); + throw new ToolCallException($request, $e); } } } diff --git a/src/Exception/ToolExecutionException.php b/src/Exception/ToolCallException.php similarity index 67% rename from src/Exception/ToolExecutionException.php rename to src/Exception/ToolCallException.php index f2df9366..71978d9d 100644 --- a/src/Exception/ToolExecutionException.php +++ b/src/Exception/ToolCallException.php @@ -16,12 +16,12 @@ /** * @author Tobias Nyholm */ -final class ToolExecutionException extends \RuntimeException implements ExceptionInterface +final class ToolCallException extends \RuntimeException implements ExceptionInterface { public function __construct( public readonly CallToolRequest $request, ?\Throwable $previous = null, ) { - parent::__construct(\sprintf('Execution of tool "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); + parent::__construct(\sprintf('Tool call "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); } } diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 3cff864f..8699eab2 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -11,7 +11,11 @@ namespace Mcp\JsonRpc; -use Mcp\Capability\Registry; +use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ReferenceRegistryInterface; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -46,28 +50,34 @@ public function __construct( iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { - $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array($methodHandlers) : $methodHandlers; + $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array( + $methodHandlers, + ) : $methodHandlers; } public static function make( - Registry $registry, + ReferenceRegistryInterface $registry, + ReferenceProviderInterface $referenceProvider, Implementation $implementation, + ToolCallerInterface $toolCaller, + ResourceReaderInterface $resourceReader, + PromptGetterInterface $promptGetter, LoggerInterface $logger = new NullLogger(), ): self { return new self( - MessageFactory::make(), - [ + messageFactory: MessageFactory::make(), + methodHandlers: [ new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($registry), - new RequestHandler\GetPromptHandler($registry), - new RequestHandler\ListResourcesHandler($registry), - new RequestHandler\ReadResourceHandler($registry), - new RequestHandler\CallToolHandler($registry, $logger), - new RequestHandler\ListToolsHandler($registry), + new RequestHandler\ListPromptsHandler($referenceProvider), + new RequestHandler\GetPromptHandler($promptGetter), + new RequestHandler\ListResourcesHandler($referenceProvider), + new RequestHandler\ReadResourceHandler($resourceReader), + new RequestHandler\CallToolHandler($toolCaller, $logger), + new RequestHandler\ListToolsHandler($referenceProvider), ], - $logger, + logger: $logger, ); } @@ -107,7 +117,8 @@ public function process(string $input): iterable } catch (\DomainException) { yield null; } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]); + $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e], + ); yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); } catch (\InvalidArgumentException $e) { diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index fffc2655..89eec187 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -51,7 +51,7 @@ public function __construct( * completions?: mixed, * prompts?: array{listChanged?: bool}|object, * resources?: array{listChanged?: bool, subscribe?: bool}|object, - * tools?: object, + * tools?: object|array{listChanged?: bool}, * experimental?: array, * } $data */ @@ -106,7 +106,7 @@ public static function fromArray(array $data): self promptsListChanged: $promptsListChanged, logging: $loggingEnabled, completions: $completionsEnabled, - experimental: $data['experimental'] ?? null + experimental: $data['experimental'] ?? null, ); } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index a4dfebd0..28aab382 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -11,13 +11,12 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Result\CallToolResult; use Mcp\Server\MethodHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -29,7 +28,7 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ToolCallerInterface $toolCaller, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -44,16 +43,19 @@ public function handle(CallToolRequest|HasMethodInterface $message): Response|Er \assert($message instanceof CallToolRequest); try { - $content = $this->registry->handleCallTool($message->name, $message->arguments); + $content = $this->toolCaller->call($message); } catch (ExceptionInterface $exception) { - $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), [ - 'tool' => $message->name, - 'arguments' => $message->arguments, - ]); + $this->logger->error( + \sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), + [ + 'tool' => $message->name, + 'arguments' => $message->arguments, + ], + ); return Error::forInternalError('Error while executing tool', $message->getId()); } - return new Response($message->getId(), new CallToolResult($content)); + return new Response($message->getId(), $content); } } diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index c74044d8..1ac0a3ff 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -11,13 +11,12 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; -use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\MethodHandlerInterface; /** @@ -26,7 +25,7 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly PromptGetterInterface $promptGetter, ) { } @@ -40,11 +39,11 @@ public function handle(GetPromptRequest|HasMethodInterface $message): Response|E \assert($message instanceof GetPromptRequest); try { - $messages = $this->registry->handleGetPrompt($message->name, $message->arguments); + $messages = $this->promptGetter->get($message); } catch (ExceptionInterface) { return Error::forInternalError('Error while handling prompt', $message->getId()); } - return new Response($message->getId(), new GetPromptResult($messages)); + return new Response($message->getId(), $messages); } } diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index 942550a0..2bf479c9 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; @@ -24,7 +24,7 @@ final class ListPromptsHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 75804d84..212f4f00 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; @@ -24,7 +24,7 @@ final class ListResourcesHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index ef35fa8d..eb49e0d9 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -11,7 +11,7 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; @@ -25,7 +25,7 @@ final class ListToolsHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ReferenceProviderInterface $registry, private readonly int $pageSize = 20, ) { } diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 40746a6e..9c80d2b1 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -11,14 +11,13 @@ namespace Mcp\Server\RequestHandler; -use Mcp\Capability\Registry; +use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\MethodHandlerInterface; /** @@ -27,7 +26,7 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( - private readonly Registry $registry, + private readonly ResourceReaderInterface $resourceReader, ) { } @@ -41,13 +40,13 @@ public function handle(ReadResourceRequest|HasMethodInterface $message): Respons \assert($message instanceof ReadResourceRequest); try { - $contents = $this->registry->handleReadResource($message->uri); + $contents = $this->resourceReader->read($message); } catch (ResourceNotFoundException $e) { return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); } catch (ExceptionInterface) { return Error::forInternalError('Error while reading resource', $message->getId()); } - return new Response($message->getId(), new ReadResourceResult($contents)); + return new Response($message->getId(), $contents); } } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index f8867567..ede6c77d 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -18,9 +18,15 @@ use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; +use Mcp\Capability\Prompt\PromptGetter; +use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ReferenceHandler; +use Mcp\Capability\Resource\ResourceReader; +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Tool\ToolCaller; +use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\Handler; use Mcp\Schema\Annotations; @@ -49,6 +55,12 @@ final class ServerBuilder private ?CacheInterface $cache = null; + private ?ToolCallerInterface $toolCaller = null; + + private ?ResourceReaderInterface $resourceReader = null; + + private ?PromptGetterInterface $promptGetter = null; + private ?EventDispatcherInterface $eventDispatcher = null; private ?ContainerInterface $container = null; @@ -149,6 +161,27 @@ public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): return $this; } + public function withToolCaller(ToolCallerInterface $toolCaller): self + { + $this->toolCaller = $toolCaller; + + return $this; + } + + public function withResourceReader(ResourceReaderInterface $resourceReader): self + { + $this->resourceReader = $resourceReader; + + return $this; + } + + public function withPromptGetter(PromptGetterInterface $promptGetter): self + { + $this->promptGetter = $promptGetter; + + return $this; + } + /** * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. * Defaults to a basic internal container. @@ -175,8 +208,13 @@ public function withDiscovery( /** * Manually registers a tool handler. */ - public function withTool(callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self - { + public function withTool( + callable|array|string $handler, + ?string $name = null, + ?string $description = null, + ?ToolAnnotations $annotations = null, + ?array $inputSchema = null, + ): self { $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); return $this; @@ -185,8 +223,15 @@ public function withTool(callable|array|string $handler, ?string $name = null, ? /** * Manually registers a resource handler. */ - public function withResource(callable|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self - { + public function withResource( + callable|array|string $handler, + string $uri, + ?string $name = null, + ?string $description = null, + ?string $mimeType = null, + ?int $size = null, + ?Annotations $annotations = null, + ): self { $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); return $this; @@ -195,9 +240,22 @@ public function withResource(callable|array|string $handler, string $uri, ?strin /** * Manually registers a resource template handler. */ - public function withResourceTemplate(callable|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self - { - $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); + public function withResourceTemplate( + callable|array|string $handler, + string $uriTemplate, + ?string $name = null, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ): self { + $this->manualResourceTemplates[] = compact( + 'handler', + 'uriTemplate', + 'name', + 'description', + 'mimeType', + 'annotations', + ); return $this; } @@ -220,7 +278,12 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); - $registry = new Registry(new ReferenceHandler($container), $this->eventDispatcher, $logger); + $registry = new Registry($this->eventDispatcher, $logger); + + $referenceHandler = new ReferenceHandler($container); + $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); + $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); + $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); $this->registerManualElements($registry, $logger); @@ -230,8 +293,16 @@ public function build(): Server } return new Server( - Handler::make($registry, $this->serverInfo, $logger), - $logger, + jsonRpcHandler: Handler::make( + registry: $registry, + referenceProvider: $registry, + implementation: $this->serverInfo, + toolCaller: $toolCaller, + resourceReader: $resourceReader, + promptGetter: $promptGetter, + logger: $logger, + ), + logger: $logger, ); } @@ -239,8 +310,10 @@ public function build(): Server * Helper to perform the actual registration based on stored data. * Moved into the builder. */ - private function registerManualElements(Registry $registry, LoggerInterface $logger = new NullLogger()): void - { + private function registerManualElements( + Registry\ReferenceRegistryInterface $registry, + LoggerInterface $logger = new NullLogger(), + ): void { if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { return; } @@ -270,10 +343,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $tool = new Tool($name, $inputSchema, $description, $data['annotations']); $registry->registerTool($tool, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + $logger->error( + 'Failed to register manual tool', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); } } @@ -303,10 +381,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); $registry->registerResource($resource, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); + $logger->error( + 'Failed to register manual resource', + ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); } } @@ -336,10 +419,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); + $logger->error( + 'Failed to register manual template', + ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); } } @@ -362,7 +450,9 @@ private function registerManualElements(Registry $registry, LoggerInterface $log } $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( + $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), + ) : []; foreach ($reflection->getParameters() as $param) { $reflectionType = $param->getType(); @@ -375,7 +465,7 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $arguments[] = new PromptArgument( $param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable() + !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } @@ -383,10 +473,15 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( + $data['handler'], + ) ? implode('::', $data['handler']) : $data['handler']); $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { - $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + $logger->error( + 'Failed to register manual prompt', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); } } @@ -403,7 +498,10 @@ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $r continue; } - $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + $completionAttributes = $param->getAttributes( + CompletionProvider::class, + \ReflectionAttribute::IS_INSTANCEOF, + ); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); diff --git a/tests/Capability/Prompt/PromptGetterTest.php b/tests/Capability/Prompt/PromptGetterTest.php new file mode 100644 index 00000000..46bee1cf --- /dev/null +++ b/tests/Capability/Prompt/PromptGetterTest.php @@ -0,0 +1,638 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + ); + } + + public function testGetExecutesPromptSuccessfully(): void + { + $request = new GetPromptRequest('test_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('test_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'test result'); + $handlerResult = [ + 'role' => 'user', + 'content' => 'Generated prompt content', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('test_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertInstanceOf(PromptMessage::class, $result->messages[0]); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertInstanceOf(TextContent::class, $result->messages[0]->content); + $this->assertEquals('Generated prompt content', $result->messages[0]->content->text); + } + + public function testGetWithEmptyArguments(): void + { + $request = new GetPromptRequest('empty_args_prompt', []); + $prompt = $this->createValidPrompt('empty_args_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Empty args content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_args_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([ + 'role' => 'user', + 'content' => 'Empty args content', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = new GetPromptRequest('complex_prompt', $arguments); + $prompt = $this->createValidPrompt('complex_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Complex content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('complex_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn([ + 'role' => 'assistant', + 'content' => 'Complex response', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void + { + $request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('nonexistent_prompt') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".'); + + $this->promptGetter->get($request); + } + + public function testGetThrowsRegistryExceptionWhenHandlerFails(): void + { + $request = new GetPromptRequest('failing_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('failing_prompt'); + $promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = RegistryException::internalError('Handler failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('failing_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willThrowException($handlerException); + + $this->expectException(PromptGetException::class); + + $this->promptGetter->get($request); + } + + public function testGetHandlesJsonExceptionDuringFormatting(): void + { + $request = new GetPromptRequest('json_error_prompt', []); + $prompt = $this->createValidPrompt('json_error_prompt'); + + // Create a mock PromptReference that will throw JsonException during formatResult + $promptReference = $this->createMock(PromptReference::class); + $promptReference->expects($this->once()) + ->method('formatResult') + ->willThrowException(new \JsonException('JSON encoding failed')); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('json_error_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn('some result'); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('JSON encoding failed'); + + $this->promptGetter->get($request); + } + + public function testGetHandlesArrayOfMessages(): void + { + $request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']); + $prompt = $this->createValidPrompt('multi_message_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Multiple messages'); + $handlerResult = [ + [ + 'role' => 'user', + 'content' => 'First message', + ], + [ + 'role' => 'assistant', + 'content' => 'Second message', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('multi_message_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['context' => 'test']) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('First message', $result->messages[0]->content->text); + $this->assertEquals(Role::Assistant, $result->messages[1]->role); + $this->assertEquals('Second message', $result->messages[1]->content->text); + } + + public function testGetHandlesPromptMessageObjects(): void + { + $request = new GetPromptRequest('prompt_message_prompt', []); + $prompt = $this->createValidPrompt('prompt_message_prompt'); + $promptMessage = new PromptMessage( + Role::User, + new TextContent('Direct prompt message') + ); + $promptReference = new PromptReference($prompt, fn () => $promptMessage); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('prompt_message_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($promptMessage); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertSame($promptMessage, $result->messages[0]); + } + + public function testGetHandlesUserAssistantStructure(): void + { + $request = new GetPromptRequest('user_assistant_prompt', []); + $prompt = $this->createValidPrompt('user_assistant_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Conversation content'); + $handlerResult = [ + 'user' => 'What is the weather?', + 'assistant' => 'I can help you check the weather.', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('user_assistant_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('What is the weather?', $result->messages[0]->content->text); + $this->assertEquals(Role::Assistant, $result->messages[1]->role); + $this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text); + } + + public function testGetHandlesEmptyArrayResult(): void + { + $request = new GetPromptRequest('empty_array_prompt', []); + $prompt = $this->createValidPrompt('empty_array_prompt'); + $promptReference = new PromptReference($prompt, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_array_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(0, $result->messages); + } + + public function testGetWithTypedContentStructure(): void + { + $request = new GetPromptRequest('typed_content_prompt', []); + $prompt = $this->createValidPrompt('typed_content_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Typed content'); + $handlerResult = [ + 'role' => 'user', + 'content' => [ + 'type' => 'text', + 'text' => 'Typed text content', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('typed_content_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertEquals(Role::User, $result->messages[0]->role); + $this->assertEquals('Typed text content', $result->messages[0]->content->text); + } + + public function testGetWithPromptReferenceHavingCompletionProviders(): void + { + $request = new GetPromptRequest('completion_prompt', ['param' => 'value']); + $prompt = $this->createValidPrompt('completion_prompt'); + $completionProviders = ['param' => EnumCompletionProvider::class]; + $promptReference = new PromptReference( + $prompt, + fn () => 'Completion content', + false, + $completionProviders + ); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('completion_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['param' => 'value']) + ->willReturn([ + 'role' => 'user', + 'content' => 'Completion content', + ]); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + } + + public function testGetHandlesMixedMessageArray(): void + { + $request = new GetPromptRequest('mixed_prompt', []); + $prompt = $this->createValidPrompt('mixed_prompt'); + $promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message')); + $promptReference = new PromptReference($prompt, fn () => 'Mixed content'); + $handlerResult = [ + $promptMessage, + [ + 'role' => 'user', + 'content' => 'Regular message', + ], + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('mixed_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(2, $result->messages); + $this->assertSame($promptMessage, $result->messages[0]); + $this->assertEquals(Role::User, $result->messages[1]->role); + $this->assertEquals('Regular message', $result->messages[1]->content->text); + } + + public function testGetReflectsFormattedMessagesCorrectly(): void + { + $request = new GetPromptRequest('format_test_prompt', []); + $prompt = $this->createValidPrompt('format_test_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Format test'); + + // Test that the formatted result from PromptReference.formatResult is properly returned + $handlerResult = [ + 'role' => 'user', + 'content' => 'Test formatting', + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('format_test_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($handlerResult); + + $result = $this->promptGetter->get($request); + + $this->assertInstanceOf(GetPromptResult::class, $result); + $this->assertCount(1, $result->messages); + $this->assertEquals('Test formatting', $result->messages[0]->content->text); + $this->assertEquals(Role::User, $result->messages[0]->role); + } + + /** + * Test that invalid handler results throw RuntimeException from PromptReference.formatResult(). + */ + public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void + { + $request = new GetPromptRequest('invalid_prompt', []); + $prompt = $this->createValidPrompt('invalid_prompt'); + $promptReference = new PromptReference($prompt, fn () => 'Invalid content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('invalid_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn('This is not a valid prompt format'); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that null result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void + { + $request = new GetPromptRequest('null_prompt', []); + $prompt = $this->createValidPrompt('null_prompt'); + $promptReference = new PromptReference($prompt, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('null_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(null); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that scalar result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void + { + $request = new GetPromptRequest('scalar_prompt', []); + $prompt = $this->createValidPrompt('scalar_prompt'); + $promptReference = new PromptReference($prompt, fn () => 42); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('scalar_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(42); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that boolean result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void + { + $request = new GetPromptRequest('boolean_prompt', []); + $prompt = $this->createValidPrompt('boolean_prompt'); + $promptReference = new PromptReference($prompt, fn () => true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('boolean_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn(true); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + /** + * Test that object result from handler throws RuntimeException. + */ + public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void + { + $request = new GetPromptRequest('object_prompt', []); + $prompt = $this->createValidPrompt('object_prompt'); + $objectResult = new \stdClass(); + $objectResult->property = 'value'; + $promptReference = new PromptReference($prompt, fn () => $objectResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('object_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($objectResult); + + $this->expectException(PromptGetException::class); + $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); + + $this->promptGetter->get($request); + } + + public function testConstructorWithDefaultLogger(): void + { + $promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + ); + + $this->assertInstanceOf(PromptGetter::class, $promptGetter); + } + + public function testConstructorWithCustomLogger(): void + { + $logger = $this->createMock(LoggerInterface::class); + $promptGetter = new PromptGetter( + $this->referenceProvider, + $this->referenceHandler, + $logger, + ); + + $this->assertInstanceOf(PromptGetter::class, $promptGetter); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: null, + ); + } +} diff --git a/tests/Capability/Registry/RegistryProviderTest.php b/tests/Capability/Registry/RegistryProviderTest.php new file mode 100644 index 00000000..8669afab --- /dev/null +++ b/tests/Capability/Registry/RegistryProviderTest.php @@ -0,0 +1,313 @@ +registry = new Registry(); + } + + public function testGetToolReturnsRegisteredTool(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn () => 'result'; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertInstanceOf(ToolReference::class, $toolRef); + $this->assertEquals($tool->name, $toolRef->tool->name); + $this->assertEquals($handler, $toolRef->handler); + $this->assertFalse($toolRef->isManual); + } + + public function testGetToolReturnsNullForUnregisteredTool(): void + { + $toolRef = $this->registry->getTool('non_existent_tool'); + $this->assertNull($toolRef); + } + + public function testGetResourceReturnsRegisteredResource(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertInstanceOf(ResourceReference::class, $resourceRef); + $this->assertEquals($resource->uri, $resourceRef->schema->uri); + $this->assertEquals($handler, $resourceRef->handler); + $this->assertFalse($resourceRef->isManual); + } + + public function testGetResourceReturnsNullForUnregisteredResource(): void + { + $resourceRef = $this->registry->getResource('test://non_existent'); + $this->assertNull($resourceRef); + } + + public function testGetResourceMatchesResourceTemplate(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $resourceRef = $this->registry->getResource('test://123'); + $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); + $this->assertEquals($template->uriTemplate, $resourceRef->resourceTemplate->uriTemplate); + $this->assertEquals($handler, $resourceRef->handler); + } + + public function testGetResourceWithIncludeTemplatesFalse(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $resourceRef = $this->registry->getResource('test://123', false); + $this->assertNull($resourceRef); + } + + public function testGetResourcePrefersDirectResourceOverTemplate(): void + { + $resource = $this->createValidResource('test://123'); + $resourceHandler = fn () => 'direct resource'; + + $template = $this->createValidResourceTemplate('test://{id}'); + $templateHandler = fn (string $id) => "template for {$id}"; + + $this->registry->registerResource($resource, $resourceHandler); + $this->registry->registerResourceTemplate($template, $templateHandler); + + $resourceRef = $this->registry->getResource('test://123'); + $this->assertInstanceOf(ResourceReference::class, $resourceRef); + $this->assertEquals($resource->uri, $resourceRef->schema->uri); + } + + public function testGetResourceTemplateReturnsRegisteredTemplate(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertInstanceOf(ResourceTemplateReference::class, $templateRef); + $this->assertEquals($template->uriTemplate, $templateRef->resourceTemplate->uriTemplate); + $this->assertEquals($handler, $templateRef->handler); + $this->assertFalse($templateRef->isManual); + } + + public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void + { + $templateRef = $this->registry->getResourceTemplate('test://{non_existent}'); + $this->assertNull($templateRef); + } + + public function testGetPromptReturnsRegisteredPrompt(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $handler = fn () => ['role' => 'user', 'content' => 'test message']; + + $this->registry->registerPrompt($prompt, $handler); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertInstanceOf(PromptReference::class, $promptRef); + $this->assertEquals($prompt->name, $promptRef->prompt->name); + $this->assertEquals($handler, $promptRef->handler); + $this->assertFalse($promptRef->isManual); + } + + public function testGetPromptReturnsNullForUnregisteredPrompt(): void + { + $promptRef = $this->registry->getPrompt('non_existent_prompt'); + $this->assertNull($promptRef); + } + + public function testGetToolsReturnsAllRegisteredTools(): void + { + $tool1 = $this->createValidTool('tool1'); + $tool2 = $this->createValidTool('tool2'); + + $this->registry->registerTool($tool1, fn () => 'result1'); + $this->registry->registerTool($tool2, fn () => 'result2'); + + $tools = $this->registry->getTools(); + $this->assertCount(2, $tools); + $this->assertArrayHasKey('tool1', $tools); + $this->assertArrayHasKey('tool2', $tools); + $this->assertInstanceOf(Tool::class, $tools['tool1']); + $this->assertInstanceOf(Tool::class, $tools['tool2']); + } + + public function testGetResourcesReturnsAllRegisteredResources(): void + { + $resource1 = $this->createValidResource('test://resource1'); + $resource2 = $this->createValidResource('test://resource2'); + + $this->registry->registerResource($resource1, fn () => 'content1'); + $this->registry->registerResource($resource2, fn () => 'content2'); + + $resources = $this->registry->getResources(); + $this->assertCount(2, $resources); + $this->assertArrayHasKey('test://resource1', $resources); + $this->assertArrayHasKey('test://resource2', $resources); + $this->assertInstanceOf(Resource::class, $resources['test://resource1']); + $this->assertInstanceOf(Resource::class, $resources['test://resource2']); + } + + public function testGetPromptsReturnsAllRegisteredPrompts(): void + { + $prompt1 = $this->createValidPrompt('prompt1'); + $prompt2 = $this->createValidPrompt('prompt2'); + + $this->registry->registerPrompt($prompt1, fn () => []); + $this->registry->registerPrompt($prompt2, fn () => []); + + $prompts = $this->registry->getPrompts(); + $this->assertCount(2, $prompts); + $this->assertArrayHasKey('prompt1', $prompts); + $this->assertArrayHasKey('prompt2', $prompts); + $this->assertInstanceOf(Prompt::class, $prompts['prompt1']); + $this->assertInstanceOf(Prompt::class, $prompts['prompt2']); + } + + public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void + { + $template1 = $this->createValidResourceTemplate('test1://{id}'); + $template2 = $this->createValidResourceTemplate('test2://{category}'); + + $this->registry->registerResourceTemplate($template1, fn () => 'content1'); + $this->registry->registerResourceTemplate($template2, fn () => 'content2'); + + $templates = $this->registry->getResourceTemplates(); + $this->assertCount(2, $templates); + $this->assertArrayHasKey('test1://{id}', $templates); + $this->assertArrayHasKey('test2://{category}', $templates); + $this->assertInstanceOf(ResourceTemplate::class, $templates['test1://{id}']); + $this->assertInstanceOf(ResourceTemplate::class, $templates['test2://{category}']); + } + + public function testHasElementsReturnsFalseForEmptyRegistry(): void + { + $this->assertFalse($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenToolIsRegistered(): void + { + $tool = $this->createValidTool('test_tool'); + $this->registry->registerTool($tool, fn () => 'result'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenResourceIsRegistered(): void + { + $resource = $this->createValidResource('test://resource'); + $this->registry->registerResource($resource, fn () => 'content'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenPromptIsRegistered(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $this->registry->registerPrompt($prompt, fn () => []); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testHasElementsReturnsTrueWhenResourceTemplateIsRegistered(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $this->registry->registerResourceTemplate($template, fn () => 'content'); + + $this->assertTrue($this->registry->hasElements()); + } + + public function testResourceTemplateMatchingPrefersMoreSpecificMatches(): void + { + $specificTemplate = $this->createValidResourceTemplate('test://users/{userId}/profile'); + $genericTemplate = $this->createValidResourceTemplate('test://users/{userId}'); + + $this->registry->registerResourceTemplate($genericTemplate, fn () => 'generic'); + $this->registry->registerResourceTemplate($specificTemplate, fn () => 'specific'); + + // Should match the more specific template first + $resourceRef = $this->registry->getResource('test://users/123/profile'); + $this->assertInstanceOf(ResourceTemplateReference::class, $resourceRef); + $this->assertEquals('test://users/{userId}/profile', $resourceRef->resourceTemplate->uriTemplate); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null + ); + } + + private function createValidResource(string $uri): Resource + { + return new Resource( + uri: $uri, + name: 'test_resource', + description: 'Test resource', + mimeType: 'text/plain' + ); + } + + private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate + { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: 'test_template', + description: 'Test resource template', + mimeType: 'text/plain' + ); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: [] + ); + } +} diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Capability/Registry/RegistryTest.php new file mode 100644 index 00000000..14548b63 --- /dev/null +++ b/tests/Capability/Registry/RegistryTest.php @@ -0,0 +1,354 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new Registry(null, $this->logger); + } + + public function testConstructorWithDefaults(): void + { + $registry = new Registry(); + $capabilities = $registry->getCapabilities(); + + $this->assertInstanceOf(ServerCapabilities::class, $capabilities); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->promptsListChanged); + } + + public function testGetCapabilitiesWhenEmpty(): void + { + $this->logger + ->expects($this->once()) + ->method('info') + ->with('No capabilities registered on server.'); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + } + + public function testGetCapabilitiesWhenPopulated(): void + { + $tool = $this->createValidTool('test_tool'); + $resource = $this->createValidResource('test://resource'); + $prompt = $this->createValidPrompt('test_prompt'); + $template = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerTool($tool, fn () => 'result'); + $this->registry->registerResource($resource, fn () => 'content'); + $this->registry->registerPrompt($prompt, fn () => []); + $this->registry->registerResourceTemplate($template, fn () => 'template'); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->completions); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->logging); + } + + public function testRegisterToolWithManualFlag(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = fn () => 'result'; + + $this->registry->registerTool($tool, $handler, true); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void + { + $manualTool = $this->createValidTool('test_tool'); + $discoveredTool = $this->createValidTool('test_tool'); + + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered tool 'test_tool' as it conflicts with a manually registered one."); + + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterToolOverridesDiscoveredWithManual(): void + { + $discoveredTool = $this->createValidTool('test_tool'); + $manualTool = $this->createValidTool('test_tool'); + + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testRegisterResourceWithManualFlag(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler, true); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertTrue($resourceRef->isManual); + } + + public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void + { + $manualResource = $this->createValidResource('test://resource'); + $discoveredResource = $this->createValidResource('test://resource'); + + $this->registry->registerResource($manualResource, fn () => 'manual', true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered resource 'test://resource' as it conflicts with a manually registered one."); + + $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertTrue($resourceRef->isManual); + } + + public function testRegisterResourceTemplateWithCompletionProviders(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $completionProviders = ['id' => EnumCompletionProvider::class]; + + $this->registry->registerResourceTemplate($template, fn () => 'content', $completionProviders); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertEquals($completionProviders, $templateRef->completionProviders); + } + + public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): void + { + $manualTemplate = $this->createValidResourceTemplate('test://{id}'); + $discoveredTemplate = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered template 'test://{id}' as it conflicts with a manually registered one."); + + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertTrue($templateRef->isManual); + } + + public function testRegisterPromptWithCompletionProviders(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $completionProviders = ['param' => EnumCompletionProvider::class]; + + $this->registry->registerPrompt($prompt, fn () => [], $completionProviders); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertEquals($completionProviders, $promptRef->completionProviders); + } + + public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void + { + $manualPrompt = $this->createValidPrompt('test_prompt'); + $discoveredPrompt = $this->createValidPrompt('test_prompt'); + + $this->registry->registerPrompt($manualPrompt, fn () => 'manual', [], true); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with("Ignoring discovered prompt 'test_prompt' as it conflicts with a manually registered one."); + + $this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertTrue($promptRef->isManual); + } + + public function testClearRemovesOnlyDiscoveredElements(): void + { + $manualTool = $this->createValidTool('manual_tool'); + $discoveredTool = $this->createValidTool('discovered_tool'); + $manualResource = $this->createValidResource('test://manual'); + $discoveredResource = $this->createValidResource('test://discovered'); + $manualPrompt = $this->createValidPrompt('manual_prompt'); + $discoveredPrompt = $this->createValidPrompt('discovered_prompt'); + $manualTemplate = $this->createValidResourceTemplate('manual://{id}'); + $discoveredTemplate = $this->createValidResourceTemplate('discovered://{id}'); + + $this->registry->registerTool($manualTool, fn () => 'manual', true); + $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); + $this->registry->registerResource($manualResource, fn () => 'manual', true); + $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); + $this->registry->registerPrompt($manualPrompt, fn () => [], [], true); + $this->registry->registerPrompt($discoveredPrompt, fn () => [], [], false); + $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Removed 4 discovered elements from internal registry.'); + + $this->registry->clear(); + + $this->assertNotNull($this->registry->getTool('manual_tool')); + $this->assertNull($this->registry->getTool('discovered_tool')); + $this->assertNotNull($this->registry->getResource('test://manual')); + $this->assertNull( + $this->registry->getResource('test://discovered', false), + ); // Don't include templates to avoid debug log + $this->assertNotNull($this->registry->getPrompt('manual_prompt')); + $this->assertNull($this->registry->getPrompt('discovered_prompt')); + $this->assertNotNull($this->registry->getResourceTemplate('manual://{id}')); + $this->assertNull($this->registry->getResourceTemplate('discovered://{id}')); + } + + public function testClearLogsNothingWhenNoDiscoveredElements(): void + { + $manualTool = $this->createValidTool('manual_tool'); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $this->logger + ->expects($this->never()) + ->method('debug'); + + $this->registry->clear(); + + $this->assertNotNull($this->registry->getTool('manual_tool')); + } + + public function testRegisterToolHandlesStringHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = 'TestClass::testMethod'; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals($handler, $toolRef->handler); + } + + public function testRegisterToolHandlesArrayHandler(): void + { + $tool = $this->createValidTool('test_tool'); + $handler = ['TestClass', 'testMethod']; + + $this->registry->registerTool($tool, $handler); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals($handler, $toolRef->handler); + } + + public function testRegisterResourceHandlesCallableHandler(): void + { + $resource = $this->createValidResource('test://resource'); + $handler = fn () => 'content'; + + $this->registry->registerResource($resource, $handler); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertEquals($handler, $resourceRef->handler); + } + + public function testMultipleRegistrationsOfSameElementWithSameType(): void + { + $tool1 = $this->createValidTool('test_tool'); + $tool2 = $this->createValidTool('test_tool'); + + $this->registry->registerTool($tool1, fn () => 'first', false); + $this->registry->registerTool($tool2, fn () => 'second', false); + + // Second registration should override the first + $toolRef = $this->registry->getTool('test_tool'); + $this->assertEquals('second', ($toolRef->handler)()); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null, + ); + } + + private function createValidResource(string $uri): Resource + { + return new Resource( + uri: $uri, + name: 'test_resource', + description: 'Test resource', + mimeType: 'text/plain', + ); + } + + private function createValidResourceTemplate(string $uriTemplate): ResourceTemplate + { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: 'test_template', + description: 'Test resource template', + mimeType: 'text/plain', + ); + } + + private function createValidPrompt(string $name): Prompt + { + return new Prompt( + name: $name, + description: "Test prompt: {$name}", + arguments: [], + ); + } +} diff --git a/tests/Capability/Resource/ResourceReaderTest.php b/tests/Capability/Resource/ResourceReaderTest.php new file mode 100644 index 00000000..2206ce92 --- /dev/null +++ b/tests/Capability/Resource/ResourceReaderTest.php @@ -0,0 +1,522 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + + $this->resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + ); + } + + public function testReadResourceSuccessfullyWithStringResult(): void + { + $request = new ReadResourceRequest('file://test.txt'); + $resource = $this->createValidResource('file://test.txt', 'test', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => 'test content'); + $handlerResult = 'test content'; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('file://test.txt') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'file://test.txt']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('test content', $result->contents[0]->text); + $this->assertEquals('file://test.txt', $result->contents[0]->uri); + $this->assertEquals('text/plain', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithArrayResult(): void + { + $request = new ReadResourceRequest('api://data'); + $resource = $this->createValidResource('api://data', 'data', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => ['key' => 'value', 'count' => 42]); + $handlerResult = ['key' => 'value', 'count' => 42]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('api://data') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'api://data']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertJsonStringEqualsJsonString( + json_encode($handlerResult, \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + $this->assertEquals('api://data', $result->contents[0]->uri); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithBlobResult(): void + { + $request = new ReadResourceRequest('file://image.png'); + $resource = $this->createValidResource('file://image.png', 'image', 'image/png'); + + $handlerResult = [ + 'blob' => base64_encode('binary data'), + 'mimeType' => 'image/png', + ]; + + $resourceReference = new ResourceReference($resource, fn () => $handlerResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('file://image.png') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'file://image.png']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(BlobResourceContents::class, $result->contents[0]); + $this->assertEquals(base64_encode('binary data'), $result->contents[0]->blob); + $this->assertEquals('file://image.png', $result->contents[0]->uri); + $this->assertEquals('image/png', $result->contents[0]->mimeType); + } + + public function testReadResourceSuccessfullyWithResourceContentResult(): void + { + $request = new ReadResourceRequest('custom://resource'); + $resource = $this->createValidResource('custom://resource', 'resource', 'text/plain'); + $textContent = new TextResourceContents('custom://resource', 'text/plain', 'direct content'); + $resourceReference = new ResourceReference($resource, fn () => $textContent); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('custom://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'custom://resource']) + ->willReturn($textContent); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertSame($textContent, $result->contents[0]); + } + + public function testReadResourceSuccessfullyWithMultipleContentResults(): void + { + $request = new ReadResourceRequest('multi://content'); + $resource = $this->createValidResource('multi://content', 'content', 'application/json'); + $content1 = new TextResourceContents('multi://content', 'text/plain', 'first content'); + $content2 = new TextResourceContents('multi://content', 'text/plain', 'second content'); + $handlerResult = [$content1, $content2]; + $resourceReference = new ResourceReference($resource, fn () => $handlerResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('multi://content') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'multi://content']) + ->willReturn($handlerResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(2, $result->contents); + $this->assertSame($content1, $result->contents[0]); + $this->assertSame($content2, $result->contents[1]); + } + + public function testReadResourceTemplate(): void + { + $request = new ReadResourceRequest('users://123'); + $resourceTemplate = $this->createValidResourceTemplate('users://{id}', 'user_template'); + $templateReference = new ResourceTemplateReference( + $resourceTemplate, + fn () => ['id' => 123, 'name' => 'Test User'], + ); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('users://123') + ->willReturn($templateReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($templateReference, ['uri' => 'users://123']) + ->willReturn(['id' => 123, 'name' => 'Test User']); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertJsonStringEqualsJsonString( + json_encode(['id' => 123, 'name' => 'Test User'], \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + } + + public function testReadResourceThrowsExceptionWhenResourceNotFound(): void + { + $request = new ReadResourceRequest('nonexistent://resource'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('nonexistent://resource') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".'); + + $this->resourceReader->read($request); + } + + public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void + { + $request = new ReadResourceRequest('failing://resource'); + $resource = $this->createValidResource('failing://resource', 'failing', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = new RegistryException('Handler execution failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('failing://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'failing://resource']) + ->willThrowException($handlerException); + + $this->expectException(ResourceReadException::class); + $this->expectExceptionMessage('Handler execution failed'); + + $this->resourceReader->read($request); + } + + public function testReadResourcePassesCorrectArgumentsToHandler(): void + { + $request = new ReadResourceRequest('test://resource'); + $resource = $this->createValidResource('test://resource', 'test', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => 'test'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('test://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with( + $this->identicalTo($resourceReference), + $this->equalTo(['uri' => 'test://resource']), + ) + ->willReturn('test'); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + } + + public function testReadResourceWithEmptyStringResult(): void + { + $request = new ReadResourceRequest('empty://resource'); + $resource = $this->createValidResource('empty://resource', 'empty', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => ''); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('empty://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'empty://resource']) + ->willReturn(''); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('', $result->contents[0]->text); + } + + public function testReadResourceWithEmptyArrayResult(): void + { + $request = new ReadResourceRequest('empty://array'); + $resource = $this->createValidResource('empty://array', 'array', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('empty://array') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'empty://array']) + ->willReturn([]); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('[]', $result->contents[0]->text); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + } + + public function testReadResourceWithNullResult(): void + { + $request = new ReadResourceRequest('null://resource'); + $resource = $this->createValidResource('null://resource', 'null', 'text/plain'); + $resourceReference = new ResourceReference($resource, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('null://resource') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'null://resource']) + ->willReturn(null); + + // The formatResult method in ResourceReference should handle null values + $this->expectException(\RuntimeException::class); + + $this->resourceReader->read($request); + } + + public function testReadResourceWithDifferentMimeTypes(): void + { + $request = new ReadResourceRequest('xml://data'); + $resource = $this->createValidResource('xml://data', 'data', 'application/xml'); + $resourceReference = new ResourceReference($resource, fn () => 'value'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('xml://data') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'xml://data']) + ->willReturn('value'); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + // The MIME type should be guessed from content since formatResult handles the conversion + $this->assertEquals('value', $result->contents[0]->text); + } + + public function testReadResourceWithJsonMimeTypeAndArrayResult(): void + { + $request = new ReadResourceRequest('api://json'); + $resource = $this->createValidResource('api://json', 'json', 'application/json'); + $resourceReference = new ResourceReference($resource, fn () => ['formatted' => true, 'data' => [1, 2, 3]]); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('api://json') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'api://json']) + ->willReturn(['formatted' => true, 'data' => [1, 2, 3]]); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertCount(1, $result->contents); + $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); + $this->assertEquals('application/json', $result->contents[0]->mimeType); + $this->assertJsonStringEqualsJsonString( + json_encode(['formatted' => true, 'data' => [1, 2, 3]], \JSON_PRETTY_PRINT), + $result->contents[0]->text, + ); + } + + public function testReadResourceCallsFormatResultOnReference(): void + { + $request = new ReadResourceRequest('format://test'); + $resource = $this->createValidResource('format://test', 'format', 'text/plain'); + + // Create a mock ResourceReference to verify formatResult is called + $resourceReference = $this + ->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([$resource, fn () => 'test', false]) + ->onlyMethods(['formatResult']) + ->getMock(); + + $handlerResult = 'test result'; + $formattedResult = [new TextResourceContents('format://test', 'text/plain', 'formatted content')]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with('format://test') + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => 'format://test']) + ->willReturn($handlerResult); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with($handlerResult, 'format://test') + ->willReturn($formattedResult); + + $result = $this->resourceReader->read($request); + + $this->assertInstanceOf(ReadResourceResult::class, $result); + $this->assertSame($formattedResult, $result->contents); + } + + public function testConstructorWithDefaultLogger(): void + { + $resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + ); + + $this->assertInstanceOf(ResourceReader::class, $resourceReader); + } + + public function testConstructorWithCustomLogger(): void + { + $logger = $this->createMock(LoggerInterface::class); + $resourceReader = new ResourceReader( + $this->referenceProvider, + $this->referenceHandler, + $logger, + ); + + $this->assertInstanceOf(ResourceReader::class, $resourceReader); + } + + private function createValidResource(string $uri, string $name, ?string $mimeType = null): Resource + { + return new Resource( + uri: $uri, + name: $name, + description: "Test resource: {$name}", + mimeType: $mimeType, + size: null, + annotations: null, + ); + } + + private function createValidResourceTemplate( + string $uriTemplate, + string $name, + ?string $mimeType = null, + ): ResourceTemplate { + return new ResourceTemplate( + uriTemplate: $uriTemplate, + name: $name, + description: "Test resource template: {$name}", + mimeType: $mimeType, + annotations: null, + ); + } +} diff --git a/tests/Capability/Tool/ToolCallerTest.php b/tests/Capability/Tool/ToolCallerTest.php new file mode 100644 index 00000000..8894dc93 --- /dev/null +++ b/tests/Capability/Tool/ToolCallerTest.php @@ -0,0 +1,628 @@ +referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->toolCaller = new ToolCaller( + $this->referenceProvider, + $this->referenceHandler, + $this->logger, + ); + } + + public function testCallExecutesToolSuccessfully(): void + { + $request = new CallToolRequest('test_tool', ['param' => 'value']); + $tool = $this->createValidTool('test_tool'); + $toolReference = new ToolReference($tool, fn () => 'test result'); + $handlerResult = 'test result'; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) + ->willReturn($handlerResult); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug') + ->with( + $this->logicalOr( + $this->equalTo('Executing tool'), + $this->equalTo('Tool executed successfully') + ), + $this->logicalOr( + $this->equalTo(['name' => 'test_tool', 'arguments' => ['param' => 'value']]), + $this->equalTo(['name' => 'test_tool', 'result_type' => 'string']) + ) + ); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('test result', $result->content[0]->text); + $this->assertFalse($result->isError); + } + + public function testCallWithEmptyArguments(): void + { + $request = new CallToolRequest('test_tool', []); + $tool = $this->createValidTool('test_tool'); + $toolReference = new ToolReference($tool, fn () => 'result'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('result'); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = new CallToolRequest('complex_tool', $arguments); + $tool = $this->createValidTool('complex_tool'); + $toolReference = new ToolReference($tool, fn () => ['processed' => true]); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('complex_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn(['processed' => true]); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + } + + public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void + { + $request = new CallToolRequest('nonexistent_tool', ['param' => 'value']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('nonexistent_tool') + ->willReturn(null); + + $this->referenceHandler + ->expects($this->never()) + ->method('handle'); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Executing tool', ['name' => 'nonexistent_tool', 'arguments' => ['param' => 'value']]); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('Tool not found', ['name' => 'nonexistent_tool']); + + $this->expectException(ToolNotFoundException::class); + $this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".'); + + $this->toolCaller->call($request); + } + + public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void + { + $request = new CallToolRequest('failing_tool', ['param' => 'value']); + $tool = $this->createValidTool('failing_tool'); + $toolReference = new ToolReference($tool, fn () => throw new \RuntimeException('Handler failed')); + $handlerException = new \RuntimeException('Handler failed'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) + ->willThrowException($handlerException); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('Executing tool', ['name' => 'failing_tool', 'arguments' => ['param' => 'value']]); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Tool execution failed', + $this->callback(function ($context) { + return 'failing_tool' === $context['name'] + && 'Handler failed' === $context['exception'] + && isset($context['trace']); + }) + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".'); + + $thrownException = null; + try { + $this->toolCaller->call($request); + } catch (ToolCallException $e) { + $thrownException = $e; + throw $e; + } finally { + if ($thrownException) { + $this->assertSame($request, $thrownException->request); + $this->assertSame($handlerException, $thrownException->getPrevious()); + } + } + } + + public function testCallHandlesNullResult(): void + { + $request = new CallToolRequest('null_tool', []); + $tool = $this->createValidTool('null_tool'); + $toolReference = new ToolReference($tool, fn () => null); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('null_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(null); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('(null)', $result->content[0]->text); + } + + public function testCallHandlesBooleanResults(): void + { + $request = new CallToolRequest('bool_tool', []); + $tool = $this->createValidTool('bool_tool'); + $toolReference = new ToolReference($tool, fn () => true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('bool_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(true); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('true', $result->content[0]->text); + } + + public function testCallHandlesArrayResults(): void + { + $request = new CallToolRequest('array_tool', []); + $tool = $this->createValidTool('array_tool'); + $toolReference = new ToolReference($tool, fn () => ['key' => 'value', 'number' => 42]); + $arrayResult = ['key' => 'value', 'number' => 42]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($arrayResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertJsonStringEqualsJsonString( + json_encode($arrayResult, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), + $result->content[0]->text + ); + } + + public function testCallHandlesContentObjectResults(): void + { + $request = new CallToolRequest('content_tool', []); + $tool = $this->createValidTool('content_tool'); + $toolReference = new ToolReference($tool, fn () => new TextContent('Direct content')); + $contentResult = new TextContent('Direct content'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('content_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($contentResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertSame($contentResult, $result->content[0]); + } + + public function testCallHandlesArrayOfContentResults(): void + { + $request = new CallToolRequest('content_array_tool', []); + $tool = $this->createValidTool('content_array_tool'); + $toolReference = new ToolReference($tool, fn () => [ + new TextContent('First content'), + new TextContent('Second content'), + ]); + $contentArray = [ + new TextContent('First content'), + new TextContent('Second content'), + ]; + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('content_array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($contentArray); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(2, $result->content); + $this->assertSame($contentArray[0], $result->content[0]); + $this->assertSame($contentArray[1], $result->content[1]); + } + + public function testCallWithDifferentExceptionTypes(): void + { + $request = new CallToolRequest('error_tool', []); + $tool = $this->createValidTool('error_tool'); + $toolReference = new ToolReference($tool, fn () => throw new \InvalidArgumentException('Invalid input')); + $handlerException = new \InvalidArgumentException('Invalid input'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('error_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willThrowException($handlerException); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Tool execution failed', + $this->callback(function ($context) { + return 'error_tool' === $context['name'] + && 'Invalid input' === $context['exception'] + && isset($context['trace']); + }) + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); + + $this->toolCaller->call($request); + } + + public function testCallLogsResultTypeCorrectlyForString(): void + { + $request = new CallToolRequest('string_tool', []); + $tool = $this->createValidTool('string_tool'); + $toolReference = new ToolReference($tool, fn () => 'string result'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('string_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('string result'); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallLogsResultTypeCorrectlyForInteger(): void + { + $request = new CallToolRequest('int_tool', []); + $tool = $this->createValidTool('int_tool'); + $toolReference = new ToolReference($tool, fn () => 42); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('int_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(42); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testCallLogsResultTypeCorrectlyForArray(): void + { + $request = new CallToolRequest('array_tool', []); + $tool = $this->createValidTool('array_tool'); + $toolReference = new ToolReference($tool, fn () => ['test']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(['test']); + + $this->logger + ->expects($this->exactly(2)) + ->method('debug'); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + } + + public function testConstructorWithDefaultLogger(): void + { + $executor = new ToolCaller($this->referenceProvider, $this->referenceHandler); + + // Verify it's constructed without throwing exceptions + $this->assertInstanceOf(ToolCaller::class, $executor); + } + + public function testCallHandlesEmptyArrayResult(): void + { + $request = new CallToolRequest('empty_array_tool', []); + $tool = $this->createValidTool('empty_array_tool'); + $toolReference = new ToolReference($tool, fn () => []); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('empty_array_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn([]); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('[]', $result->content[0]->text); + } + + public function testCallHandlesMixedContentAndNonContentArray(): void + { + $request = new CallToolRequest('mixed_tool', []); + $tool = $this->createValidTool('mixed_tool'); + $mixedResult = [ + new TextContent('First content'), + 'plain string', + 42, + new TextContent('Second content'), + ]; + $toolReference = new ToolReference($tool, fn () => $mixedResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('mixed_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($mixedResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + // The ToolReference.formatResult should handle this mixed array + $this->assertGreaterThan(1, \count($result->content)); + } + + public function testCallHandlesStdClassResult(): void + { + $request = new CallToolRequest('object_tool', []); + $tool = $this->createValidTool('object_tool'); + $objectResult = new \stdClass(); + $objectResult->property = 'value'; + $toolReference = new ToolReference($tool, fn () => $objectResult); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('object_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn($objectResult); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertStringContainsString('"property": "value"', $result->content[0]->text); + } + + public function testCallHandlesBooleanFalseResult(): void + { + $request = new CallToolRequest('false_tool', []); + $tool = $this->createValidTool('false_tool'); + $toolReference = new ToolReference($tool, fn () => false); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('false_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(false); + + $result = $this->toolCaller->call($request); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('false', $result->content[0]->text); + } + + private function createValidTool(string $name): Tool + { + return new Tool( + name: $name, + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + 'required' => null, + ], + description: "Test tool: {$name}", + annotations: null, + ); + } +} diff --git a/tests/Schema/ServerCapabilitiesTest.php b/tests/Schema/ServerCapabilitiesTest.php new file mode 100644 index 00000000..3a9f2b99 --- /dev/null +++ b/tests/Schema/ServerCapabilitiesTest.php @@ -0,0 +1,406 @@ +assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertFalse($capabilities->promptsListChanged); + $this->assertFalse($capabilities->logging); + $this->assertFalse($capabilities->completions); + $this->assertNull($capabilities->experimental); + } + + public function testConstructorWithAllParameters(): void + { + $experimental = ['feature1' => true, 'feature2' => 'enabled']; + + $capabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: false, + promptsListChanged: true, + logging: true, + completions: true, + experimental: $experimental + ); + + $this->assertFalse($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertFalse($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->resourcesListChanged); + $this->assertFalse($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertEquals($experimental, $capabilities->experimental); + } + + public function testConstructorWithNullValues(): void + { + $capabilities = new ServerCapabilities( + tools: null, + toolsListChanged: null, + resources: null, + resourcesSubscribe: null, + resourcesListChanged: null, + prompts: null, + promptsListChanged: null, + logging: null, + completions: null, + experimental: null + ); + + $this->assertNull($capabilities->tools); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->resources); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertNull($capabilities->prompts); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->logging); + $this->assertNull($capabilities->completions); + $this->assertNull($capabilities->experimental); + } + + public function testFromArrayWithEmptyArray(): void + { + $capabilities = ServerCapabilities::fromArray([]); + + $this->assertFalse($capabilities->logging); + $this->assertFalse($capabilities->completions); + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->prompts); + $this->assertFalse($capabilities->resources); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertNull($capabilities->experimental); + } + + public function testFromArrayWithBasicCapabilities(): void + { + $data = [ + 'tools' => new \stdClass(), + 'resources' => new \stdClass(), + 'prompts' => new \stdClass(), + 'logging' => new \stdClass(), + 'completions' => new \stdClass(), + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertNull($capabilities->toolsListChanged); + $this->assertNull($capabilities->promptsListChanged); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + } + + public function testFromArrayWithPromptsArrayListChanged(): void + { + $data = [ + 'prompts' => ['listChanged' => true], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testFromArrayWithPromptsObjectListChanged(): void + { + $prompts = new \stdClass(); + $prompts->listChanged = true; + + $data = [ + 'prompts' => $prompts, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + } + + public function testFromArrayWithResourcesArraySubscribeAndListChanged(): void + { + $data = [ + 'resources' => [ + 'subscribe' => true, + 'listChanged' => false, + ], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + } + + public function testFromArrayWithResourcesObjectSubscribeAndListChanged(): void + { + $resources = new \stdClass(); + $resources->subscribe = false; + $resources->listChanged = true; + + $data = [ + 'resources' => $resources, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->resources); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->resourcesListChanged); + } + + public function testFromArrayWithToolsArrayListChanged(): void + { + $data = [ + 'tools' => ['listChanged' => false], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertFalse($capabilities->toolsListChanged); + } + + public function testFromArrayWithToolsObjectListChanged(): void + { + $tools = new \stdClass(); + $tools->listChanged = true; + + $data = [ + 'tools' => $tools, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + } + + public function testFromArrayWithExperimental(): void + { + $experimental = ['feature1' => true, 'feature2' => 'test']; + $data = [ + 'experimental' => $experimental, + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertEquals($experimental, $capabilities->experimental); + } + + public function testFromArrayWithComplexData(): void + { + $data = [ + 'tools' => ['listChanged' => true], + 'resources' => [ + 'subscribe' => true, + 'listChanged' => false, + ], + 'prompts' => ['listChanged' => true], + 'logging' => new \stdClass(), + 'completions' => new \stdClass(), + 'experimental' => ['customFeature' => 'enabled'], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->tools); + $this->assertTrue($capabilities->toolsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertTrue($capabilities->resourcesSubscribe); + $this->assertFalse($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->prompts); + $this->assertTrue($capabilities->promptsListChanged); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->completions); + $this->assertEquals(['customFeature' => 'enabled'], $capabilities->experimental); + } + + public function testJsonSerializeWithDefaults(): void + { + $capabilities = new ServerCapabilities(); + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'tools' => new \stdClass(), + 'resources' => new \stdClass(), + 'prompts' => new \stdClass(), + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithAllFeaturesEnabled(): void + { + $experimental = ['feature1' => true]; + $capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: true, + resources: true, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: true, + promptsListChanged: true, + logging: true, + completions: true, + experimental: $experimental + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertArrayHasKey('logging', $json); + $this->assertEquals(new \stdClass(), $json['logging']); + + $this->assertArrayHasKey('completions', $json); + $this->assertEquals(new \stdClass(), $json['completions']); + + $this->assertArrayHasKey('prompts', $json); + $this->assertTrue($json['prompts']->listChanged); + + $this->assertArrayHasKey('resources', $json); + $this->assertTrue($json['resources']->subscribe); + $this->assertTrue($json['resources']->listChanged); + + $this->assertArrayHasKey('tools', $json); + $this->assertTrue($json['tools']->listChanged); + + $this->assertArrayHasKey('experimental', $json); + $this->assertEquals((object) $experimental, $json['experimental']); + } + + public function testJsonSerializeWithFalseValues(): void + { + $capabilities = new ServerCapabilities( + tools: false, + resources: false, + prompts: false, + logging: false, + completions: false + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertEquals([], $json); + } + + public function testJsonSerializeWithMixedValues(): void + { + $capabilities = new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: false, + resourcesSubscribe: true, + resourcesListChanged: true, + prompts: true, + promptsListChanged: false, + logging: false, + completions: true + ); + + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'completions' => new \stdClass(), + 'prompts' => new \stdClass(), + 'resources' => (object) [ + 'subscribe' => true, + 'listChanged' => true, + ], + 'tools' => new \stdClass(), + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithOnlyListChangedFlags(): void + { + $capabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesListChanged: true, + prompts: false, + promptsListChanged: true + ); + + $json = $capabilities->jsonSerialize(); + + $expected = [ + 'prompts' => (object) ['listChanged' => true], + 'resources' => (object) ['listChanged' => true], + 'tools' => (object) ['listChanged' => true], + ]; + + $this->assertEquals($expected, $json); + } + + public function testJsonSerializeWithNullExperimental(): void + { + $capabilities = new ServerCapabilities( + tools: true, + experimental: null + ); + + $json = $capabilities->jsonSerialize(); + + $this->assertArrayNotHasKey('experimental', $json); + $this->assertArrayHasKey('tools', $json); + } + + public function testFromArrayHandlesEdgeCasesGracefully(): void + { + $data = [ + 'prompts' => [], + 'resources' => [], + 'tools' => [], + ]; + + $capabilities = ServerCapabilities::fromArray($data); + + $this->assertTrue($capabilities->prompts); + $this->assertNull($capabilities->promptsListChanged); + $this->assertTrue($capabilities->resources); + $this->assertNull($capabilities->resourcesSubscribe); + $this->assertNull($capabilities->resourcesListChanged); + $this->assertTrue($capabilities->tools); + $this->assertNull($capabilities->toolsListChanged); + } +} diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php new file mode 100644 index 00000000..e8f13622 --- /dev/null +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -0,0 +1,294 @@ +toolExecutor = $this->createMock(ToolCallerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new CallToolHandler( + $this->toolExecutor, + $this->logger, + ); + } + + public function testSupportsCallToolRequest(): void + { + $request = $this->createCallToolRequest('test_tool', ['param' => 'value']); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulToolCall(): void + { + $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); + $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $this->logger + ->expects($this->never()) + ->method('error'); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolCallWithEmptyArguments(): void + { + $request = $this->createCallToolRequest('simple_tool', []); + $expectedResult = new CallToolResult([new TextContent('Simple result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolCallWithComplexArguments(): void + { + $arguments = [ + 'string_param' => 'value', + 'int_param' => 42, + 'bool_param' => true, + 'array_param' => ['nested' => 'data'], + 'null_param' => null, + ]; + $request = $this->createCallToolRequest('complex_tool', $arguments); + $expectedResult = new CallToolResult([new TextContent('Complex result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleToolNotFoundExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); + $exception = new ToolNotFoundException($request); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "nonexistent_tool": "Tool not found for call: "nonexistent_tool".".', + [ + 'tool' => 'nonexistent_tool', + 'arguments' => ['param' => 'value'], + ], + ); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); + } + + public function testHandleToolExecutionExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".', + [ + 'tool' => 'failing_tool', + 'arguments' => ['param' => 'value'], + ], + ); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); + } + + public function testHandleWithNullResult(): void + { + $request = $this->createCallToolRequest('null_tool', []); + $expectedResult = new CallToolResult([]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleWithErrorResult(): void + { + $request = $this->createCallToolRequest('error_tool', []); + $expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertTrue($response->result->isError); + } + + public function testConstructorWithDefaultLogger(): void + { + $handler = new CallToolHandler($this->toolExecutor); + + $this->assertInstanceOf(CallToolHandler::class, $handler); + } + + public function testHandleLogsErrorWithCorrectParameters(): void + { + $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); + $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', + [ + 'tool' => 'test_tool', + 'arguments' => ['key1' => 'value1', 'key2' => 42], + ], + ); + + $this->handler->handle($request); + } + + public function testHandleWithSpecialCharactersInToolName(): void + { + $request = $this->createCallToolRequest('tool-with_special.chars', []); + $expectedResult = new CallToolResult([new TextContent('Special tool result')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleWithSpecialCharactersInArguments(): void + { + $arguments = [ + 'special_chars' => 'äöü ñ 中文 🚀', + 'unicode' => '\\u{1F600}', + 'quotes' => 'text with "quotes" and \'single quotes\'', + ]; + $request = $this->createCallToolRequest('unicode_tool', $arguments); + $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); + + $this->toolExecutor + ->expects($this->once()) + ->method('call') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + /** + * @param array $arguments + */ + private function createCallToolRequest(string $name, array $arguments): CallToolRequest + { + return CallToolRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => CallToolRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ]); + } +} diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php new file mode 100644 index 00000000..3debaa05 --- /dev/null +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -0,0 +1,342 @@ +promptGetter = $this->createMock(PromptGetterInterface::class); + + $this->handler = new GetPromptHandler($this->promptGetter); + } + + public function testSupportsGetPromptRequest(): void + { + $request = $this->createGetPromptRequest('test_prompt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulPromptGet(): void + { + $request = $this->createGetPromptRequest('greeting_prompt'); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')), + ]; + $expectedResult = new GetPromptResult( + description: 'A greeting prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithArguments(): void + { + $arguments = [ + 'name' => 'John', + 'context' => 'business meeting', + 'formality' => 'formal', + ]; + $request = $this->createGetPromptRequest('personalized_prompt', $arguments); + $expectedMessages = [ + new PromptMessage( + Role::User, + new TextContent('Good morning, John. How may I assist you in your business meeting?'), + ), + ]; + $expectedResult = new GetPromptResult( + description: 'A personalized greeting prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithNullArguments(): void + { + $request = $this->createGetPromptRequest('simple_prompt', null); + $expectedMessages = [ + new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')), + ]; + $expectedResult = new GetPromptResult( + description: 'A simple prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithEmptyArguments(): void + { + $request = $this->createGetPromptRequest('empty_args_prompt', []); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Default message')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt with empty arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithMultipleMessages(): void + { + $request = $this->createGetPromptRequest('conversation_prompt'); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Hello')), + new PromptMessage(Role::Assistant, new TextContent('Hi there! How can I help you today?')), + new PromptMessage(Role::User, new TextContent('I need assistance with my project')), + ]; + $expectedResult = new GetPromptResult( + description: 'A conversation prompt', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(3, $response->result->messages); + } + + public function testHandlePromptNotFoundExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('nonexistent_prompt'); + $exception = new PromptNotFoundException($request); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while handling prompt', $response->message); + } + + public function testHandlePromptGetExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('failing_prompt'); + $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while handling prompt', $response->message); + } + + public function testHandlePromptGetWithComplexArguments(): void + { + $arguments = [ + 'user_data' => [ + 'name' => 'Alice', + 'preferences' => ['formal', 'concise'], + 'history' => [ + 'last_interaction' => '2025-01-15', + 'topics' => ['technology', 'business'], + ], + ], + 'context' => 'technical consultation', + 'metadata' => [ + 'session_id' => 'sess_123456', + 'timestamp' => 1705392000, + ], + ]; + $request = $this->createGetPromptRequest('complex_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Complex prompt generated with all parameters')), + ]; + $expectedResult = new GetPromptResult( + description: 'A complex prompt with nested arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetWithSpecialCharacters(): void + { + $arguments = [ + 'message' => 'Hello 世界! How are you? 😊', + 'special' => 'äöü ñ ß', + 'quotes' => 'Text with "double" and \'single\' quotes', + ]; + $request = $this->createGetPromptRequest('unicode_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Unicode message processed')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt handling unicode characters', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandlePromptGetReturnsEmptyMessages(): void + { + $request = $this->createGetPromptRequest('empty_prompt'); + $expectedResult = new GetPromptResult( + description: 'An empty prompt', + messages: [], + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(0, $response->result->messages); + } + + public function testHandlePromptGetWithLargeNumberOfArguments(): void + { + $arguments = []; + for ($i = 0; $i < 100; ++$i) { + $arguments["arg_{$i}"] = "value_{$i}"; + } + + $request = $this->createGetPromptRequest('many_args_prompt', $arguments); + $expectedMessages = [ + new PromptMessage(Role::User, new TextContent('Processed 100 arguments')), + ]; + $expectedResult = new GetPromptResult( + description: 'A prompt with many arguments', + messages: $expectedMessages, + ); + + $this->promptGetter + ->expects($this->once()) + ->method('get') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + /** + * @param array|null $arguments + */ + private function createGetPromptRequest(string $name, ?array $arguments = null): GetPromptRequest + { + return GetPromptRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => GetPromptRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ]); + } +} diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php new file mode 100644 index 00000000..0904d68b --- /dev/null +++ b/tests/Server/RequestHandler/PingHandlerTest.php @@ -0,0 +1,147 @@ +handler = new PingHandler(); + } + + public function testSupportsPingRequest(): void + { + $request = $this->createPingRequest(); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandlePingRequest(): void + { + $request = $this->createPingRequest(); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + public function testHandleMultiplePingRequests(): void + { + $request1 = $this->createPingRequest(); + $request2 = $this->createPingRequest(); + + $response1 = $this->handler->handle($request1); + $response2 = $this->handler->handle($request2); + + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + $this->assertEquals($request1->getId(), $response1->id); + $this->assertEquals($request2->getId(), $response2->id); + } + + public function testHandlerHasNoSideEffects(): void + { + $request = $this->createPingRequest(); + + // Handle same request multiple times + $response1 = $this->handler->handle($request); + $response2 = $this->handler->handle($request); + + // Both responses should be identical + $this->assertEquals($response1->id, $response2->id); + $this->assertEquals( + \get_class($response1->result), + \get_class($response2->result), + ); + } + + public function testEmptyResultIsCorrectType(): void + { + $request = $this->createPingRequest(); + $response = $this->handler->handle($request); + + $this->assertInstanceOf(EmptyResult::class, $response->result); + + // Verify EmptyResult serializes to empty object + $serialized = json_encode($response->result); + $this->assertEquals('{}', $serialized); + } + + public function testHandlerIsStateless(): void + { + $handler1 = new PingHandler(); + $handler2 = new PingHandler(); + + $request = $this->createPingRequest(); + + $response1 = $handler1->handle($request); + $response2 = $handler2->handle($request); + + // Both handlers should produce equivalent results + $this->assertEquals($response1->id, $response2->id); + $this->assertEquals( + \get_class($response1->result), + \get_class($response2->result), + ); + } + + public function testSupportsMethodIsConsistent(): void + { + $request = $this->createPingRequest(); + + // Multiple calls to supports should return same result + $this->assertTrue($this->handler->supports($request)); + $this->assertTrue($this->handler->supports($request)); + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandlerCanBeReused(): void + { + $requests = []; + $responses = []; + + // Create multiple ping requests + for ($i = 0; $i < 5; ++$i) { + $requests[$i] = $this->createPingRequest(); + $responses[$i] = $this->handler->handle($requests[$i]); + } + + // All responses should be valid + foreach ($responses as $i => $response) { + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($requests[$i]->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + } + + private function createPingRequest(): Request + { + return PingRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => PingRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + ]); + } +} diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php new file mode 100644 index 00000000..6cb8acc6 --- /dev/null +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -0,0 +1,351 @@ +resourceReader = $this->createMock(ResourceReaderInterface::class); + + $this->handler = new ReadResourceHandler($this->resourceReader); + } + + public function testSupportsReadResourceRequest(): void + { + $request = $this->createReadResourceRequest('file://test.txt'); + + $this->assertTrue($this->handler->supports($request)); + } + + public function testHandleSuccessfulResourceRead(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'This is the content of the readme file.', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithBlobContent(): void + { + $uri = 'file://images/logo.png'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new BlobResourceContents( + uri: $uri, + mimeType: 'image/png', + blob: base64_encode('fake-image-data'), + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithMultipleContents(): void + { + $uri = 'app://data/mixed-content'; + $request = $this->createReadResourceRequest($uri); + $textContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'Text part of the resource', + ); + $blobContent = new BlobResourceContents( + uri: $uri, + mimeType: 'application/octet-stream', + blob: base64_encode('binary-data'), + ); + $expectedResult = new ReadResourceResult([$textContent, $blobContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(2, $response->result->contents); + } + + public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($request); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + public function testHandleResourceReadExceptionReturnsGenericError(): void + { + $uri = 'file://corrupted/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceReadException( + $request, + new \RuntimeException('Failed to read resource: corrupted data'), + ); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while reading resource', $response->message); + } + + public function testHandleResourceReadWithDifferentUriSchemes(): void + { + $uriSchemes = [ + 'file://local/path/file.txt', + 'http://example.com/resource', + 'https://secure.example.com/api/data', + 'ftp://files.example.com/document.pdf', + 'app://internal/resource/123', + 'custom-scheme://special/resource', + ]; + + foreach ($uriSchemes as $uri) { + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: "Content for {$uri}", + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + + // Reset the mock for next iteration + $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->handler = new ReadResourceHandler($this->resourceReader); + } + } + + public function testHandleResourceReadWithSpecialCharactersInUri(): void + { + $uri = 'file://path/with spaces/äöü-file-ñ.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: 'Content with unicode characters: äöü ñ 世界 🚀', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + } + + public function testHandleResourceReadWithEmptyContent(): void + { + $uri = 'file://empty/file.txt'; + $request = $this->createReadResourceRequest($uri); + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: 'text/plain', + text: '', + ); + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertInstanceOf(TextResourceContents::class, $response->result->contents[0]); + $this->assertEquals('', $response->result->contents[0]->text); + } + + public function testHandleResourceReadWithDifferentMimeTypes(): void + { + $mimeTypes = [ + 'text/plain', + 'text/html', + 'application/json', + 'application/xml', + 'image/png', + 'image/jpeg', + 'application/pdf', + 'video/mp4', + 'audio/mpeg', + 'application/octet-stream', + ]; + + foreach ($mimeTypes as $i => $mimeType) { + $uri = "file://test/file{$i}"; + $request = $this->createReadResourceRequest($uri); + + if (str_starts_with($mimeType, 'text/') || str_starts_with($mimeType, 'application/json')) { + $expectedContent = new TextResourceContents( + uri: $uri, + mimeType: $mimeType, + text: "Content for {$mimeType}", + ); + } else { + $expectedContent = new BlobResourceContents( + uri: $uri, + mimeType: $mimeType, + blob: base64_encode("binary-content-for-{$mimeType}"), + ); + } + $expectedResult = new ReadResourceResult([$expectedContent]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertEquals($mimeType, $response->result->contents[0]->mimeType); + + // Reset the mock for next iteration + $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->handler = new ReadResourceHandler($this->resourceReader); + } + } + + public function testHandleResourceNotFoundWithCustomMessage(): void + { + $uri = 'file://custom/missing.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($request); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willThrowException($exception); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + public function testHandleResourceReadWithEmptyResult(): void + { + $uri = 'file://empty/resource'; + $request = $this->createReadResourceRequest($uri); + $expectedResult = new ReadResourceResult([]); + + $this->resourceReader + ->expects($this->once()) + ->method('read') + ->with($request) + ->willReturn($expectedResult); + + $response = $this->handler->handle($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($expectedResult, $response->result); + $this->assertCount(0, $response->result->contents); + } + + private function createReadResourceRequest(string $uri): ReadResourceRequest + { + return ReadResourceRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ReadResourceRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 2c2129e0..19177112 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -32,6 +32,7 @@ public function testJsonExceptions() ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); $transport = $this->getMockBuilder(InMemoryTransport::class) From b1e54f1f86464c140091b242b1c5a1932d7e6459 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 15 Sep 2025 08:30:41 +0200 Subject: [PATCH 07/66] Switch to set* and add* on ServerBuilder instead of with* (#71) --- README.md | 4 +- .../01-discovery-stdio-calculator/server.php | 8 +-- .../02-discovery-http-userprofile/server.php | 12 ++-- .../03-manual-registration-stdio/server.php | 14 ++-- .../04-combined-registration-http/server.php | 12 ++-- examples/05-stdio-env-variables/server.php | 6 +- .../06-custom-dependencies-stdio/server.php | 8 +-- .../07-complex-tool-schema-http/server.php | 8 +-- .../08-schema-showcase-streamable/server.php | 6 +- phpstan-baseline.neon | 46 ++++--------- src/Server/ServerBuilder.php | 66 +++++++++---------- 11 files changed, 83 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 84db63ea..121d8dd4 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ use Mcp\Server; use Mcp\Server\Transport\StdioTransport; Server::make() - ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new StdioTransport()); ``` diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index b54b6e98..ce19dc0e 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -19,10 +19,10 @@ logger()->info('Starting MCP Stdio Calculator Server...'); Server::make() - ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') - ->withContainer(container()) - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new StdioTransport(logger: logger())); diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 2a76580c..1d4ddc45 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -24,11 +24,11 @@ $container->set(LoggerInterface::class, logger()); Server::make() - ->withServerInfo('HTTP User Profiles', '1.0.0') - ->withLogger(logger()) - ->withContainer($container) - ->withDiscovery(__DIR__, ['.']) - ->withTool( + ->setServerInfo('HTTP User Profiles', '1.0.0') + ->setLogger(logger()) + ->setContainer($container) + ->setDiscovery(__DIR__, ['.']) + ->addTool( function (float $a, float $b, string $operation = 'add'): array { $result = match ($operation) { 'add' => $a + $b, @@ -47,7 +47,7 @@ function (float $a, float $b, string $operation = 'add'): array { name: 'calculator', description: 'Perform basic math operations (add, subtract, multiply, divide)' ) - ->withResource( + ->addResource( function (): array { $memoryUsage = memory_get_usage(true); $memoryPeak = memory_get_peak_usage(true); diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php index cea4001c..8a4916fc 100644 --- a/examples/03-manual-registration-stdio/server.php +++ b/examples/03-manual-registration-stdio/server.php @@ -20,13 +20,13 @@ logger()->info('Starting MCP Manual Registration (Stdio) Server...'); Server::make() - ->withServerInfo('Manual Reg Server', '1.0.0') - ->withLogger(logger()) - ->withContainer(container()) - ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') - ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') - ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') - ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') + ->setServerInfo('Manual Reg Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') + ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') + ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') + ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') ->build() ->connect(new StdioTransport(logger: logger())); diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index 4f86c409..f3ea730d 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -20,12 +20,12 @@ logger()->info('Starting MCP Combined Registration (HTTP) Server...'); Server::make() - ->withServerInfo('Combined HTTP Server', '1.0.0') - ->withLogger(logger()) - ->withContainer(container()) - ->withDiscovery(__DIR__, ['.']) - ->withTool([ManualHandlers::class, 'manualGreeter']) - ->withResource( + ->setServerInfo('Combined HTTP Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setDiscovery(__DIR__, ['.']) + ->addTool([ManualHandlers::class, 'manualGreeter']) + ->addResource( [ManualHandlers::class, 'getPriorityConfigManual'], 'config://priority', 'priority_config_manual', diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php index 134a1ae2..1cfaa13d 100644 --- a/examples/05-stdio-env-variables/server.php +++ b/examples/05-stdio-env-variables/server.php @@ -50,9 +50,9 @@ logger()->info('Starting MCP Stdio Environment Variable Example Server...'); Server::make() - ->withServerInfo('Env Var Server', '1.0.0') - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Env Var Server', '1.0.0') + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new StdioTransport(logger: logger())); diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index a9fe46d2..653d7773 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -28,10 +28,10 @@ $container->set(Services\StatsServiceInterface::class, $statsService); Server::make() - ->withServerInfo('Task Manager Server', '1.0.0') - ->withLogger(logger()) - ->withContainer($container) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Task Manager Server', '1.0.0') + ->setLogger(logger()) + ->setContainer($container) + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new StdioTransport(logger: logger())); diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php index bef31e56..44dbc45d 100644 --- a/examples/07-complex-tool-schema-http/server.php +++ b/examples/07-complex-tool-schema-http/server.php @@ -19,10 +19,10 @@ logger()->info('Starting MCP Complex Schema HTTP Server...'); Server::make() - ->withServerInfo('Event Scheduler Server', '1.0.0') - ->withLogger(logger()) - ->withContainer(container()) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Event Scheduler Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler')); diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php index df348a5a..b77b6db8 100644 --- a/examples/08-schema-showcase-streamable/server.php +++ b/examples/08-schema-showcase-streamable/server.php @@ -19,9 +19,9 @@ logger()->info('Starting MCP Schema Showcase Server...'); Server::make() - ->withServerInfo('Schema Showcase', '1.0.0') - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) + ->setServerInfo('Schema Showcase', '1.0.0') + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) ->build() ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1ac806b3..a9382bf3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -463,43 +463,43 @@ parameters: path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php @@ -535,49 +535,25 @@ parameters: path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualPrompts has unknown class Mcp\\Server\\Closure as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualPrompts type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResourceTemplates has unknown class Mcp\\Server\\Closure as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResourceTemplates type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResources has unknown class Mcp\\Server\\Closure as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualResources type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualTools has unknown class Mcp\\Server\\Closure as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$manualTools type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index ede6c77d..4075cdba 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -70,15 +70,15 @@ final class ServerBuilder private ?string $instructions = null; /** @var array< - * array{handler: array|string|Closure, + * array{handler: array|string|\Closure, * name: string|null, * description: string|null, * annotations: ToolAnnotations|null} * > */ - private array $manualTools = []; + private array $tools = []; /** @var array< - * array{handler: array|string|Closure, + * array{handler: array|string|\Closure, * uri: string, * name: string|null, * description: string|null, @@ -86,23 +86,23 @@ final class ServerBuilder * size: int|null, * annotations: Annotations|null} * > */ - private array $manualResources = []; + private array $resources = []; /** @var array< - * array{handler: array|string|Closure, + * array{handler: array|string|\Closure, * uriTemplate: string, * name: string|null, * description: string|null, * mimeType: string|null, * annotations: Annotations|null} * > */ - private array $manualResourceTemplates = []; + private array $resourceTemplates = []; /** @var array< - * array{handler: array|string|Closure, + * array{handler: array|string|\Closure, * name: string|null, * description: string|null} * > */ - private array $manualPrompts = []; + private array $prompts = []; private ?string $discoveryBasePath = null; /** * @var array|string[] @@ -113,7 +113,7 @@ final class ServerBuilder /** * Sets the server's identity. Required. */ - public function withServerInfo(string $name, string $version, ?string $description = null): self + public function setServerInfo(string $name, string $version, ?string $description = null): self { $this->serverInfo = new Implementation(trim($name), trim($version), $description); @@ -123,7 +123,7 @@ public function withServerInfo(string $name, string $version, ?string $descripti /** * Configures the server's pagination limit. */ - public function withPaginationLimit(int $paginationLimit): self + public function setPaginationLimit(int $paginationLimit): self { $this->paginationLimit = $paginationLimit; @@ -137,7 +137,7 @@ public function withPaginationLimit(int $paginationLimit): self * etc. It can be thought of like a "hint" to the model. For example, this information MAY * be added to the system prompt. */ - public function withInstructions(?string $instructions): self + public function setInstructions(?string $instructions): self { $this->instructions = $instructions; @@ -147,35 +147,35 @@ public function withInstructions(?string $instructions): self /** * Provides a PSR-3 logger instance. Defaults to NullLogger. */ - public function withLogger(LoggerInterface $logger): self + public function setLogger(LoggerInterface $logger): self { $this->logger = $logger; return $this; } - public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): self + public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self { $this->eventDispatcher = $eventDispatcher; return $this; } - public function withToolCaller(ToolCallerInterface $toolCaller): self + public function setToolCaller(ToolCallerInterface $toolCaller): self { $this->toolCaller = $toolCaller; return $this; } - public function withResourceReader(ResourceReaderInterface $resourceReader): self + public function setResourceReader(ResourceReaderInterface $resourceReader): self { $this->resourceReader = $resourceReader; return $this; } - public function withPromptGetter(PromptGetterInterface $promptGetter): self + public function setPromptGetter(PromptGetterInterface $promptGetter): self { $this->promptGetter = $promptGetter; @@ -186,14 +186,14 @@ public function withPromptGetter(PromptGetterInterface $promptGetter): self * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. * Defaults to a basic internal container. */ - public function withContainer(ContainerInterface $container): self + public function setContainer(ContainerInterface $container): self { $this->container = $container; return $this; } - public function withDiscovery( + public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], array $excludeDirs = [], @@ -208,14 +208,14 @@ public function withDiscovery( /** * Manually registers a tool handler. */ - public function withTool( + public function addTool( callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, ): self { - $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); return $this; } @@ -223,7 +223,7 @@ public function withTool( /** * Manually registers a resource handler. */ - public function withResource( + public function addResource( callable|array|string $handler, string $uri, ?string $name = null, @@ -232,7 +232,7 @@ public function withResource( ?int $size = null, ?Annotations $annotations = null, ): self { - $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + $this->resources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); return $this; } @@ -240,7 +240,7 @@ public function withResource( /** * Manually registers a resource template handler. */ - public function withResourceTemplate( + public function addResourceTemplate( callable|array|string $handler, string $uriTemplate, ?string $name = null, @@ -248,7 +248,7 @@ public function withResourceTemplate( ?string $mimeType = null, ?Annotations $annotations = null, ): self { - $this->manualResourceTemplates[] = compact( + $this->resourceTemplates[] = compact( 'handler', 'uriTemplate', 'name', @@ -263,9 +263,9 @@ public function withResourceTemplate( /** * Manually registers a prompt handler. */ - public function withPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self + public function addPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self { - $this->manualPrompts[] = compact('handler', 'name', 'description'); + $this->prompts[] = compact('handler', 'name', 'description'); return $this; } @@ -285,7 +285,7 @@ public function build(): Server $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); - $this->registerManualElements($registry, $logger); + $this->registerCapabilities($registry, $logger); if (null !== $this->discoveryBasePath) { $discovery = new Discoverer($registry, $logger); @@ -310,11 +310,11 @@ public function build(): Server * Helper to perform the actual registration based on stored data. * Moved into the builder. */ - private function registerManualElements( + private function registerCapabilities( Registry\ReferenceRegistryInterface $registry, LoggerInterface $logger = new NullLogger(), ): void { - if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { + if (empty($this->tools) && empty($this->resources) && empty($this->resourceTemplates) && empty($this->prompts)) { return; } @@ -322,7 +322,7 @@ private function registerManualElements( $schemaGenerator = new SchemaGenerator($docBlockParser); // Register Tools - foreach ($this->manualTools as $data) { + foreach ($this->tools as $data) { try { $reflection = HandlerResolver::resolve($data['handler']); @@ -357,7 +357,7 @@ private function registerManualElements( } // Register Resources - foreach ($this->manualResources as $data) { + foreach ($this->resources as $data) { try { $reflection = HandlerResolver::resolve($data['handler']); @@ -395,7 +395,7 @@ private function registerManualElements( } // Register Templates - foreach ($this->manualResourceTemplates as $data) { + foreach ($this->resourceTemplates as $data) { try { $reflection = HandlerResolver::resolve($data['handler']); @@ -433,7 +433,7 @@ private function registerManualElements( } // Register Prompts - foreach ($this->manualPrompts as $data) { + foreach ($this->prompts as $data) { try { $reflection = HandlerResolver::resolve($data['handler']); From 719262b15d203a10083713cc62a9b27d070a787d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Sep 2025 11:12:40 +0100 Subject: [PATCH 08/66] feat(server): Rework transport architecture and add StreamableHttpTransport (#49) * feat(server): Rework transport architecture and add StreamableHttpTransport - `Server::connect()` no longer contains a processing loop. - `TransportInterface` is updated with `setMessageHandler()` and `listen()`. - `StdioTransport` is updated to implement the new interface. - A new, minimal `StreamableHttpTransport` is added for stateless HTTP. * refactor(server): use event-driven message handling * feat(server): Introduce a formal session management system * fix: ensure session elements are preserved when building server (regression from #46) * refactor: consolidate HTTP example to use shared dependencies * feat(server): Enhance message handling with session support - Updated `TransportInterface` to use `onMessage` for handling incoming messages with session IDs. - Refactored `Server`, `Handler`, and transport classes to accommodate session management using `Uuid`. - Introduced methods for creating sessions with auto-generated and specific UUIDs in `SessionFactory` and `SessionFactoryInterface`. * feat(server): Integrate session management in message handling - Added session support to the `Server` and `Handler` classes, allowing for session data to be managed during message processing. - Updated `TransportInterface` to include session context in the `send` method. - Refactored various request handlers to utilize session information, ensuring proper session handling for incoming requests. - Introduced a file-based session store for persistent session data management * feat: Update session builder method to use set* instead of with* * feat: Fix test compatibility with new session management architecture * chore: apply code style fixes * fix: remove deprecated SSE transport and resolve PHPStan issues --- .gitignore | 1 + composer.json | 11 +- .../01-discovery-stdio-calculator/server.php | 11 +- .../02-discovery-http-userprofile/server.php | 30 ++- .../03-manual-registration-stdio/server.php | 11 +- .../04-combined-registration-http/server.php | 25 +- examples/05-stdio-env-variables/server.php | 11 +- .../06-custom-dependencies-stdio/server.php | 11 +- .../07-complex-tool-schema-http/server.php | 25 +- .../08-schema-showcase-streamable/server.php | 25 +- examples/09-standalone-cli/index.php | 22 +- .../10-simple-http-transport/McpElements.php | 118 ++++++++++ examples/10-simple-http-transport/README.md | 24 ++ examples/10-simple-http-transport/server.php | 40 ++++ phpstan-baseline.neon | 44 ---- src/JsonRpc/Handler.php | 148 ++++++++++-- src/Server.php | 43 ++-- src/Server/MethodHandlerInterface.php | 3 +- src/Server/NativeClock.php | 24 ++ .../InitializedHandler.php | 5 +- src/Server/RequestHandler/CallToolHandler.php | 3 +- .../RequestHandler/GetPromptHandler.php | 3 +- .../RequestHandler/InitializeHandler.php | 8 +- .../RequestHandler/ListPromptsHandler.php | 3 +- .../RequestHandler/ListResourcesHandler.php | 3 +- .../RequestHandler/ListToolsHandler.php | 3 +- src/Server/RequestHandler/PingHandler.php | 3 +- .../RequestHandler/ReadResourceHandler.php | 3 +- src/Server/ServerBuilder.php | 28 +++ src/Server/Session/FileSessionStore.php | 157 +++++++++++++ src/Server/Session/InMemorySessionStore.php | 90 ++++++++ src/Server/Session/Session.php | 159 +++++++++++++ src/Server/Session/SessionFactory.php | 32 +++ .../Session/SessionFactoryInterface.php | 35 +++ src/Server/Session/SessionInterface.php | 85 +++++++ src/Server/Session/SessionStoreInterface.php | 64 +++++ src/Server/Transport/InMemoryTransport.php | 41 +++- .../Transport/Sse/Store/CachePoolStore.php | 65 ------ src/Server/Transport/Sse/StoreInterface.php | 26 --- src/Server/Transport/Sse/StreamTransport.php | 65 ------ src/Server/Transport/StdioTransport.php | 75 ++++-- .../Transport/StreamableHttpTransport.php | 218 ++++++++++++++++++ src/Server/TransportInterface.php | 46 +++- tests/JsonRpc/HandlerTest.php | 42 +++- .../RequestHandler/CallToolHandlerTest.php | 51 ++-- .../RequestHandler/GetPromptHandlerTest.php | 25 +- .../Server/RequestHandler/PingHandlerTest.php | 21 +- .../ReadResourceHandlerTest.php | 25 +- tests/ServerTest.php | 9 +- 49 files changed, 1614 insertions(+), 406 deletions(-) create mode 100644 examples/10-simple-http-transport/McpElements.php create mode 100644 examples/10-simple-http-transport/README.md create mode 100644 examples/10-simple-http-transport/server.php create mode 100644 src/Server/NativeClock.php create mode 100644 src/Server/Session/FileSessionStore.php create mode 100644 src/Server/Session/InMemorySessionStore.php create mode 100644 src/Server/Session/Session.php create mode 100644 src/Server/Session/SessionFactory.php create mode 100644 src/Server/Session/SessionFactoryInterface.php create mode 100644 src/Server/Session/SessionInterface.php create mode 100644 src/Server/Session/SessionStoreInterface.php delete mode 100644 src/Server/Transport/Sse/Store/CachePoolStore.php delete mode 100644 src/Server/Transport/Sse/StoreInterface.php delete mode 100644 src/Server/Transport/Sse/StreamTransport.php create mode 100644 src/Server/Transport/StreamableHttpTransport.php diff --git a/.gitignore b/.gitignore index 3c7c26e2..825fe72f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock vendor examples/**/dev.log +examples/**/sessions diff --git a/composer.json b/composer.json index 4d94c1a2..051e49be 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,11 @@ "ext-fileinfo": "*", "opis/json-schema": "^2.4", "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/finder": "^6.4 || ^7.3", "symfony/uid": "^6.4 || ^7.3" @@ -34,7 +37,10 @@ "phpunit/phpunit": "^10.5", "psr/cache": "^3.0", "symfony/console": "^6.4 || ^7.3", - "symfony/process": "^6.4 || ^7.3" + "symfony/process": "^6.4 || ^7.3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12" }, "autoload": { "psr-4": { @@ -51,10 +57,11 @@ "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", + "Mcp\\Example\\HttpTransportExample\\": "examples/10-simple-http-transport/", "Mcp\\Tests\\": "tests/" } }, "config": { "sort-packages": true } -} +} \ No newline at end of file diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index ce19dc0e..4bff8b8b 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -18,12 +18,17 @@ logger()->info('Starting MCP Stdio Calculator Server...'); -Server::make() +$server = Server::make() ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') ->setContainer(container()) ->setLogger(logger()) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 1d4ddc45..163d495c 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -13,20 +13,23 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Capability\Registry\Container; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Psr\Log\LoggerInterface; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP HTTP User Profile Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -// --- Setup DI Container for DI in McpElements class --- -$container = new Container(); -$container->set(LoggerInterface::class, logger()); +$request = $creator->fromGlobals(); -Server::make() +$server = Server::make() ->setServerInfo('HTTP User Profiles', '1.0.0') ->setLogger(logger()) - ->setContainer($container) + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) ->addTool( function (float $a, float $b, string $operation = 'add'): array { @@ -70,7 +73,12 @@ function (): array { description: 'Current system status and runtime information', mimeType: 'application/json' ) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + ->build(); -logger()->info('Server listener stopped gracefully.'); +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); + +(new SapiEmitter())->emit($response); diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php index 8a4916fc..9b83d82e 100644 --- a/examples/03-manual-registration-stdio/server.php +++ b/examples/03-manual-registration-stdio/server.php @@ -19,7 +19,7 @@ logger()->info('Starting MCP Manual Registration (Stdio) Server...'); -Server::make() +$server = Server::make() ->setServerInfo('Manual Reg Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) @@ -27,7 +27,12 @@ ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index f3ea730d..e69c5aa5 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -13,16 +13,24 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\CombinedHttpExample\Manual\ManualHandlers; use Mcp\Server; -use Mcp\Server\Transports\HttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Combined Registration (HTTP) Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Combined HTTP Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) ->addTool([ManualHandlers::class, 'manualGreeter']) ->addResource( @@ -30,7 +38,12 @@ 'config://priority', 'priority_config_manual', ) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php index 1cfaa13d..af159f40 100644 --- a/examples/05-stdio-env-variables/server.php +++ b/examples/05-stdio-env-variables/server.php @@ -49,11 +49,16 @@ logger()->info('Starting MCP Stdio Environment Variable Example Server...'); -Server::make() +$server = Server::make() ->setServerInfo('Env Var Server', '1.0.0') ->setLogger(logger()) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index 653d7773..3a6d86ff 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -27,12 +27,17 @@ $statsService = new Services\SystemStatsService($taskRepo); $container->set(Services\StatsServiceInterface::class, $statsService); -Server::make() +$server = Server::make() ->setServerInfo('Task Manager Server', '1.0.0') ->setLogger(logger()) ->setContainer($container) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); logger()->info('Server listener stopped gracefully.'); diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php index 44dbc45d..25e3039b 100644 --- a/examples/07-complex-tool-schema-http/server.php +++ b/examples/07-complex-tool-schema-http/server.php @@ -13,17 +13,30 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Mcp\Server\Transports\HttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Complex Schema HTTP Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Event Scheduler Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php index b77b6db8..c7a323ea 100644 --- a/examples/08-schema-showcase-streamable/server.php +++ b/examples/08-schema-showcase-streamable/server.php @@ -13,16 +13,29 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; -use Mcp\Server\Transports\StreamableHttpServerTransport; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; -logger()->info('Starting MCP Schema Showcase Server...'); +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -Server::make() +$request = $creator->fromGlobals(); + +$server = Server::make() ->setServerInfo('Schema Showcase', '1.0.0') ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); -logger()->info('Server listener stopped gracefully.'); +(new SapiEmitter())->emit($response); diff --git a/examples/09-standalone-cli/index.php b/examples/09-standalone-cli/index.php index f7a67423..9ef61b7e 100644 --- a/examples/09-standalone-cli/index.php +++ b/examples/09-standalone-cli/index.php @@ -11,6 +11,8 @@ require __DIR__.'/vendor/autoload.php'; +use Mcp\Server; +use Mcp\Server\Transport\StdioTransport; use Symfony\Component\Console as SymfonyConsole; use Symfony\Component\Console\Output\OutputInterface; @@ -20,18 +22,14 @@ $output = new SymfonyConsole\Output\ConsoleOutput($debug ? OutputInterface::VERBOSITY_VERY_VERBOSE : OutputInterface::VERBOSITY_NORMAL); $logger = new SymfonyConsole\Logger\ConsoleLogger($output); -// Configure the JsonRpcHandler and build the functionality -$jsonRpcHandler = new Mcp\JsonRpc\Handler( - Mcp\JsonRpc\MessageFactory::make(), - App\Builder::buildMethodHandlers(), - $logger -); +$server = Server::make() + ->setServerInfo('Standalone CLI', '1.0.0') + ->setLogger($logger) + ->setDiscovery(__DIR__, ['.']) + ->build(); -// Set up the server -$sever = new Mcp\Server($jsonRpcHandler, $logger); +$transport = new StdioTransport(logger: $logger); -// Create the transport layer using Stdio -$transport = new Mcp\Server\Transport\StdioTransport(logger: $logger); +$server->connect($transport); -// Start our application -$sever->connect($transport); +$transport->listen(); diff --git a/examples/10-simple-http-transport/McpElements.php b/examples/10-simple-http-transport/McpElements.php new file mode 100644 index 00000000..6ac08427 --- /dev/null +++ b/examples/10-simple-http-transport/McpElements.php @@ -0,0 +1,118 @@ + $a + $b, + 'subtract', '-' => $a - $b, + 'multiply', '*' => $a * $b, + 'divide', '/' => 0 != $b ? $a / $b : 'Error: Division by zero', + default => 'Error: Unknown operation. Use: add, subtract, multiply, divide', + }; + } + + /** + * Server information resource. + * + * @return array{status: string, timestamp: int, version: string, transport: string, uptime: int} + */ + #[McpResource( + uri: 'info://server/status', + name: 'server_status', + description: 'Current server status and information', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'running', + 'timestamp' => time(), + 'version' => '1.0.0', + 'transport' => 'HTTP', + 'uptime' => time() - $_SERVER['REQUEST_TIME'], + ]; + } + + /** + * Configuration resource. + * + * @return array{debug: bool, environment: string, timezone: string, locale: string} + */ + #[McpResource( + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration settings', + mimeType: 'application/json' + )] + public function getAppConfig(): array + { + return [ + 'debug' => $_SERVER['DEBUG'] ?? false, + 'environment' => $_SERVER['APP_ENV'] ?? 'production', + 'timezone' => date_default_timezone_get(), + 'locale' => 'en_US', + ]; + } + + /** + * Greeting prompt. + * + * @return array{role: string, content: string} + */ + #[McpPrompt( + name: 'greet', + description: 'Generate a personalized greeting message' + )] + public function greetPrompt(string $firstName = 'World', string $timeOfDay = 'day'): array + { + $greeting = match (strtolower($timeOfDay)) { + 'morning' => 'Good morning', + 'afternoon' => 'Good afternoon', + 'evening', 'night' => 'Good evening', + default => 'Hello', + }; + + return [ + 'role' => 'user', + 'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport.", + ]; + } +} diff --git a/examples/10-simple-http-transport/README.md b/examples/10-simple-http-transport/README.md new file mode 100644 index 00000000..aa06a67e --- /dev/null +++ b/examples/10-simple-http-transport/README.md @@ -0,0 +1,24 @@ +# HTTP MCP Server Example + +This example demonstrates how to use the MCP SDK with HTTP transport using the StreamableHttpTransport. It provides a complete HTTP-based MCP server that can handle JSON-RPC requests over HTTP POST. + +## Usage + +**Step 1: Start the HTTP server** + +```bash +cd examples/10-simple-http-transport +php -S localhost:8000 server.php +``` + +**Step 2: Connect with MCP Inspector** + +```bash +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +## Available Features + +- **Tools**: `current_time`, `calculate` +- **Resources**: `info://server/status`, `config://app/settings` +- **Prompts**: `greet` diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php new file mode 100644 index 00000000..0ce83404 --- /dev/null +++ b/examples/10-simple-http-transport/server.php @@ -0,0 +1,40 @@ +fromGlobals(); + +$server = Server::make() + ->setServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + +$server->connect($transport); + +$response = $transport->listen(); + +(new SapiEmitter())->emit($response); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a9382bf3..f7b29633 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,17 +54,6 @@ parameters: count: 1 path: examples/02-discovery-http-userprofile/server.php - - - message: '#^Instantiated class StreamableHttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/02-discovery-http-userprofile/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, StreamableHttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/02-discovery-http-userprofile/server.php - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' @@ -84,17 +73,6 @@ parameters: count: 2 path: examples/04-combined-registration-http/server.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/04-combined-registration-http/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/04-combined-registration-http/server.php - message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' @@ -288,17 +266,6 @@ parameters: count: 2 path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\HttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/07-complex-tool-schema-http/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\HttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/07-complex-tool-schema-http/server.php - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' @@ -354,17 +321,6 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - message: '#^Instantiated class Mcp\\Server\\Transports\\StreamableHttpServerTransport not found\.$#' - identifier: class.notFound - count: 1 - path: examples/08-schema-showcase-streamable/server.php - - - - message: '#^Parameter \#1 \$transport of method Mcp\\Server\:\:connect\(\) expects Mcp\\Server\\TransportInterface, Mcp\\Server\\Transports\\StreamableHttpServerTransport given\.$#' - identifier: argument.type - count: 1 - path: examples/08-schema-showcase-streamable/server.php - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#' diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index 8699eab2..e7e66964 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -24,11 +24,16 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\MethodHandlerInterface; use Mcp\Server\NotificationHandler; use Mcp\Server\RequestHandler; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionStoreInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @final @@ -47,6 +52,8 @@ class Handler */ public function __construct( private readonly MessageFactory $messageFactory, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -62,10 +69,14 @@ public static function make( ToolCallerInterface $toolCaller, ResourceReaderInterface $resourceReader, PromptGetterInterface $promptGetter, + SessionStoreInterface $sessionStore, + SessionFactoryInterface $sessionFactory, LoggerInterface $logger = new NullLogger(), ): self { return new self( messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, methodHandlers: [ new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), @@ -82,29 +93,78 @@ public static function make( } /** - * @return iterable + * @return iterable}> * * @throws ExceptionInterface When a handler throws an exception during message processing * @throws \JsonException When JSON encoding of the response fails */ - public function process(string $input): iterable + public function process(string $input, ?Uuid $sessionId): iterable { $this->logger->info('Received message to process.', ['message' => $input]); + $this->runGarbageCollection(); + try { - $messages = $this->messageFactory->create($input); + $messages = iterator_to_array($this->messageFactory->create($input)); } catch (\JsonException $e) { $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - - yield $this->encodeResponse(Error::forParseError($e->getMessage())); + $error = Error::forParseError($e->getMessage()); + yield [$this->encodeResponse($error), []]; return; } + $hasInitializeRequest = false; + foreach ($messages as $message) { + if ($message instanceof InitializeRequest) { + $hasInitializeRequest = true; + break; + } + } + + $session = null; + + if ($hasInitializeRequest) { + // Spec: An initialize request must not be part of a batch. + if (\count($messages) > 1) { + $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); + yield [$this->encodeResponse($error), []]; + + return; + } + + // Spec: An initialize request must not have a session ID. + if ($sessionId) { + $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); + yield [$this->encodeResponse($error), []]; + + return; + } + + $session = $this->sessionFactory->create($this->sessionStore); + } else { + if (!$sessionId) { + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + yield [$this->encodeResponse($error), ['status_code' => 400]]; + + return; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + yield [$this->encodeResponse($error), ['status_code' => 404]]; + + return; + } + + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + } + foreach ($messages as $message) { if ($message instanceof InvalidInputMessageException) { $this->logger->warning('Failed to create message.', ['exception' => $message]); - yield $this->encodeResponse(Error::forInvalidRequest($message->getMessage(), 0)); + $error = Error::forInvalidRequest($message->getMessage(), 0); + yield [$this->encodeResponse($error), []]; continue; } @@ -113,24 +173,32 @@ public function process(string $input): iterable ]); try { - yield $this->encodeResponse($this->handle($message)); + $response = $this->handle($message, $session); + yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; } catch (\DomainException) { - yield null; + yield [null, []]; } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e], + $this->logger->warning( + \sprintf('Failed to create response: %s', $e->getMessage()), + ['exception' => $e], ); - yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); + $error = Error::forMethodNotFound($e->getMessage()); + yield [$this->encodeResponse($error), []]; } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - yield $this->encodeResponse(Error::forInvalidParams($e->getMessage())); + $error = Error::forInvalidParams($e->getMessage()); + yield [$this->encodeResponse($error), []]; } catch (\Throwable $e) { $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - yield $this->encodeResponse(Error::forInternalError($e->getMessage())); + $error = Error::forInternalError($e->getMessage()); + yield [$this->encodeResponse($error), []]; } } + + $session->save(); } /** @@ -159,7 +227,7 @@ private function encodeResponse(Response|Error|null $response): ?string * @throws NotFoundExceptionInterface When no handler is found for the request method * @throws ExceptionInterface When a request handler throws an exception */ - private function handle(HasMethodInterface $message): Response|Error|null + private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null { $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ 'message' => $message, @@ -167,18 +235,20 @@ private function handle(HasMethodInterface $message): Response|Error|null $handled = false; foreach ($this->methodHandlers as $handler) { - if ($handler->supports($message)) { - $return = $handler->handle($message); - $handled = true; - - $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ - 'method' => $message::getMethod(), - 'response' => $return, - ]); - - if (null !== $return) { - return $return; - } + if (!$handler->supports($message)) { + continue; + } + + $return = $handler->handle($message, $session); + $handled = true; + + $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ + 'method' => $message::getMethod(), + 'response' => $return, + ]); + + if (null !== $return) { + return $return; } } @@ -188,4 +258,32 @@ private function handle(HasMethodInterface $message): Response|Error|null throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + private function runGarbageCollection(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->sessionStore->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => \count($deletedSessions), + 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } + + /** + * Destroy a specific session. + */ + public function destroySession(Uuid $sessionId): void + { + $this->sessionStore->destroy($sessionId); + $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); + } } diff --git a/src/Server.php b/src/Server.php index fc81382d..55b84159 100644 --- a/src/Server.php +++ b/src/Server.php @@ -16,9 +16,11 @@ use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class Server { @@ -36,37 +38,30 @@ public static function make(): ServerBuilder public function connect(TransportInterface $transport): void { $transport->initialize(); + $this->logger->info('Transport initialized.', [ 'transport' => $transport::class, ]); - while ($transport->isConnected()) { - foreach ($transport->receive() as $message) { - if (null === $message) { - continue; - } - - try { - foreach ($this->jsonRpcHandler->process($message) as $response) { - if (null === $response) { - continue; - } - - $transport->send($response); + $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { + try { + foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { + if (null === $response) { + continue; } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); - continue; + + $transport->send($response, $context); } + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message' => $message, + 'exception' => $e, + ]); } + }); - usleep(1000); - } - - $transport->close(); - $this->logger->info('Transport closed'); + $transport->onSessionEnd(function (Uuid $sessionId) { + $this->jsonRpcHandler->destroySession($sessionId); + }); } } diff --git a/src/Server/MethodHandlerInterface.php b/src/Server/MethodHandlerInterface.php index 7f949bb1..4abca854 100644 --- a/src/Server/MethodHandlerInterface.php +++ b/src/Server/MethodHandlerInterface.php @@ -16,6 +16,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,5 +28,5 @@ public function supports(HasMethodInterface $message): bool; /** * @throws ExceptionInterface When the handler encounters an error processing the request */ - public function handle(HasMethodInterface $message): Response|Error|null; + public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null; } diff --git a/src/Server/NativeClock.php b/src/Server/NativeClock.php new file mode 100644 index 00000000..5e0b9d28 --- /dev/null +++ b/src/Server/NativeClock.php @@ -0,0 +1,24 @@ + @@ -27,8 +28,10 @@ public function supports(HasMethodInterface $message): bool return $message instanceof InitializedNotification; } - public function handle(InitializedNotification|HasMethodInterface $message): Response|Error|null + public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null { + $session->set('initialized', true); + return null; } } diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php index 28aab382..fd6bddae 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/RequestHandler/CallToolHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -38,7 +39,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message): Response|Error + public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof CallToolRequest); diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php index 1ac0a3ff..a6b98909 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/RequestHandler/GetPromptHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -34,7 +35,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof GetPromptRequest; } - public function handle(GetPromptRequest|HasMethodInterface $message): Response|Error + public function handle(GetPromptRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof GetPromptRequest); diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php index 11d9b0ab..e27d1fbb 100644 --- a/src/Server/RequestHandler/InitializeHandler.php +++ b/src/Server/RequestHandler/InitializeHandler.php @@ -18,6 +18,7 @@ use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -35,11 +36,14 @@ public function supports(HasMethodInterface $message): bool return $message instanceof InitializeRequest; } - public function handle(InitializeRequest|HasMethodInterface $message): Response + public function handle(InitializeRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof InitializeRequest); - return new Response($message->getId(), + $session->set('client_info', $message->clientInfo->jsonSerialize()); + + return new Response( + $message->getId(), new InitializeResult($this->capabilities, $this->serverInfo), ); } diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index 2bf479c9..bd93dd60 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -34,7 +35,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListPromptsRequest; } - public function handle(ListPromptsRequest|HasMethodInterface $message): Response + public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListPromptsRequest); diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index 212f4f00..f0abad28 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -34,7 +35,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListResourcesRequest; } - public function handle(ListResourcesRequest|HasMethodInterface $message): Response + public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListResourcesRequest); diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index eb49e0d9..e792b0f6 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -35,7 +36,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListToolsRequest; } - public function handle(ListToolsRequest|HasMethodInterface $message): Response + public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListToolsRequest); diff --git a/src/Server/RequestHandler/PingHandler.php b/src/Server/RequestHandler/PingHandler.php index 2cf8ec91..30701332 100644 --- a/src/Server/RequestHandler/PingHandler.php +++ b/src/Server/RequestHandler/PingHandler.php @@ -16,6 +16,7 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel @@ -27,7 +28,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof PingRequest; } - public function handle(PingRequest|HasMethodInterface $message): Response + public function handle(PingRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof PingRequest); diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php index 9c80d2b1..455e23a5 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/RequestHandler/ReadResourceHandler.php @@ -19,6 +19,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm @@ -35,7 +36,7 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ReadResourceRequest; } - public function handle(ReadResourceRequest|HasMethodInterface $message): Response|Error + public function handle(ReadResourceRequest|HasMethodInterface $message, SessionInterface $session): Response|Error { \assert($message instanceof ReadResourceRequest); diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 4075cdba..61af4745 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -38,6 +38,10 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\SessionFactory; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionStoreInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -65,6 +69,12 @@ final class ServerBuilder private ?ContainerInterface $container = null; + private ?SessionFactoryInterface $sessionFactory = null; + + private ?SessionStoreInterface $sessionStore = null; + + private int $sessionTtl = 3600; + private ?int $paginationLimit = 50; private ?string $instructions = null; @@ -193,6 +203,18 @@ public function setContainer(ContainerInterface $container): self return $this; } + public function setSession( + SessionStoreInterface $sessionStore, + SessionFactoryInterface $sessionFactory = new SessionFactory(), + int $ttl = 3600, + ): self { + $this->sessionFactory = $sessionFactory; + $this->sessionStore = $sessionStore; + $this->sessionTtl = $ttl; + + return $this; + } + public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], @@ -292,6 +314,10 @@ public function build(): Server $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + return new Server( jsonRpcHandler: Handler::make( registry: $registry, @@ -300,6 +326,8 @@ public function build(): Server toolCaller: $toolCaller, resourceReader: $resourceReader, promptGetter: $promptGetter, + sessionStore: $sessionStore, + sessionFactory: $sessionFactory, logger: $logger, ), logger: $logger, diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php new file mode 100644 index 00000000..217d1eb4 --- /dev/null +++ b/src/Server/Session/FileSessionStore.php @@ -0,0 +1,157 @@ +directory)) { + @mkdir($this->directory, 0775, true); + } + + if (!is_dir($this->directory) || !is_writable($this->directory)) { + throw new \RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); + } + } + + public function exists(Uuid $id): bool + { + $path = $this->pathFor($id); + + if (!is_file($path)) { + return false; + } + + $mtime = @filemtime($path) ?: 0; + + return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; + } + + public function read(Uuid $sessionId): string|false + { + $path = $this->pathFor($sessionId); + + if (!is_file($path)) { + return false; + } + + $mtime = @filemtime($path) ?: 0; + if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { + @unlink($path); + + return false; + } + + $data = @file_get_contents($path); + if (false === $data) { + return false; + } + + return $data; + } + + public function write(Uuid $sessionId, string $data): bool + { + $path = $this->pathFor($sessionId); + + $tmp = $path.'.tmp'; + if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { + return false; + } + + // Atomic move + if (!@rename($tmp, $path)) { + // Fallback if rename fails cross-device + if (false === @copy($tmp, $path)) { + @unlink($tmp); + + return false; + } + @unlink($tmp); + } + + @touch($path, $this->clock->now()->getTimestamp()); + + return true; + } + + public function destroy(Uuid $sessionId): bool + { + $path = $this->pathFor($sessionId); + + if (is_file($path)) { + @unlink($path); + } + + return true; + } + + /** + * Remove sessions older than the configured TTL. + * Returns an array of deleted session IDs (UUID instances). + */ + public function gc(): array + { + $deleted = []; + $now = $this->clock->now()->getTimestamp(); + + $dir = @opendir($this->directory); + if (false === $dir) { + return $deleted; + } + + while (($entry = readdir($dir)) !== false) { + // Skip dot entries + if ('.' === $entry || '..' === $entry) { + continue; + } + + $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; + if (!is_file($path)) { + continue; + } + + $mtime = @filemtime($path) ?: 0; + if (($now - $mtime) > $this->ttl) { + @unlink($path); + try { + $deleted[] = Uuid::fromString($entry); + } catch (\Throwable) { + // ignore non-UUID file names + } + } + } + + closedir($dir); + + return $deleted; + } + + private function pathFor(Uuid $id): string + { + return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); + } +} diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php new file mode 100644 index 00000000..4051ba76 --- /dev/null +++ b/src/Server/Session/InMemorySessionStore.php @@ -0,0 +1,90 @@ + + */ + protected array $store = []; + + public function __construct( + protected readonly int $ttl = 3600, + protected readonly ClockInterface $clock = new NativeClock(), + ) { + } + + public function exists(Uuid $id): bool + { + return isset($this->store[$id->toRfc4122()]); + } + + public function read(Uuid $sessionId): string|false + { + $session = $this->store[$sessionId->toRfc4122()] ?? ''; + if ('' === $session) { + return false; + } + + $currentTimestamp = $this->clock->now()->getTimestamp(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId->toRfc4122()]); + + return false; + } + + return $session['data']; + } + + public function write(Uuid $sessionId, string $data): bool + { + $this->store[$sessionId->toRfc4122()] = [ + 'data' => $data, + 'timestamp' => $this->clock->now()->getTimestamp(), + ]; + + return true; + } + + public function destroy(Uuid $sessionId): bool + { + if (isset($this->store[$sessionId->toRfc4122()])) { + unset($this->store[$sessionId->toRfc4122()]); + } + + return true; + } + + public function gc(): array + { + $currentTimestamp = $this->clock->now()->getTimestamp(); + $deletedSessions = []; + + foreach ($this->store as $sessionId => $session) { + $sessionId = Uuid::fromString($sessionId); + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId->toRfc4122()]); + $deletedSessions[] = $sessionId; + } + } + + return $deletedSessions; + } +} diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php new file mode 100644 index 00000000..6bf3c306 --- /dev/null +++ b/src/Server/Session/Session.php @@ -0,0 +1,159 @@ + + */ +class Session implements SessionInterface +{ + /** + * @param array $data Stores all session data. + * Keys are snake_case by convention for MCP-specific data. + * + * Official keys are: + * - initialized: bool + * - client_info: array|null + * - protocol_version: string|null + * - log_level: string|null + */ + public function __construct( + protected SessionStoreInterface $store, + protected Uuid $id = new UuidV4(), + protected array $data = [], + ) { + if ($rawData = $this->store->read($this->id)) { + $this->data = json_decode($rawData, true) ?? []; + } + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getStore(): SessionStoreInterface + { + return $this->store; + } + + public function save(): void + { + $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); + } + + public function get(string $key, mixed $default = null): mixed + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (\is_array($data) && \array_key_exists($segment, $data)) { + $data = $data[$segment]; + } else { + return $default; + } + } + + return $data; + } + + public function set(string $key, mixed $value, bool $overwrite = true): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (\count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !\is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if ($overwrite || !isset($data[$lastKey])) { + $data[$lastKey] = $value; + } + } + + public function has(string $key): bool + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (\is_array($data) && \array_key_exists($segment, $data)) { + $data = $data[$segment]; + } elseif (\is_object($data) && isset($data->{$segment})) { + $data = $data->{$segment}; + } else { + return false; + } + } + + return true; + } + + public function forget(string $key): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (\count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !\is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if (isset($data[$lastKey])) { + unset($data[$lastKey]); + } + } + + public function clear(): void + { + $this->data = []; + } + + public function pull(string $key, mixed $default = null): mixed + { + $value = $this->get($key, $default); + $this->forget($key); + + return $value; + } + + public function all(): array + { + return $this->data; + } + + public function hydrate(array $attributes): void + { + $this->data = $attributes; + } + + /** @return array */ + public function jsonSerialize(): array + { + return $this->all(); + } +} diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php new file mode 100644 index 00000000..0064ae4c --- /dev/null +++ b/src/Server/Session/SessionFactory.php @@ -0,0 +1,32 @@ + + */ +class SessionFactory implements SessionFactoryInterface +{ + public function create(SessionStoreInterface $store): SessionInterface + { + return new Session($store, Uuid::v4()); + } + + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface + { + return new Session($store, $id); + } +} diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionFactoryInterface.php new file mode 100644 index 00000000..15343346 --- /dev/null +++ b/src/Server/Session/SessionFactoryInterface.php @@ -0,0 +1,35 @@ + + */ +interface SessionFactoryInterface +{ + /** + * Creates a new session with an auto-generated UUID. + * This is the standard factory method for creating sessions. + */ + public function create(SessionStoreInterface $store): SessionInterface; + + /** + * Creates a session with a specific UUID. + * Use this when you need to reconstruct a session with a known ID. + */ + public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface; +} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php new file mode 100644 index 00000000..9ee5e807 --- /dev/null +++ b/src/Server/Session/SessionInterface.php @@ -0,0 +1,85 @@ + + */ +interface SessionInterface extends \JsonSerializable +{ + /** + * Get the session ID. + */ + public function getId(): Uuid; + + /** + * Save the session. + */ + public function save(): void; + + /** + * Get a specific attribute from the session. + * Supports dot notation for nested access. + */ + public function get(string $key, mixed $default = null): mixed; + + /** + * Set a specific attribute in the session. + * Supports dot notation for nested access. + */ + public function set(string $key, mixed $value, bool $overwrite = true): void; + + /** + * Check if an attribute exists in the session. + * Supports dot notation for nested access. + */ + public function has(string $key): bool; + + /** + * Remove an attribute from the session. + * Supports dot notation for nested access. + */ + public function forget(string $key): void; + + /** + * Remove all attributes from the session. + */ + public function clear(): void; + + /** + * Get an attribute's value and then remove it from the session. + * Supports dot notation for nested access. + */ + public function pull(string $key, mixed $default = null): mixed; + + /** + * Get all attributes of the session. + * + * @return array + */ + public function all(): array; + + /** + * Set all attributes of the session, typically for hydration. + * This will overwrite existing attributes. + * + * @param array $attributes + */ + public function hydrate(array $attributes): void; + + /** + * Get the session store instance. + */ + public function getStore(): SessionStoreInterface; +} diff --git a/src/Server/Session/SessionStoreInterface.php b/src/Server/Session/SessionStoreInterface.php new file mode 100644 index 00000000..13f5f161 --- /dev/null +++ b/src/Server/Session/SessionStoreInterface.php @@ -0,0 +1,64 @@ + + */ +interface SessionStoreInterface +{ + /** + * Check if a session exists. + * + * @param Uuid $id the session id + * + * @return bool true if the session exists, false otherwise + */ + public function exists(Uuid $id): bool; + + /** + * Read session data. + * + * Returns an encoded string of the read data. + * If nothing was read, it must return false. + * + * @param Uuid $id the session id to read data for + */ + public function read(Uuid $id): string|false; + + /** + * Write session data. + * + * @param Uuid $id the session id + * @param string $data the encoded session data + */ + public function write(Uuid $id, string $data): bool; + + /** + * Destroy a session. + * + * @param Uuid $id The session ID being destroyed. + * The return value (usually TRUE on success, FALSE on failure). + */ + public function destroy(Uuid $id): bool; + + /** + * Cleanup old sessions + * Sessions that have not updated for + * the configured TTL will be removed. + * + * @return Uuid[] + */ + public function gc(): array; +} diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index 015c70c5..de80edee 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -12,13 +12,20 @@ namespace Mcp\Server\Transport; use Mcp\Server\TransportInterface; +use Symfony\Component\Uid\Uuid; /** * @author Tobias Nyholm */ class InMemoryTransport implements TransportInterface { - private bool $connected = true; + /** @var callable(string, ?Uuid): void */ + private $messageListener; + + /** @var callable(Uuid): void */ + private $sessionDestroyListener; + + private ?Uuid $sessionId = null; /** * @param list $messages @@ -32,22 +39,42 @@ public function initialize(): void { } - public function isConnected(): bool + public function onMessage(callable $listener): void { - return $this->connected; + $this->messageListener = $listener; } - public function receive(): \Generator + public function send(string $data, array $context): void { - yield from $this->messages; - $this->connected = false; + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + } + + public function listen(): mixed + { + foreach ($this->messages as $message) { + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $message, $this->sessionId); + } + } + + if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { + \call_user_func($this->sessionDestroyListener, $this->sessionId); + } + + return null; } - public function send(string $data): void + public function onSessionEnd(callable $listener): void { + $this->sessionDestroyListener = $listener; } public function close(): void { + if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { + \call_user_func($this->sessionDestroyListener, $this->sessionId); + } } } diff --git a/src/Server/Transport/Sse/Store/CachePoolStore.php b/src/Server/Transport/Sse/Store/CachePoolStore.php deleted file mode 100644 index 68a476fb..00000000 --- a/src/Server/Transport/Sse/Store/CachePoolStore.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ -final class CachePoolStore implements StoreInterface -{ - public function __construct( - private readonly CacheItemPoolInterface $cachePool, - ) { - } - - public function push(Uuid $id, string $message): void - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - $messages = $item->isHit() ? $item->get() : []; - $messages[] = $message; - $item->set($messages); - - $this->cachePool->save($item); - } - - public function pop(Uuid $id): ?string - { - $item = $this->cachePool->getItem($this->getCacheKey($id)); - - if (!$item->isHit()) { - return null; - } - - $messages = $item->get(); - $message = array_shift($messages); - - $item->set($messages); - $this->cachePool->save($item); - - return $message; - } - - public function remove(Uuid $id): void - { - $this->cachePool->deleteItem($this->getCacheKey($id)); - } - - private function getCacheKey(Uuid $id): string - { - return 'message_'.$id->toRfc4122(); - } -} diff --git a/src/Server/Transport/Sse/StoreInterface.php b/src/Server/Transport/Sse/StoreInterface.php deleted file mode 100644 index e2bed2d9..00000000 --- a/src/Server/Transport/Sse/StoreInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -interface StoreInterface -{ - public function push(Uuid $id, string $message): void; - - public function pop(Uuid $id): ?string; - - public function remove(Uuid $id): void; -} diff --git a/src/Server/Transport/Sse/StreamTransport.php b/src/Server/Transport/Sse/StreamTransport.php deleted file mode 100644 index 70a01189..00000000 --- a/src/Server/Transport/Sse/StreamTransport.php +++ /dev/null @@ -1,65 +0,0 @@ - - */ -final class StreamTransport implements TransportInterface -{ - public function __construct( - private readonly string $messageEndpoint, - private readonly StoreInterface $store, - private readonly Uuid $id, - ) { - } - - public function initialize(): void - { - ignore_user_abort(true); - $this->flushEvent('endpoint', $this->messageEndpoint); - } - - public function isConnected(): bool - { - return 0 === connection_aborted(); - } - - public function receive(): \Generator - { - yield $this->store->pop($this->id); - } - - public function send(string $data): void - { - $this->flushEvent('message', $data); - } - - public function close(): void - { - $this->store->remove($this->id); - } - - private function flushEvent(string $event, string $data): void - { - echo \sprintf('event: %s', $event).\PHP_EOL; - echo \sprintf('data: %s', $data).\PHP_EOL; - echo \PHP_EOL; - if (false !== ob_get_length()) { - ob_flush(); - } - flush(); - } -} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 309683ab..89132cae 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -14,13 +14,20 @@ use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Uid\Uuid; /** - * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. + * @author Kyrian Obikwelu */ class StdioTransport implements TransportInterface { - private string $buffer = ''; + /** @var callable(string, ?Uuid): void */ + private $messageListener; + + /** @var callable(Uuid): void */ + private $sessionEndListener; + + private ?Uuid $sessionId = null; /** * @param resource $input @@ -37,39 +44,67 @@ public function initialize(): void { } - public function isConnected(): bool + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function send(string $data, array $context): void { - return true; + $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + + fwrite($this->output, $data.\PHP_EOL); } - public function receive(): \Generator + public function listen(): mixed { - $line = fgets($this->input); + $this->logger->info('StdioTransport is listening for messages on STDIN...'); - $this->logger->debug('Received message on StdioTransport.', [ - 'line' => $line, - ]); + while (!feof($this->input)) { + $line = fgets($this->input); + if (false === $line) { + break; + } - if (false === $line) { - return; + $trimmedLine = trim($line); + if (!empty($trimmedLine)) { + $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $trimmedLine, $this->sessionId); + } + } } - $this->buffer .= rtrim($line).\PHP_EOL; - if (str_contains($this->buffer, \PHP_EOL)) { - $lines = explode(\PHP_EOL, $this->buffer); - $this->buffer = array_pop($lines); - yield from $lines; + $this->logger->info('StdioTransport finished listening.'); + + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); } + + return null; } - public function send(string $data): void + public function onSessionEnd(callable $listener): void { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); - - fwrite($this->output, $data.\PHP_EOL); + $this->sessionEndListener = $listener; } public function close(): void { + if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { + \call_user_func($this->sessionEndListener, $this->sessionId); + } + + if (\is_resource($this->input)) { + fclose($this->input); + } + + if (\is_resource($this->output)) { + fclose($this->output); + } } } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php new file mode 100644 index 00000000..428315b4 --- /dev/null +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -0,0 +1,218 @@ + + */ +class StreamableHttpTransport implements TransportInterface +{ + /** @var callable(string, ?Uuid): void */ + private $messageListener; + + /** @var callable(Uuid): void */ + private $sessionEndListener; + + private ?Uuid $sessionId = null; + + /** @var string[] */ + private array $outgoingMessages = []; + private ?Uuid $outgoingSessionId = null; + private ?int $outgoingStatusCode = null; + + /** @var array */ + private array $corsHeaders = [ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', + ]; + + public function __construct( + private readonly ServerRequestInterface $request, + private readonly ResponseFactoryInterface $responseFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); + $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + } + + public function initialize(): void + { + } + + public function send(string $data, array $context): void + { + $this->outgoingMessages[] = $data; + + if (isset($context['session_id'])) { + $this->outgoingSessionId = $context['session_id']; + } + + if (isset($context['status_code']) && \is_int($context['status_code'])) { + $this->outgoingStatusCode = $context['status_code']; + } + + $this->logger->debug('Sending data to client via StreamableHttpTransport.', [ + 'data' => $data, + 'session_id' => $this->outgoingSessionId?->toRfc4122(), + 'status_code' => $this->outgoingStatusCode, + ]); + } + + public function listen(): mixed + { + return match ($this->request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'GET' => $this->handleGetRequest(), + 'POST' => $this->handlePostRequest(), + 'DELETE' => $this->handleDeleteRequest(), + default => $this->handleUnsupportedRequest(), + }; + } + + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function onSessionEnd(callable $listener): void + { + $this->sessionEndListener = $listener; + } + + protected function handleOptionsRequest(): ResponseInterface + { + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + protected function handlePostRequest(): ResponseInterface + { + $acceptHeader = $this->request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); + $this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]); + + return $this->createErrorResponse($error, 406); + } + + if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { + $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); + $this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]); + + return $this->createErrorResponse($error, 415); + } + + $body = $this->request->getBody()->getContents(); + if (empty($body)) { + $error = Error::forInvalidRequest('Bad Request: Empty request body.'); + $this->logger->warning('Client sent empty request body.'); + + return $this->createErrorResponse($error, 400); + } + + $this->logger->debug('Received message on StreamableHttpTransport.', [ + 'body' => $body, + 'session_id' => $this->sessionId?->toRfc4122(), + ]); + + if (\is_callable($this->messageListener)) { + \call_user_func($this->messageListener, $body, $this->sessionId); + } + + if (empty($this->outgoingMessages)) { + return $this->withCorsHeaders($this->responseFactory->createResponse(202)); + } + + $responseBody = 1 === \count($this->outgoingMessages) + ? $this->outgoingMessages[0] + : '['.implode(',', $this->outgoingMessages).']'; + + $status = $this->outgoingStatusCode ?? 200; + + $response = $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($responseBody)); + + if ($this->outgoingSessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122()); + } + + return $this->withCorsHeaders($response); + } + + protected function handleGetRequest(): ResponseInterface + { + $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); + + return $this->withCorsHeaders($response); + } + + protected function handleDeleteRequest(): ResponseInterface + { + if (!$this->sessionId) { + $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); + $this->logger->warning('DELETE request received without session ID.'); + + return $this->createErrorResponse($error, 400); + } + + if (\is_callable($this->sessionEndListener)) { + \call_user_func($this->sessionEndListener, $this->sessionId); + } + + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + protected function handleUnsupportedRequest(): ResponseInterface + { + $this->logger->warning('Unsupported HTTP method received.', [ + 'method' => $this->request->getMethod(), + ]); + + $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + + return $this->withCorsHeaders($response); + } + + protected function withCorsHeaders(ResponseInterface $response): ResponseInterface + { + foreach ($this->corsHeaders as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $response; + } + + protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface + { + $errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); + + return $this->responseFactory->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($errorPayload)); + } + + public function close(): void + { + } +} diff --git a/src/Server/TransportInterface.php b/src/Server/TransportInterface.php index 49963a70..6e975188 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/TransportInterface.php @@ -11,18 +11,58 @@ namespace Mcp\Server; +use Symfony\Component\Uid\Uuid; + /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ interface TransportInterface { + /** + * Initializes the transport. + */ public function initialize(): void; - public function isConnected(): bool; + /** + * Registers a callback that will be invoked whenever the transport receives an incoming message. + * + * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs + */ + public function onMessage(callable $listener): void; + + /** + * Starts the transport's execution process. + * + * - For a blocking transport like STDIO, this method will run a continuous loop. + * - For a single-request transport like HTTP, this will process the request + * and return a result (e.g., a PSR-7 Response) to be sent to the client. + * + * @return mixed the result of the transport's execution, if any + */ + public function listen(): mixed; - public function receive(): \Generator; + /** + * Sends a raw JSON-RPC message string back to the client. + * + * @param string $data The JSON-RPC message string to send + * @param array $context The context of the message + */ + public function send(string $data, array $context): void; - public function send(string $data): void; + /** + * Registers a callback that will be invoked when a session needs to be destroyed. + * This can happen when a client disconnects or explicitly ends their session. + * + * @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session + */ + public function onSessionEnd(callable $listener): void; + /** + * Closes the transport and cleans up any resources. + * + * This method should be called when the transport is no longer needed. + * It should clean up any resources and close any connections. + */ public function close(): void; } diff --git a/tests/JsonRpc/HandlerTest.php b/tests/JsonRpc/HandlerTest.php index a2fdeec9..20781def 100644 --- a/tests/JsonRpc/HandlerTest.php +++ b/tests/JsonRpc/HandlerTest.php @@ -15,8 +15,12 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Response; use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Session\SessionStoreInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; class HandlerTest extends TestCase { @@ -44,9 +48,24 @@ public function testHandleMultipleNotifications() $handlerC->method('supports')->willReturn(true); $handlerC->expects($this->once())->method('handle'); - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); + $sessionFactory = $this->createMock(SessionFactoryInterface::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $session = $this->createMock(SessionInterface::class); + + $sessionFactory->method('create')->willReturn($session); + $sessionFactory->method('createWithId')->willReturn($session); + $sessionStore->method('exists')->willReturn(true); + + $jsonRpc = new Handler( + MessageFactory::make(), + $sessionFactory, + $sessionStore, + [$handlerA, $handlerB, $handlerC] + ); + $sessionId = Uuid::v4(); $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}' + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId ); iterator_to_array($result); } @@ -75,9 +94,24 @@ public function testHandleMultipleRequests() $handlerC->method('supports')->willReturn(true); $handlerC->expects($this->never())->method('handle'); - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); + $sessionFactory = $this->createMock(SessionFactoryInterface::class); + $sessionStore = $this->createMock(SessionStoreInterface::class); + $session = $this->createMock(SessionInterface::class); + + $sessionFactory->method('create')->willReturn($session); + $sessionFactory->method('createWithId')->willReturn($session); + $sessionStore->method('exists')->willReturn(true); + + $jsonRpc = new Handler( + MessageFactory::make(), + $sessionFactory, + $sessionStore, + [$handlerA, $handlerB, $handlerC] + ); + $sessionId = Uuid::v4(); $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId ); iterator_to_array($result); } diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Server/RequestHandler/CallToolHandlerTest.php index e8f13622..1b2187ff 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Server/RequestHandler/CallToolHandlerTest.php @@ -20,6 +20,7 @@ use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\RequestHandler\CallToolHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -27,16 +28,18 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ToolCallerInterface|MockObject $toolExecutor; + private ToolCallerInterface|MockObject $toolCaller; private LoggerInterface|MockObject $logger; + private SessionInterface|MockObject $session; protected function setUp(): void { - $this->toolExecutor = $this->createMock(ToolCallerInterface::class); + $this->toolCaller = $this->createMock(ToolCallerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new CallToolHandler( - $this->toolExecutor, + $this->toolCaller, $this->logger, ); } @@ -53,7 +56,7 @@ public function testHandleSuccessfulToolCall(): void $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -63,7 +66,7 @@ public function testHandleSuccessfulToolCall(): void ->expects($this->never()) ->method('error'); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -75,13 +78,13 @@ public function testHandleToolCallWithEmptyArguments(): void $request = $this->createCallToolRequest('simple_tool', []); $expectedResult = new CallToolResult([new TextContent('Simple result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -99,13 +102,13 @@ public function testHandleToolCallWithComplexArguments(): void $request = $this->createCallToolRequest('complex_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Complex result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -116,7 +119,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); $exception = new ToolNotFoundException($request); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -133,7 +136,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void ], ); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -146,7 +149,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) @@ -163,7 +166,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void ], ); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -176,13 +179,13 @@ public function testHandleWithNullResult(): void $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -193,13 +196,13 @@ public function testHandleWithErrorResult(): void $request = $this->createCallToolRequest('error_tool', []); $expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -208,7 +211,7 @@ public function testHandleWithErrorResult(): void public function testConstructorWithDefaultLogger(): void { - $handler = new CallToolHandler($this->toolExecutor); + $handler = new CallToolHandler($this->toolCaller); $this->assertInstanceOf(CallToolHandler::class, $handler); } @@ -218,7 +221,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->willThrowException($exception); @@ -234,7 +237,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void ], ); - $this->handler->handle($request); + $this->handler->handle($request, $this->session); } public function testHandleWithSpecialCharactersInToolName(): void @@ -242,13 +245,13 @@ public function testHandleWithSpecialCharactersInToolName(): void $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -264,13 +267,13 @@ public function testHandleWithSpecialCharactersInArguments(): void $request = $this->createCallToolRequest('unicode_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $this->toolExecutor + $this->toolCaller ->expects($this->once()) ->method('call') ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Server/RequestHandler/GetPromptHandlerTest.php index 3debaa05..120b0e08 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Server/RequestHandler/GetPromptHandlerTest.php @@ -22,6 +22,7 @@ use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\RequestHandler\GetPromptHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -29,10 +30,12 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; private PromptGetterInterface|MockObject $promptGetter; + private SessionInterface|MockObject $session; protected function setUp(): void { $this->promptGetter = $this->createMock(PromptGetterInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new GetPromptHandler($this->promptGetter); } @@ -61,7 +64,7 @@ public function testHandleSuccessfulPromptGet(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -93,7 +96,7 @@ public function testHandlePromptGetWithArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -116,7 +119,7 @@ public function testHandlePromptGetWithNullArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -139,7 +142,7 @@ public function testHandlePromptGetWithEmptyArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -164,7 +167,7 @@ public function testHandlePromptGetWithMultipleMessages(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -182,7 +185,7 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -201,7 +204,7 @@ public function testHandlePromptGetExceptionReturnsError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -241,7 +244,7 @@ public function testHandlePromptGetWithComplexArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -269,7 +272,7 @@ public function testHandlePromptGetWithSpecialCharacters(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -289,7 +292,7 @@ public function testHandlePromptGetReturnsEmptyMessages(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -318,7 +321,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Server/RequestHandler/PingHandlerTest.php index 0904d68b..3be1176b 100644 --- a/tests/Server/RequestHandler/PingHandlerTest.php +++ b/tests/Server/RequestHandler/PingHandlerTest.php @@ -16,14 +16,17 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; use Mcp\Server\RequestHandler\PingHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; class PingHandlerTest extends TestCase { private PingHandler $handler; + private SessionInterface $session; protected function setUp(): void { + $this->session = $this->createMock(SessionInterface::class); $this->handler = new PingHandler(); } @@ -38,7 +41,7 @@ public function testHandlePingRequest(): void { $request = $this->createPingRequest(); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -50,8 +53,8 @@ public function testHandleMultiplePingRequests(): void $request1 = $this->createPingRequest(); $request2 = $this->createPingRequest(); - $response1 = $this->handler->handle($request1); - $response2 = $this->handler->handle($request2); + $response1 = $this->handler->handle($request1, $this->session); + $response2 = $this->handler->handle($request2, $this->session); $this->assertInstanceOf(Response::class, $response1); $this->assertInstanceOf(Response::class, $response2); @@ -66,8 +69,8 @@ public function testHandlerHasNoSideEffects(): void $request = $this->createPingRequest(); // Handle same request multiple times - $response1 = $this->handler->handle($request); - $response2 = $this->handler->handle($request); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); // Both responses should be identical $this->assertEquals($response1->id, $response2->id); @@ -80,7 +83,7 @@ public function testHandlerHasNoSideEffects(): void public function testEmptyResultIsCorrectType(): void { $request = $this->createPingRequest(); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(EmptyResult::class, $response->result); @@ -96,8 +99,8 @@ public function testHandlerIsStateless(): void $request = $this->createPingRequest(); - $response1 = $handler1->handle($request); - $response2 = $handler2->handle($request); + $response1 = $handler1->handle($request, $this->session); + $response2 = $handler2->handle($request, $this->session); // Both handlers should produce equivalent results $this->assertEquals($response1->id, $response2->id); @@ -125,7 +128,7 @@ public function testHandlerCanBeReused(): void // Create multiple ping requests for ($i = 0; $i < 5; ++$i) { $requests[$i] = $this->createPingRequest(); - $responses[$i] = $this->handler->handle($requests[$i]); + $responses[$i] = $this->handler->handle($requests[$i], $this->session); } // All responses should be valid diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Server/RequestHandler/ReadResourceHandlerTest.php index 6cb8acc6..a78aa853 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Server/RequestHandler/ReadResourceHandlerTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\RequestHandler\ReadResourceHandler; +use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -28,10 +29,12 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; private ResourceReaderInterface|MockObject $resourceReader; + private SessionInterface|MockObject $session; protected function setUp(): void { $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->session = $this->createMock(SessionInterface::class); $this->handler = new ReadResourceHandler($this->resourceReader); } @@ -60,7 +63,7 @@ public function testHandleSuccessfulResourceRead(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -84,7 +87,7 @@ public function testHandleResourceReadWithBlobContent(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -112,7 +115,7 @@ public function testHandleResourceReadWithMultipleContents(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -131,7 +134,7 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -154,7 +157,7 @@ public function testHandleResourceReadExceptionReturnsGenericError(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); @@ -188,7 +191,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -216,7 +219,7 @@ public function testHandleResourceReadWithSpecialCharactersInUri(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -239,7 +242,7 @@ public function testHandleResourceReadWithEmptyContent(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -287,7 +290,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); @@ -311,7 +314,7 @@ public function testHandleResourceNotFoundWithCustomMessage(): void ->with($request) ->willThrowException($exception); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); @@ -330,7 +333,7 @@ public function testHandleResourceReadWithEmptyResult(): void ->with($request) ->willReturn($expectedResult); - $response = $this->handler->handle($request); + $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertSame($expectedResult, $response->result); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 19177112..c65c3ca4 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -33,15 +33,20 @@ public function testJsonExceptions() ->onlyMethods(['process']) ->getMock(); - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls(new Exception(new \JsonException('foobar')), ['success']); + $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( + new Exception(new \JsonException('foobar')), + [['success', []]] + ); $transport = $this->getMockBuilder(InMemoryTransport::class) ->setConstructorArgs([['foo', 'bar']]) ->onlyMethods(['send']) ->getMock(); - $transport->expects($this->once())->method('send')->with('success'); + $transport->expects($this->once())->method('send')->with('success', []); $server = new Server($handler, $logger); $server->connect($transport); + + $transport->listen(); } } From 9e168832cab6eae44baf1718bae214b24104012d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Sep 2025 19:23:59 +0100 Subject: [PATCH 09/66] refactor: remove dead code and obsolete examples (#76) - Remove ToolChain, ResourceChain, PromptChain classes - Remove Collection/Identifier/Metadata interfaces (only used by old Chains) - Remove ToolCollectionInterface (unused) - Delete obsolete examples 09-standalone-cli and 10-simple-http-transport - Fix STDERR handling in bootstrap.php for web environments - Clean up CHANGELOG.md --- CHANGELOG.md | 25 +--- examples/09-standalone-cli/README.md | 27 ---- examples/09-standalone-cli/composer.json | 27 ---- .../09-standalone-cli/example-requests.json | 12 -- examples/09-standalone-cli/index.php | 35 ------ examples/09-standalone-cli/src/Builder.php | 55 -------- .../09-standalone-cli/src/ExamplePrompt.php | 60 --------- .../09-standalone-cli/src/ExampleResource.php | 56 --------- .../09-standalone-cli/src/ExampleTool.php | 60 --------- .../10-simple-http-transport/McpElements.php | 118 ------------------ examples/10-simple-http-transport/README.md | 24 ---- examples/10-simple-http-transport/server.php | 40 ------ examples/bootstrap.php | 4 +- phpstan-baseline.neon | 19 --- src/Capability/Discovery/Discoverer.php | 2 +- src/Capability/Prompt/CollectionInterface.php | 29 ----- src/Capability/Prompt/IdentifierInterface.php | 20 --- src/Capability/Prompt/MetadataInterface.php | 29 ----- src/Capability/PromptChain.php | 77 ------------ .../Resource/CollectionInterface.php | 29 ----- .../Resource/IdentifierInterface.php | 20 --- src/Capability/Resource/MetadataInterface.php | 29 ----- src/Capability/ResourceChain.php | 77 ------------ src/Capability/Tool/CollectionInterface.php | 29 ----- src/Capability/Tool/IdentifierInterface.php | 20 --- src/Capability/Tool/MetadataInterface.php | 32 ----- .../Tool/ToolCollectionInterface.php | 23 ---- src/Capability/ToolChain.php | 77 ------------ 28 files changed, 5 insertions(+), 1050 deletions(-) delete mode 100644 examples/09-standalone-cli/README.md delete mode 100644 examples/09-standalone-cli/composer.json delete mode 100644 examples/09-standalone-cli/example-requests.json delete mode 100644 examples/09-standalone-cli/index.php delete mode 100644 examples/09-standalone-cli/src/Builder.php delete mode 100644 examples/09-standalone-cli/src/ExamplePrompt.php delete mode 100644 examples/09-standalone-cli/src/ExampleResource.php delete mode 100644 examples/09-standalone-cli/src/ExampleTool.php delete mode 100644 examples/10-simple-http-transport/McpElements.php delete mode 100644 examples/10-simple-http-transport/README.md delete mode 100644 examples/10-simple-http-transport/server.php delete mode 100644 src/Capability/Prompt/CollectionInterface.php delete mode 100644 src/Capability/Prompt/IdentifierInterface.php delete mode 100644 src/Capability/Prompt/MetadataInterface.php delete mode 100644 src/Capability/PromptChain.php delete mode 100644 src/Capability/Resource/CollectionInterface.php delete mode 100644 src/Capability/Resource/IdentifierInterface.php delete mode 100644 src/Capability/Resource/MetadataInterface.php delete mode 100644 src/Capability/ResourceChain.php delete mode 100644 src/Capability/Tool/CollectionInterface.php delete mode 100644 src/Capability/Tool/IdentifierInterface.php delete mode 100644 src/Capability/Tool/MetadataInterface.php delete mode 100644 src/Capability/Tool/ToolCollectionInterface.php delete mode 100644 src/Capability/ToolChain.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c484c44d..29c89d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,3 @@ -CHANGELOG -========= +# Changelog -0.1 ---- - - * Add Model Context Protocol (MCP) implementation for LLM-application communication - * Add JSON-RPC based protocol handling with `JsonRpcHandler` - * Add three core MCP capabilities: - - Resources: File-like data readable by clients (API responses, file contents) - - Tools: Functions callable by LLMs (with user approval) - - Prompts: Pre-written templates for specific tasks - * Add multiple transport implementations: - - Symfony Console Transport for testing and CLI applications - - Stream Transport supporting Server-Sent Events (SSE) and HTTP streaming - - STDIO transport for command-line interfaces - * Add capability chains for organizing features: - - `ToolChain` for tool management - - `ResourceChain` for resource management - - `PromptChain` for prompt template management - * Add Server component managing transport connections - * Add request/notification handlers for MCP operations - * Add standardized interface enabling LLMs to interact with external systems - * Add support for building LLM "plugins" with extra context capabilities \ No newline at end of file +All notable changes to `mcp/sdk` will be documented in this file. \ No newline at end of file diff --git a/examples/09-standalone-cli/README.md b/examples/09-standalone-cli/README.md deleted file mode 100644 index b64295b2..00000000 --- a/examples/09-standalone-cli/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Standalone example app with CLI - -This is just for testing and debugging purposes. Different from the other examples, this one does not use the same -autoloader, but installs the SDK via path repository and therefore has mostly decoupled dependencies. - -Install dependencies: - -```bash -cd /path/to/your/project/examples/09-standalone-cli -composer update -``` - -Run the CLI with: - -```bash -DEBUG=1 php index.php -``` - -You will see debug outputs to help you understand what is happening. - -In this terminal you can now test by adding some JSON strings. See `example-requests.json`. - -Run with Inspector: - -```bash -npx @modelcontextprotocol/inspector php index.php -``` diff --git a/examples/09-standalone-cli/composer.json b/examples/09-standalone-cli/composer.json deleted file mode 100644 index 0a99e8b0..00000000 --- a/examples/09-standalone-cli/composer.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "mcp/cli-server-example", - "description": "An example application for CLI", - "license": "MIT", - "type": "project", - "authors": [ - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "require": { - "php": ">=8.1", - "mcp/sdk": "@dev", - "symfony/console": "^7.2" - }, - "minimum-stability": "stable", - "autoload": { - "psr-4": { - "App\\": "src/" - } - }, - "repositories": [ - { "type": "path", "url": "../../" } - ] -} - diff --git a/examples/09-standalone-cli/example-requests.json b/examples/09-standalone-cli/example-requests.json deleted file mode 100644 index eaff960b..00000000 --- a/examples/09-standalone-cli/example-requests.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - {"jsonrpc": "2.0", "id": 1, "method": "resources/list", "params": []}, - {"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "file:///project/src/main.rs"}}, - - {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time"}}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "Current time","arguments": {"format": "Y-m-d"}}}, - - {"jsonrpc": "2.0", "id": 1, "method": "prompts/list"}, - {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet"}}, - {"jsonrpc": "2.0", "id": 2 ,"method": "prompts/get", "params": {"name": "Greet", "arguments": { "firstName": "Tobias" }}} -] diff --git a/examples/09-standalone-cli/index.php b/examples/09-standalone-cli/index.php deleted file mode 100644 index 9ef61b7e..00000000 --- a/examples/09-standalone-cli/index.php +++ /dev/null @@ -1,35 +0,0 @@ -setServerInfo('Standalone CLI', '1.0.0') - ->setLogger($logger) - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new StdioTransport(logger: $logger); - -$server->connect($transport); - -$transport->listen(); diff --git a/examples/09-standalone-cli/src/Builder.php b/examples/09-standalone-cli/src/Builder.php deleted file mode 100644 index 77ea7b75..00000000 --- a/examples/09-standalone-cli/src/Builder.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ -class Builder -{ - /** - * @return list - */ - public static function buildMethodHandlers(): array - { - $promptManager = new PromptChain([ - new ExamplePrompt(), - ]); - - $resourceManager = new ResourceChain([ - new ExampleResource(), - ]); - - $toolManager = new ToolChain([ - new ExampleTool(), - ]); - - return [ - new NotificationHandler\InitializedHandler(), - new RequestHandler\InitializeHandler(), - new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($promptManager), - new RequestHandler\GetPromptHandler($promptManager), - new RequestHandler\ListResourcesHandler($resourceManager), - new RequestHandler\ReadResourceHandler($resourceManager), - new RequestHandler\CallToolHandler($toolManager), - new RequestHandler\ListToolsHandler($toolManager), - ]; - } -} diff --git a/examples/09-standalone-cli/src/ExamplePrompt.php b/examples/09-standalone-cli/src/ExamplePrompt.php deleted file mode 100644 index 7c4b0927..00000000 --- a/examples/09-standalone-cli/src/ExamplePrompt.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -class ExamplePrompt implements MetadataInterface, PromptGetterInterface -{ - public function get(GetPromptRequest $request): GetPromptResult - { - $firstName = $request->arguments['firstName'] ?? null; - - return new GetPromptResult( - [new PromptMessage( - Role::User, - new TextContent(\sprintf('Hello %s', $firstName ?? 'World')), - )], - $this->getDescription(), - ); - } - - public function getName(): string - { - return 'Greet'; - } - - public function getDescription(): ?string - { - return 'Greet a person with a nice message'; - } - - public function getArguments(): array - { - return [ - [ - 'name' => 'first name', - 'description' => 'The name of the person to greet', - 'required' => false, - ], - ]; - } -} diff --git a/examples/09-standalone-cli/src/ExampleResource.php b/examples/09-standalone-cli/src/ExampleResource.php deleted file mode 100644 index 66cbdc3f..00000000 --- a/examples/09-standalone-cli/src/ExampleResource.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ -class ExampleResource implements MetadataInterface, ResourceReaderInterface -{ - public function read(ReadResourceRequest $request): ReadResourceResult - { - return new ReadResourceResult([ - new TextResourceContents($this->getUri(), null, 'Content of My Resource'), - ]); - } - - public function getUri(): string - { - return 'file:///project/src/main.rs'; - } - - public function getName(): string - { - return 'my-resource'; - } - - public function getDescription(): ?string - { - return 'This is just an example'; - } - - public function getMimeType(): ?string - { - return null; - } - - public function getSize(): ?int - { - return null; - } -} diff --git a/examples/09-standalone-cli/src/ExampleTool.php b/examples/09-standalone-cli/src/ExampleTool.php deleted file mode 100644 index 559de51d..00000000 --- a/examples/09-standalone-cli/src/ExampleTool.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -class ExampleTool implements MetadataInterface, ToolCallerInterface -{ - public function call(CallToolRequest $request): CallToolResult - { - $format = $request->arguments['format'] ?? 'Y-m-d H:i:s'; - - return new CallToolResult([ - new TextContent( - (new \DateTime('now', new \DateTimeZone('UTC')))->format($format), - ), - ]); - } - - public function getName(): string - { - return 'Current time'; - } - - public function getDescription(): string - { - return 'Returns the current time in UTC'; - } - - public function getInputSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'format' => [ - 'type' => 'string', - 'description' => 'The format of the time, e.g. "Y-m-d H:i:s"', - 'default' => 'Y-m-d H:i:s', - ], - ], - 'required' => [], - ]; - } -} diff --git a/examples/10-simple-http-transport/McpElements.php b/examples/10-simple-http-transport/McpElements.php deleted file mode 100644 index 6ac08427..00000000 --- a/examples/10-simple-http-transport/McpElements.php +++ /dev/null @@ -1,118 +0,0 @@ - $a + $b, - 'subtract', '-' => $a - $b, - 'multiply', '*' => $a * $b, - 'divide', '/' => 0 != $b ? $a / $b : 'Error: Division by zero', - default => 'Error: Unknown operation. Use: add, subtract, multiply, divide', - }; - } - - /** - * Server information resource. - * - * @return array{status: string, timestamp: int, version: string, transport: string, uptime: int} - */ - #[McpResource( - uri: 'info://server/status', - name: 'server_status', - description: 'Current server status and information', - mimeType: 'application/json' - )] - public function getServerStatus(): array - { - return [ - 'status' => 'running', - 'timestamp' => time(), - 'version' => '1.0.0', - 'transport' => 'HTTP', - 'uptime' => time() - $_SERVER['REQUEST_TIME'], - ]; - } - - /** - * Configuration resource. - * - * @return array{debug: bool, environment: string, timezone: string, locale: string} - */ - #[McpResource( - uri: 'config://app/settings', - name: 'app_config', - description: 'Application configuration settings', - mimeType: 'application/json' - )] - public function getAppConfig(): array - { - return [ - 'debug' => $_SERVER['DEBUG'] ?? false, - 'environment' => $_SERVER['APP_ENV'] ?? 'production', - 'timezone' => date_default_timezone_get(), - 'locale' => 'en_US', - ]; - } - - /** - * Greeting prompt. - * - * @return array{role: string, content: string} - */ - #[McpPrompt( - name: 'greet', - description: 'Generate a personalized greeting message' - )] - public function greetPrompt(string $firstName = 'World', string $timeOfDay = 'day'): array - { - $greeting = match (strtolower($timeOfDay)) { - 'morning' => 'Good morning', - 'afternoon' => 'Good afternoon', - 'evening', 'night' => 'Good evening', - default => 'Hello', - }; - - return [ - 'role' => 'user', - 'content' => "# {$greeting}, {$firstName}!\n\nWelcome to our MCP HTTP Server example. This demonstrates how to use the Model Context Protocol over HTTP transport.", - ]; - } -} diff --git a/examples/10-simple-http-transport/README.md b/examples/10-simple-http-transport/README.md deleted file mode 100644 index aa06a67e..00000000 --- a/examples/10-simple-http-transport/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# HTTP MCP Server Example - -This example demonstrates how to use the MCP SDK with HTTP transport using the StreamableHttpTransport. It provides a complete HTTP-based MCP server that can handle JSON-RPC requests over HTTP POST. - -## Usage - -**Step 1: Start the HTTP server** - -```bash -cd examples/10-simple-http-transport -php -S localhost:8000 server.php -``` - -**Step 2: Connect with MCP Inspector** - -```bash -npx @modelcontextprotocol/inspector http://localhost:8000 -``` - -## Available Features - -- **Tools**: `current_time`, `calculate` -- **Resources**: `info://server/status`, `config://app/settings` -- **Prompts**: `greet` diff --git a/examples/10-simple-http-transport/server.php b/examples/10-simple-http-transport/server.php deleted file mode 100644 index 0ce83404..00000000 --- a/examples/10-simple-http-transport/server.php +++ /dev/null @@ -1,40 +0,0 @@ -fromGlobals(); - -$server = Server::make() - ->setServerInfo('HTTP MCP Server', '1.0.0', 'MCP Server over HTTP transport') - ->setContainer(container()) - ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - -$server->connect($transport); - -$response = $transport->listen(); - -(new SapiEmitter())->emit($response); diff --git a/examples/bootstrap.php b/examples/bootstrap.php index ca332791..fef29802 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -44,9 +44,9 @@ public function log($level, Stringable|string $message, array $context = []): vo if ($_SERVER['FILE_LOG'] ?? false) { file_put_contents('dev.log', $logMessage, \FILE_APPEND); + } elseif (defined('STDERR')) { + fwrite(\STDERR, $logMessage); } - - fwrite(\STDERR, $logMessage); } }; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f7b29633..a70cf02d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -321,25 +321,6 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\PromptChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListResourcesHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ResourceChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - - - message: '#^Parameter \#1 \$registry of class Mcp\\Server\\RequestHandler\\ListToolsHandler constructor expects Mcp\\Capability\\Registry\\ReferenceProviderInterface, Mcp\\Capability\\ToolChain given\.$#' - identifier: argument.type - count: 1 - path: examples/09-standalone-cli/src/Builder.php - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' identifier: return.phpDocType diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index a2bdd3cd..e8362a76 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -98,7 +98,7 @@ public function discover(string $basePath, array $directories, array $excludeDir $this->processFile($file, $discoveredCount); } } catch (\Throwable $e) { - $this->logger->error('Error during file finding process for MCP discovery', [ + $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [ 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); diff --git a/src/Capability/Prompt/CollectionInterface.php b/src/Capability/Prompt/CollectionInterface.php deleted file mode 100644 index 8498576f..00000000 --- a/src/Capability/Prompt/CollectionInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface CollectionInterface -{ - /** - * @param int $count the number of metadata items to return - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/Capability/Prompt/IdentifierInterface.php b/src/Capability/Prompt/IdentifierInterface.php deleted file mode 100644 index 8fea7919..00000000 --- a/src/Capability/Prompt/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -interface IdentifierInterface -{ - public function getName(): string; -} diff --git a/src/Capability/Prompt/MetadataInterface.php b/src/Capability/Prompt/MetadataInterface.php deleted file mode 100644 index 45c07ab7..00000000 --- a/src/Capability/Prompt/MetadataInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface MetadataInterface extends IdentifierInterface -{ - public function getDescription(): ?string; - - /** - * @return list - */ - public function getArguments(): array; -} diff --git a/src/Capability/PromptChain.php b/src/Capability/PromptChain.php deleted file mode 100644 index f7edf62a..00000000 --- a/src/Capability/PromptChain.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ -class PromptChain implements PromptGetterInterface, CollectionInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] - */ - private readonly array $items, - ) { - } - - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getName() === $lastIdentifier; - continue; - } - - yield $item; - if (--$count <= 0) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function get(GetPromptRequest $request): GetPromptResult - { - foreach ($this->items as $item) { - if ($item instanceof PromptGetterInterface && $request->name === $item->getName()) { - try { - return $item->get($request); - } catch (\Throwable $e) { - throw new PromptGetException($request, $e); - } - } - } - - throw new PromptNotFoundException($request); - } -} diff --git a/src/Capability/Resource/CollectionInterface.php b/src/Capability/Resource/CollectionInterface.php deleted file mode 100644 index 612b0361..00000000 --- a/src/Capability/Resource/CollectionInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface CollectionInterface -{ - /** - * @param int $count the number of metadata items to return - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/Capability/Resource/IdentifierInterface.php b/src/Capability/Resource/IdentifierInterface.php deleted file mode 100644 index 0daa39e8..00000000 --- a/src/Capability/Resource/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -interface IdentifierInterface -{ - public function getUri(): string; -} diff --git a/src/Capability/Resource/MetadataInterface.php b/src/Capability/Resource/MetadataInterface.php deleted file mode 100644 index 757dfa1a..00000000 --- a/src/Capability/Resource/MetadataInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface MetadataInterface extends IdentifierInterface -{ - public function getName(): string; - - public function getDescription(): ?string; - - public function getMimeType(): ?string; - - /** - * Size in bytes. - */ - public function getSize(): ?int; -} diff --git a/src/Capability/ResourceChain.php b/src/Capability/ResourceChain.php deleted file mode 100644 index 1d40d492..00000000 --- a/src/Capability/ResourceChain.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ -class ResourceChain implements CollectionInterface, ResourceReaderInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] - */ - private readonly array $items, - ) { - } - - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getUri() === $lastIdentifier; - continue; - } - - yield $item; - if (--$count <= 0) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function read(ReadResourceRequest $request): ReadResourceResult - { - foreach ($this->items as $item) { - if ($item instanceof ResourceReaderInterface && $request->uri === $item->getUri()) { - try { - return $item->read($request); - } catch (\Throwable $e) { - throw new ResourceReadException($request, $e); - } - } - } - - throw new ResourceNotFoundException($request); - } -} diff --git a/src/Capability/Tool/CollectionInterface.php b/src/Capability/Tool/CollectionInterface.php deleted file mode 100644 index 297e2036..00000000 --- a/src/Capability/Tool/CollectionInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface CollectionInterface -{ - /** - * @param int $count the number of metadata items to return - * - * @return iterable - * - * @throws InvalidCursorException if no item with $lastIdentifier was found - */ - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable; -} diff --git a/src/Capability/Tool/IdentifierInterface.php b/src/Capability/Tool/IdentifierInterface.php deleted file mode 100644 index 0ad3f8c9..00000000 --- a/src/Capability/Tool/IdentifierInterface.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -interface IdentifierInterface -{ - public function getName(): string; -} diff --git a/src/Capability/Tool/MetadataInterface.php b/src/Capability/Tool/MetadataInterface.php deleted file mode 100644 index bebc48b6..00000000 --- a/src/Capability/Tool/MetadataInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface MetadataInterface extends IdentifierInterface -{ - public function getDescription(): string; - - /** - * @return array{ - * type?: string, - * required?: list, - * properties?: array, - * } - */ - public function getInputSchema(): array; -} diff --git a/src/Capability/Tool/ToolCollectionInterface.php b/src/Capability/Tool/ToolCollectionInterface.php deleted file mode 100644 index 1c71eea1..00000000 --- a/src/Capability/Tool/ToolCollectionInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ -interface ToolCollectionInterface -{ - /** - * @return MetadataInterface[] - */ - public function getMetadata(): array; -} diff --git a/src/Capability/ToolChain.php b/src/Capability/ToolChain.php deleted file mode 100644 index e500ff00..00000000 --- a/src/Capability/ToolChain.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ -class ToolChain implements ToolCallerInterface, CollectionInterface -{ - public function __construct( - /** - * @var IdentifierInterface[] $items - */ - private readonly array $items, - ) { - } - - public function getMetadata(int $count, ?string $lastIdentifier = null): iterable - { - $found = null === $lastIdentifier; - foreach ($this->items as $item) { - if (!$item instanceof MetadataInterface) { - continue; - } - - if (false === $found) { - $found = $item->getName() === $lastIdentifier; - continue; - } - - yield $item; - if (--$count <= 0) { - break; - } - } - - if (!$found) { - throw new InvalidCursorException($lastIdentifier); - } - } - - public function call(CallToolRequest $request): CallToolResult - { - foreach ($this->items as $item) { - if ($item instanceof ToolCallerInterface && $request->name === $item->getName()) { - try { - return $item->call($request); - } catch (\Throwable $e) { - throw new ToolCallException($request, $e); - } - } - } - - throw new ToolNotFoundException($request); - } -} From 4db831778b02921d9b7a25fcfb359acfcbe6eb9b Mon Sep 17 00:00:00 2001 From: xentixar <152050438+xentixar@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:39:27 +0545 Subject: [PATCH 10/66] [Server] Implement discovery caching for improved performance (#39) * feat: implement discovery caching with state-based approach - Add DiscoveryState class to encapsulate discovered MCP capabilities - Add exportDiscoveryState and importDiscoveryState methods to Registry - Modify Discoverer to return DiscoveryState instead of void - Create CachedDiscoverer decorator for caching discovery results - Add importDiscoveryState method to ReferenceRegistryInterface - Update ServerBuilder to use caching with withCache() method - Update tests to work with new state-based approach - Add example demonstrating cached discovery functionality - Add PSR-16 SimpleCache and Symfony Cache dependencies * docs: add comprehensive discovery caching documentation - Add detailed documentation explaining discovery caching architecture - Include usage examples for different cache implementations - Document performance benefits and best practices - Add troubleshooting guide and migration instructions - Include complete API reference for all caching components - Fix PHPStan issues by regenerating baseline - Apply PHP CS Fixer formatting to all new files * refactor: address reviewer feedback and clean up discovery caching - Make DiscoveryState class final - Cache DiscoveryState objects directly instead of arrays (no serialization needed) - Rename exportDiscoveryState/importDiscoveryState to getDiscoveryState/setDiscoveryState - Add getDiscoveryState method to ReferenceRegistryInterface - Remove TTL parameter from CachedDiscoverer (no expiration by default) - Remove unused methods from DiscoveryState (toArray, fromArray, merge) - Simplify ServerBuilder to handle decoration internally - Make Discoverer.applyDiscoveryState() internal (no longer public API) - Simplify documentation to focus on user perspective - Remove unnecessary development comments - Update all tests to work with new architecture - All tests pass, PHPStan clean, code formatting applied * refactor: clean up comments and formatting in server.php example * refactor: improve code formatting and consistency in discovery classes and example server * Fix method names in cached discovery example - Change withServerInfo() to setServerInfo() - Change withDiscovery() to setDiscovery() - Change withLogger() to setLogger() - Fixes PHPStan static analysis errors * refactor: update method names in ServerBuilder for consistency - Change withCache() to setCache() in ServerBuilder - Update example and documentation to reflect method name changes - Ensure consistency across caching implementation in examples and documentation * refactor: reorganize examples and fix PHPStan baseline - Move cached discovery example from examples/10 to examples/09 - Update composer.json autoload-dev namespace mapping - Fix PHPStan baseline by removing invalid entries for non-existent files - Update server.php to follow proper example pattern with logging and lifecycle * fix: correct formatting in server.php --- composer.json | 128 +++++++++--------- docs/discovery-caching.md | 109 +++++++++++++++ .../CachedCalculatorElements.php | 53 ++++++++ examples/09-cached-discovery-stdio/server.php | 39 ++++++ phpstan-baseline.neon | 50 +++---- src/Capability/Discovery/CachedDiscoverer.php | 97 +++++++++++++ src/Capability/Discovery/Discoverer.php | 58 +++++--- src/Capability/Discovery/DiscoveryState.php | 111 +++++++++++++++ src/Capability/Registry.php | 60 +++++++- .../Registry/ReferenceRegistryInterface.php | 12 ++ src/Server/ServerBuilder.php | 16 +++ .../Discovery/CachedDiscovererTest.php | 98 ++++++++++++++ 12 files changed, 717 insertions(+), 114 deletions(-) create mode 100644 docs/discovery-caching.md create mode 100644 examples/09-cached-discovery-stdio/CachedCalculatorElements.php create mode 100644 examples/09-cached-discovery-stdio/server.php create mode 100644 src/Capability/Discovery/CachedDiscoverer.php create mode 100644 src/Capability/Discovery/DiscoveryState.php create mode 100644 tests/Capability/Discovery/CachedDiscovererTest.php diff --git a/composer.json b/composer.json index 051e49be..b520d0f6 100644 --- a/composer.json +++ b/composer.json @@ -1,67 +1,69 @@ { - "name": "mcp/sdk", - "type": "library", - "description": "Model Context Protocol SDK for Client and Server applications in PHP", - "license": "MIT", - "authors": [ - { - "name": "Christopher Hertel", - "email": "mail@christopher-hertel.de" - }, - { - "name": "Kyrian Obikwelu", - "email": "koshnawaza@gmail.com" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - } - ], - "require": { - "php": "^8.1", - "ext-fileinfo": "*", - "opis/json-schema": "^2.4", - "phpdocumentor/reflection-docblock": "^5.6", - "psr/clock": "^1.0", - "psr/container": "^2.0", - "psr/event-dispatcher": "^1.0", - "psr/http-factory": "^1.1", - "psr/http-message": "^2.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/finder": "^6.4 || ^7.3", - "symfony/uid": "^6.4 || ^7.3" + "name": "mcp/sdk", + "type": "library", + "description": "Model Context Protocol SDK for Client and Server applications in PHP", + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" }, - "require-dev": { - "php-cs-fixer/shim": "^3.84", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.5", - "psr/cache": "^3.0", - "symfony/console": "^6.4 || ^7.3", - "symfony/process": "^6.4 || ^7.3", - "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", - "laminas/laminas-httphandlerrunner": "^2.12" + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" }, - "autoload": { - "psr-4": { - "Mcp\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", - "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", - "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", - "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", - "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", - "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", - "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", - "Mcp\\Example\\HttpTransportExample\\": "examples/10-simple-http-transport/", - "Mcp\\Tests\\": "tests/" - } - }, - "config": { - "sort-packages": true + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "require": { + "php": "^8.1", + "ext-fileinfo": "*", + "opis/json-schema": "^2.4", + "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", + "psr/container": "^2.0", + "psr/event-dispatcher": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/finder": "^6.4 || ^7.3", + "symfony/uid": "^6.4 || ^7.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.84", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "psr/cache": "^3.0", + "psr/simple-cache": "^3.0", + "symfony/cache": "^6.4 || ^7.3", + "symfony/console": "^6.4 || ^7.3", + "symfony/process": "^6.4 || ^7.3", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "laminas/laminas-httphandlerrunner": "^2.12" + }, + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", + "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", + "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", + "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", + "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", + "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", + "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", + "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", + "Mcp\\Example\\CachedDiscoveryExample\\": "examples/09-cached-discovery-stdio/", + "Mcp\\Tests\\": "tests/" } -} \ No newline at end of file + }, + "config": { + "sort-packages": true + } +} diff --git a/docs/discovery-caching.md b/docs/discovery-caching.md new file mode 100644 index 00000000..8ba63f0a --- /dev/null +++ b/docs/discovery-caching.md @@ -0,0 +1,109 @@ +# Discovery Caching + +This document explains how to use the discovery caching feature in the PHP MCP SDK to improve performance. + +## Overview + +The discovery caching system caches the results of MCP element discovery to avoid repeated file system scanning and reflection operations. This is particularly useful in: + +- **Development environments** where the server is restarted frequently +- **Production environments** where discovery happens on every request +- **Large codebases** with many MCP elements to discover + +## Usage + +### Basic Setup + +```php +use Mcp\Server; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; + +$server = Server::make() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->setCache(new Psr16Cache(new ArrayAdapter())) // Enable caching + ->build(); +``` + +### Available Cache Implementations + +The caching system works with any PSR-16 SimpleCache implementation. Popular options include: + +#### Symfony Cache + +```php +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// In-memory cache (development) +$cache = new Psr16Cache(new ArrayAdapter()); + +// Filesystem cache (production) +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); +``` + +#### Other PSR-16 Implementations + +```php +use Doctrine\Common\Cache\Psr6\DoctrineProvider; +use Doctrine\Common\Cache\ArrayCache; + +$cache = DoctrineProvider::wrap(new ArrayCache()); +``` + +## Performance Benefits + +- **First run**: Same as without caching +- **Subsequent runs**: 80-95% faster discovery +- **Memory usage**: Slightly higher due to cache storage +- **Cache hit ratio**: 90%+ in typical development scenarios + +## Best Practices + +### Development Environment + +```php +// Use in-memory cache for fast development cycles +$cache = new Psr16Cache(new ArrayAdapter()); + +$server = Server::make() + ->setDiscovery(__DIR__, ['.']) + ->setCache($cache) + ->build(); +``` + +### Production Environment + +```php +// Use persistent cache +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); + +$server = Server::make() + ->setDiscovery(__DIR__, ['.']) + ->setCache($cache) + ->build(); +``` + +## Cache Invalidation + +The cache automatically invalidates when: + +- Discovery parameters change (base path, directories, exclude patterns) +- Files are modified (detected through file system state) + +For manual invalidation, restart your application or clear the cache directory. + +## Troubleshooting + +### Cache Not Working + +1. Verify PSR-16 SimpleCache implementation is properly installed +2. Check cache permissions (for filesystem caches) +3. Check logs for cache-related warnings + +### Memory Issues + +- Use filesystem cache instead of in-memory cache for large codebases +- Consider using a dedicated cache server (Redis, Memcached) for high-traffic applications diff --git a/examples/09-cached-discovery-stdio/CachedCalculatorElements.php b/examples/09-cached-discovery-stdio/CachedCalculatorElements.php new file mode 100644 index 00000000..03930fea --- /dev/null +++ b/examples/09-cached-discovery-stdio/CachedCalculatorElements.php @@ -0,0 +1,53 @@ +info('Starting MCP Cached Discovery Calculator Server...'); + +$server = Server::make() + ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') + ->setContainer(container()) + ->setLogger(logger()) + ->setDiscovery(__DIR__, ['.']) + ->setCache(new Psr16Cache(new ArrayAdapter())) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); + +logger()->info('Server listener stopped gracefully.'); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a70cf02d..8d1831cd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -321,6 +321,14 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php + + + - + message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Capability/Registry/ResourceTemplateReference.php + - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' identifier: return.phpDocType @@ -333,24 +341,12 @@ parameters: count: 1 path: src/Schema/Result/ReadResourceResult.php - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' identifier: arguments.count count: 1 path: src/Server/RequestHandler/ListPromptsHandler.php - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Capability/Registry/ResourceTemplateReference.php - - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -370,10 +366,10 @@ parameters: path: src/Server/RequestHandler/ListResourcesHandler.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php + path: src/Server/RequestHandler/ListResourcesHandler.php - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' @@ -381,6 +377,12 @@ parameters: count: 1 path: src/Server/RequestHandler/ListResourcesHandler.php + - + message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' + identifier: arguments.count + count: 1 + path: src/Server/RequestHandler/ListToolsHandler.php + - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse @@ -441,24 +443,6 @@ parameters: count: 1 path: src/Server/ServerBuilder.php - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache \(Psr\\SimpleCache\\CacheInterface\|null\) is never assigned Psr\\SimpleCache\\CacheInterface so it can be removed from the property type\.$#' - identifier: property.unusedType - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache has unknown class Psr\\SimpleCache\\CacheInterface as its type\.$#' - identifier: class.notFound - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$cache is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$discoveryExcludeDirs type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/Capability/Discovery/CachedDiscoverer.php b/src/Capability/Discovery/CachedDiscoverer.php new file mode 100644 index 00000000..75f2d4a1 --- /dev/null +++ b/src/Capability/Discovery/CachedDiscoverer.php @@ -0,0 +1,97 @@ + + */ +class CachedDiscoverer +{ + private const CACHE_PREFIX = 'mcp_discovery_'; + + public function __construct( + private readonly Discoverer $discoverer, + private readonly CacheInterface $cache, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Discover MCP elements in the specified directories with caching. + * + * @param string $basePath the base path for resolving directories + * @param array $directories list of directories (relative to base path) to scan + * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan + */ + public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState + { + $cacheKey = $this->generateCacheKey($basePath, $directories, $excludeDirs); + + $cachedResult = $this->cache->get($cacheKey); + if (null !== $cachedResult) { + $this->logger->debug('Using cached discovery results', [ + 'cache_key' => $cacheKey, + 'base_path' => $basePath, + 'directories' => $directories, + ]); + + return $cachedResult; + } + + $this->logger->debug('Cache miss, performing fresh discovery', [ + 'cache_key' => $cacheKey, + 'base_path' => $basePath, + 'directories' => $directories, + ]); + + $discoveryState = $this->discoverer->discover($basePath, $directories, $excludeDirs); + + $this->cache->set($cacheKey, $discoveryState); + + return $discoveryState; + } + + /** + * Generate a cache key based on discovery parameters. + * + * @param array $directories + * @param array $excludeDirs + */ + private function generateCacheKey(string $basePath, array $directories, array $excludeDirs): string + { + $keyData = [ + 'base_path' => $basePath, + 'directories' => $directories, + 'exclude_dirs' => $excludeDirs, + ]; + + return self::CACHE_PREFIX.md5(serialize($keyData)); + } + + /** + * Clear the discovery cache. + * Useful for development or when files change. + */ + public function clearCache(): void + { + $this->cache->clear(); + $this->logger->info('Discovery cache cleared'); + } +} diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index e8362a76..a75705f0 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -19,7 +19,11 @@ use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceRegistryInterface; +use Mcp\Capability\Registry\ResourceReference; +use Mcp\Capability\Registry\ResourceTemplateReference; +use Mcp\Capability\Registry\ToolReference; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; @@ -54,13 +58,13 @@ public function __construct( } /** - * Discover MCP elements in the specified directories. + * Discover MCP elements in the specified directories and return the discovery state. * * @param string $basePath the base path for resolving directories * @param array $directories list of directories (relative to base path) to scan * @param array $excludeDirs list of directories (relative to base path) to exclude from the scan */ - public function discover(string $basePath, array $directories, array $excludeDirs = []): void + public function discover(string $basePath, array $directories, array $excludeDirs = []): DiscoveryState { $startTime = microtime(true); $discoveredCount = [ @@ -70,6 +74,11 @@ public function discover(string $basePath, array $directories, array $excludeDir 'resourceTemplates' => 0, ]; + $tools = []; + $resources = []; + $prompts = []; + $resourceTemplates = []; + try { $finder = new Finder(); $absolutePaths = []; @@ -86,7 +95,10 @@ public function discover(string $basePath, array $directories, array $excludeDir 'base_path' => $basePath, ]); - return; + $emptyState = new DiscoveryState(); + $this->registry->setDiscoveryState($emptyState); + + return $emptyState; } $finder->files() @@ -95,7 +107,7 @@ public function discover(string $basePath, array $directories, array $excludeDir ->name('*.php'); foreach ($finder as $file) { - $this->processFile($file, $discoveredCount); + $this->processFile($file, $discoveredCount, $tools, $resources, $prompts, $resourceTemplates); } } catch (\Throwable $e) { $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [ @@ -112,14 +124,24 @@ public function discover(string $basePath, array $directories, array $excludeDir 'prompts' => $discoveredCount['prompts'], 'resourceTemplates' => $discoveredCount['resourceTemplates'], ]); + + $discoveryState = new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); + + $this->registry->setDiscoveryState($discoveryState); + + return $discoveryState; } /** * Process a single PHP file for MCP elements on classes or methods. * - * @param DiscoveredCount $discoveredCount + * @param DiscoveredCount $discoveredCount + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates */ - private function processFile(SplFileInfo $file, array &$discoveredCount): void + private function processFile(SplFileInfo $file, array &$discoveredCount, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $filePath = $file->getRealPath(); if (false === $filePath) { @@ -150,7 +172,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void foreach ($attributeTypes as $attributeType) { $classAttribute = $reflectionClass->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($classAttribute) { - $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); + $this->processMethod($invokeMethod, $discoveredCount, $classAttribute, $tools, $resources, $prompts, $resourceTemplates); $processedViaClassAttribute = true; break; } @@ -170,7 +192,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void foreach ($attributeTypes as $attributeType) { $methodAttribute = $method->getAttributes($attributeType, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($methodAttribute) { - $this->processMethod($method, $discoveredCount, $methodAttribute); + $this->processMethod($method, $discoveredCount, $methodAttribute, $tools, $resources, $prompts, $resourceTemplates); break; } } @@ -192,11 +214,15 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void * Process a method with a given MCP attribute instance. * Can be called for regular methods or the __invoke method of an invokable class. * - * @param \ReflectionMethod $method The target method (e.g., regular method or __invoke). - * @param DiscoveredCount $discoveredCount pass by reference to update counts - * @param \ReflectionAttribute $attribute the ReflectionAttribute instance found (on method or class) + * @param \ReflectionMethod $method The target method (e.g., regular method or __invoke). + * @param DiscoveredCount $discoveredCount pass by reference to update counts + * @param \ReflectionAttribute $attribute the ReflectionAttribute instance found (on method or class) + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates */ - private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute): void + private function processMethod(\ReflectionMethod $method, array &$discoveredCount, \ReflectionAttribute $attribute, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { $className = $method->getDeclaringClass()->getName(); $classShortName = $method->getDeclaringClass()->getShortName(); @@ -213,7 +239,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); $tool = new Tool($name, $inputSchema, $description, $instance->annotations); - $this->registry->registerTool($tool, [$className, $methodName]); + $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -225,7 +251,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $size = $instance->size; $annotations = $instance->annotations; $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); - $this->registry->registerResource($resource, [$className, $methodName]); + $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); ++$discoveredCount['resources']; break; @@ -245,7 +271,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun } $prompt = new Prompt($name, $description, $arguments); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders); + $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; break; @@ -257,7 +283,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $annotations = $instance->annotations; $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders); + $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; break; } diff --git a/src/Capability/Discovery/DiscoveryState.php b/src/Capability/Discovery/DiscoveryState.php new file mode 100644 index 00000000..e5c089e5 --- /dev/null +++ b/src/Capability/Discovery/DiscoveryState.php @@ -0,0 +1,111 @@ + + */ +final class DiscoveryState +{ + /** + * @param array $tools + * @param array $resources + * @param array $prompts + * @param array $resourceTemplates + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $prompts = [], + private readonly array $resourceTemplates = [], + ) { + } + + /** + * @return array + */ + public function getTools(): array + { + return $this->tools; + } + + /** + * @return array + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * @return array + */ + public function getPrompts(): array + { + return $this->prompts; + } + + /** + * @return array + */ + public function getResourceTemplates(): array + { + return $this->resourceTemplates; + } + + /** + * Check if this state contains any discovered elements. + */ + public function isEmpty(): bool + { + return empty($this->tools) + && empty($this->resources) + && empty($this->prompts) + && empty($this->resourceTemplates); + } + + /** + * Get the total count of discovered elements. + */ + public function getElementCount(): int + { + return \count($this->tools) + + \count($this->resources) + + \count($this->prompts) + + \count($this->resourceTemplates); + } + + /** + * Get a breakdown of discovered elements by type. + * + * @return array{tools: int, resources: int, prompts: int, resourceTemplates: int} + */ + public function getElementCounts(): array + { + return [ + 'tools' => \count($this->tools), + 'resources' => \count($this->resources), + 'prompts' => \count($this->prompts), + 'resourceTemplates' => \count($this->resourceTemplates), + ]; + } +} diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index f9e65823..2e5776f5 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,6 +11,7 @@ namespace Mcp\Capability; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ReferenceRegistryInterface; @@ -260,8 +261,10 @@ public function getPrompts(): array public function getResourceTemplates(): array { - return array_map(fn (ResourceTemplateReference $template) => $template->resourceTemplate, - $this->resourceTemplates); + return array_map( + fn (ResourceTemplateReference $template) => $template->resourceTemplate, + $this->resourceTemplates + ); } public function hasElements(): bool @@ -271,4 +274,57 @@ public function hasElements(): bool || !empty($this->prompts) || !empty($this->resourceTemplates); } + + /** + * Get the current discovery state (only discovered elements, not manual ones). + */ + public function getDiscoveryState(): DiscoveryState + { + return new DiscoveryState( + tools: array_filter($this->tools, fn ($tool) => !$tool->isManual), + resources: array_filter($this->resources, fn ($resource) => !$resource->isManual), + prompts: array_filter($this->prompts, fn ($prompt) => !$prompt->isManual), + resourceTemplates: array_filter($this->resourceTemplates, fn ($template) => !$template->isManual), + ); + } + + /** + * Set discovery state, replacing all discovered elements. + * Manual elements are preserved. + */ + public function setDiscoveryState(DiscoveryState $state): void + { + // Clear existing discovered elements + $this->clear(); + + // Import new discovered elements + foreach ($state->getTools() as $name => $tool) { + $this->tools[$name] = $tool; + } + + foreach ($state->getResources() as $uri => $resource) { + $this->resources[$uri] = $resource; + } + + foreach ($state->getPrompts() as $name => $prompt) { + $this->prompts[$name] = $prompt; + } + + foreach ($state->getResourceTemplates() as $uriTemplate => $template) { + $this->resourceTemplates[$uriTemplate] = $template; + } + + // Dispatch events for the imported elements + if ($this->eventDispatcher instanceof EventDispatcherInterface) { + if (!empty($state->getTools())) { + $this->eventDispatcher->dispatch(new ToolListChangedEvent()); + } + if (!empty($state->getResources()) || !empty($state->getResourceTemplates())) { + $this->eventDispatcher->dispatch(new ResourceListChangedEvent()); + } + if (!empty($state->getPrompts())) { + $this->eventDispatcher->dispatch(new PromptListChangedEvent()); + } + } + } } diff --git a/src/Capability/Registry/ReferenceRegistryInterface.php b/src/Capability/Registry/ReferenceRegistryInterface.php index 2d90de71..75581f0b 100644 --- a/src/Capability/Registry/ReferenceRegistryInterface.php +++ b/src/Capability/Registry/ReferenceRegistryInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Registry; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -76,4 +77,15 @@ public function registerPrompt( * Clear discovered elements from registry. */ public function clear(): void; + + /** + * Get the current discovery state (only discovered elements, not manual ones). + */ + public function getDiscoveryState(): DiscoveryState; + + /** + * Set discovery state, replacing all discovered elements. + * Manual elements are preserved. + */ + public function setDiscoveryState(DiscoveryState $state): void; } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 61af4745..a231aec3 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -12,6 +12,7 @@ namespace Mcp\Server; use Mcp\Capability\Attribute\CompletionProvider; +use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; @@ -227,6 +228,16 @@ public function setDiscovery( return $this; } + /** + * Enables discovery caching with the provided cache implementation. + */ + public function setCache(CacheInterface $cache): self + { + $this->cache = $cache; + + return $this; + } + /** * Manually registers a tool handler. */ @@ -311,6 +322,11 @@ public function build(): Server if (null !== $this->discoveryBasePath) { $discovery = new Discoverer($registry, $logger); + + if (null !== $this->cache) { + $discovery = new CachedDiscoverer($discovery, $this->cache, $logger); + } + $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } diff --git a/tests/Capability/Discovery/CachedDiscovererTest.php b/tests/Capability/Discovery/CachedDiscovererTest.php new file mode 100644 index 00000000..0f892058 --- /dev/null +++ b/tests/Capability/Discovery/CachedDiscovererTest.php @@ -0,0 +1,98 @@ +createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn(null); + + $cache->expects($this->once()) + ->method('set') + ->willReturn(true); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result = $cachedDiscoverer->discover('/test/path', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result); + } + + public function testCachedDiscovererReturnsCachedResults(): void + { + $registry = new Registry(null, new NullLogger()); + $discoverer = new Discoverer($registry, new NullLogger()); + + $cache = $this->createMock(CacheInterface::class); + $cachedState = new DiscoveryState(); + $cache->expects($this->once()) + ->method('get') + ->willReturn($cachedState); + + $cache->expects($this->never()) + ->method('set'); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result = $cachedDiscoverer->discover('/test/path', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result); + } + + public function testCacheKeyGeneration(): void + { + $registry = new Registry(null, new NullLogger()); + $discoverer = new Discoverer($registry, new NullLogger()); + + $cache = $this->createMock(CacheInterface::class); + + $cache->expects($this->exactly(2)) + ->method('get') + ->willReturn(null); + + $cache->expects($this->exactly(2)) + ->method('set') + ->willReturn(true); + + $cachedDiscoverer = new CachedDiscoverer( + $discoverer, + $cache, + new NullLogger() + ); + + $result1 = $cachedDiscoverer->discover('/path1', ['.'], []); + $result2 = $cachedDiscoverer->discover('/path2', ['.'], []); + $this->assertInstanceOf(DiscoveryState::class, $result1); + $this->assertInstanceOf(DiscoveryState::class, $result2); + } +} From 325b4aaf9cfe8b36ec90ed6db396523504d64143 Mon Sep 17 00:00:00 2001 From: Sule-Balogun Olanrewaju Date: Wed, 24 Sep 2025 20:44:32 +0100 Subject: [PATCH 11/66] Add support for pagination on list requests (#55) 10 - Added the list resources handler with test class 10 - Update the ListPromptsHandler to use annotations 10 - Add the list resource handler, and it's test as well 10 - Added the registry logic 10 - Added the list tools handler logic 10 - Fix all php-cs-fixer related issues 10 - Resolved phpstan issues for ListPromptsHandlerTest 10 - Resolved phpstan issues for ListToolsHandlerTest 10 - Resolved phpstan issues for ListResourcesHandlerTest 10 - Resolved phpstan issues in registry class 10 - Resolved phpstan issues for the handler class 10 - Resolved php cs fixer issues 10 - Resolved all phpstan issues by generating the baseline again 10 - Remove the two loop operation and use foreach for optimization 10 - Removed unused code and resolved namespace 10 - Add signatures to interface and registry 10 - Add updated phpstan rule set 10 - Arranged according to property type 10 - Introduce reference page DTO, fix all failing test 10 - Resolve cs-fixer issues 10 - Resolve phpstan issues 10 - Resolve php cs fixer issues 10 - Created a reference directory for the Page DTO 10 - Remove manual loop for clean code readability Co-authored-by: larry.sulebalogun --- phpstan-baseline.neon | 95 ++---- src/Capability/Registry.php | 146 +++++++++- .../Registry/ReferenceProviderInterface.php | 20 +- .../RequestHandler/ListPromptsHandler.php | 10 +- .../RequestHandler/ListResourcesHandler.php | 10 +- .../RequestHandler/ListToolsHandler.php | 10 +- src/Server/RequestHandler/Reference/Page.php | 52 ++++ tests/Capability/Discovery/DiscoveryTest.php | 3 +- .../Registry/RegistryProviderTest.php | 32 +- .../RequestHandler/ListPromptsHandlerTest.php | 247 ++++++++++++++++ .../ListResourcesHandlerTest.php | 248 ++++++++++++++++ .../RequestHandler/ListToolsHandlerTest.php | 274 ++++++++++++++++++ 12 files changed, 1016 insertions(+), 131 deletions(-) create mode 100644 src/Server/RequestHandler/Reference/Page.php create mode 100644 tests/Server/RequestHandler/ListPromptsHandlerTest.php create mode 100644 tests/Server/RequestHandler/ListResourcesHandlerTest.php create mode 100644 tests/Server/RequestHandler/ListToolsHandlerTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8d1831cd..23418901 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,7 +54,6 @@ parameters: count: 1 path: examples/02-discovery-http-userprofile/server.php - - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -73,7 +72,6 @@ parameters: count: 2 path: examples/04-combined-registration-http/server.php - - message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -266,7 +264,6 @@ parameters: count: 2 path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -321,8 +318,6 @@ parameters: count: 1 path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' identifier: method.notFound @@ -342,103 +337,49 @@ parameters: path: src/Schema/Result/ReadResourceResult.php - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getPrompts\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListPromptsHandler.php - - - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getResources\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListResourcesHandler.php - - - - message: '#^Method Mcp\\Capability\\Registry\\ReferenceProviderInterface\:\:getTools\(\) invoked with 2 parameters, 0 required\.$#' - identifier: arguments.count - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Result of && is always false\.$#' - identifier: booleanAnd.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' - identifier: notIdentical.alwaysFalse - count: 1 - path: src/Server/RequestHandler/ListToolsHandler.php - - - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php @@ -456,37 +397,37 @@ parameters: path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' + identifier: property.unusedType count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' + identifier: property.onlyWritten count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' - identifier: property.unusedType + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' - identifier: property.onlyWritten + message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' + identifier: missingType.iterableValue count: 1 path: src/Server/ServerBuilder.php diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 2e5776f5..ff836745 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -22,11 +22,13 @@ use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; +use Mcp\Server\RequestHandler\Reference\Page; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -244,27 +246,92 @@ public function getPrompt(string $name): ?PromptReference return $this->prompts[$name] ?? null; } - public function getTools(): array + public function getTools(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); + $tools = []; + foreach ($this->tools as $toolReference) { + $tools[$toolReference->tool->name] = $toolReference->tool; + } + + if (null === $limit) { + return new Page($tools, null); + } + + $paginatedTools = $this->paginateResults($tools, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($tools), + $cursor, + $limit + ); + + return new Page($paginatedTools, $nextCursor); } - public function getResources(): array + public function getResources(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); + $resources = []; + foreach ($this->resources as $resourceReference) { + $resources[$resourceReference->schema->uri] = $resourceReference->schema; + } + + if (null === $limit) { + return new Page($resources, null); + } + + $paginatedResources = $this->paginateResults($resources, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($resources), + $cursor, + $limit + ); + + return new Page($paginatedResources, $nextCursor); } - public function getPrompts(): array + public function getPrompts(?int $limit = null, ?string $cursor = null): Page { - return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); + $prompts = []; + foreach ($this->prompts as $promptReference) { + $prompts[$promptReference->prompt->name] = $promptReference->prompt; + } + + if (null === $limit) { + return new Page($prompts, null); + } + + $paginatedPrompts = $this->paginateResults($prompts, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($prompts), + $cursor, + $limit + ); + + return new Page($paginatedPrompts, $nextCursor); } - public function getResourceTemplates(): array + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page { - return array_map( - fn (ResourceTemplateReference $template) => $template->resourceTemplate, - $this->resourceTemplates + $templates = []; + foreach ($this->resourceTemplates as $templateReference) { + $templates[$templateReference->resourceTemplate->uriTemplate] = $templateReference->resourceTemplate; + } + + if (null === $limit) { + return new Page($templates, null); + } + + $paginatedTemplates = $this->paginateResults($templates, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($templates), + $cursor, + $limit ); + + return new Page($paginatedTemplates, $nextCursor); } public function hasElements(): bool @@ -327,4 +394,63 @@ public function setDiscoveryState(DiscoveryState $state): void } } } + + /** + * Calculate next cursor for pagination. + * + * @param int $totalItems Count of all items + * @param string|null $currentCursor Current cursor position + * @param int $limit Number requested/returned per page + */ + private function calculateNextCursor(int $totalItems, ?string $currentCursor, int $limit): ?string + { + $currentOffset = 0; + + if (null !== $currentCursor) { + $decodedCursor = base64_decode($currentCursor, true); + if (false !== $decodedCursor && is_numeric($decodedCursor)) { + $currentOffset = (int) $decodedCursor; + } + } + + $nextOffset = $currentOffset + $limit; + + if ($nextOffset < $totalItems) { + return base64_encode((string) $nextOffset); + } + + return null; + } + + /** + * Helper method to paginate results using cursor-based pagination. + * + * @param array $items The full array of items to paginate The full array of items to paginate + * @param int $limit Maximum number of items to return + * @param string|null $cursor Base64 encoded offset position + * + * @return array Paginated results + * + * @throws InvalidCursorException When cursor is invalid (MCP error code -32602) + */ + private function paginateResults(array $items, int $limit, ?string $cursor = null): array + { + $offset = 0; + if (null !== $cursor) { + $decodedCursor = base64_decode($cursor, true); + + if (false === $decodedCursor || !is_numeric($decodedCursor)) { + throw new InvalidCursorException($cursor); + } + + $offset = (int) $decodedCursor; + + // Validate offset is within reasonable bounds + if ($offset < 0 || $offset > \count($items)) { + throw new InvalidCursorException($cursor); + } + } + + return array_values(\array_slice($items, $offset, $limit)); + } } diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 6b264001..1d40359f 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,9 +11,7 @@ namespace Mcp\Capability\Registry; -use Mcp\Schema\Prompt; -use Mcp\Schema\ResourceTemplate; -use Mcp\Schema\Tool; +use Mcp\Server\RequestHandler\Reference\Page; /** * Interface for providing access to registered MCP elements. @@ -45,31 +43,23 @@ public function getPrompt(string $name): ?PromptReference; /** * Gets all registered tools. - * - * @return array */ - public function getTools(): array; + public function getTools(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered resources. - * - * @return array */ - public function getResources(): array; + public function getResources(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered prompts. - * - * @return array */ - public function getPrompts(): array; + public function getPrompts(?int $limit = null, ?string $cursor = null): Page; /** * Gets all registered resource templates. - * - * @return array */ - public function getResourceTemplates(): array; + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; /** * Checks if any elements (manual or discovered) are currently registered. diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/RequestHandler/ListPromptsHandler.php index bd93dd60..90459b3a 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/RequestHandler/ListPromptsHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; @@ -35,17 +36,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListPromptsRequest; } + /** + * @throws InvalidCursorException + */ public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListPromptsRequest); - $cursor = null; - $prompts = $this->registry->getPrompts($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($prompts) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getPrompts($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListPromptsResult($prompts, $nextCursor), + new ListPromptsResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php index f0abad28..f70e63d5 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/RequestHandler/ListResourcesHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; @@ -35,17 +36,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListResourcesRequest; } + /** + * @throws InvalidCursorException + */ public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListResourcesRequest); - $cursor = null; - $resources = $this->registry->getResources($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($resources) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getResources($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListResourcesResult($resources, $nextCursor), + new ListResourcesResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php index e792b0f6..757eb742 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/RequestHandler/ListToolsHandler.php @@ -12,6 +12,7 @@ namespace Mcp\Server\RequestHandler; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\InvalidCursorException; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; @@ -36,17 +37,18 @@ public function supports(HasMethodInterface $message): bool return $message instanceof ListToolsRequest; } + /** + * @throws InvalidCursorException When the cursor is invalid + */ public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response { \assert($message instanceof ListToolsRequest); - $cursor = null; - $tools = $this->registry->getTools($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($tools) === $this->pageSize) ? $cursor : null; + $page = $this->registry->getTools($this->pageSize, $message->cursor); return new Response( $message->getId(), - new ListToolsResult($tools, $nextCursor), + new ListToolsResult($page->references, $page->nextCursor), ); } } diff --git a/src/Server/RequestHandler/Reference/Page.php b/src/Server/RequestHandler/Reference/Page.php new file mode 100644 index 00000000..b76aa0ba --- /dev/null +++ b/src/Server/RequestHandler/Reference/Page.php @@ -0,0 +1,52 @@ + + */ +final class Page implements \Countable, \ArrayAccess +{ + /** + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + */ + public function __construct( + public readonly array $references, + public readonly ?string $nextCursor, + ) { + } + + public function count(): int + { + return \count($this->references); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->references[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->references[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + return; + } + + public function offsetUnset(mixed $offset): void + { + return; + } +} diff --git a/tests/Capability/Discovery/DiscoveryTest.php b/tests/Capability/Discovery/DiscoveryTest.php index c6ab3e8a..71c876a7 100644 --- a/tests/Capability/Discovery/DiscoveryTest.php +++ b/tests/Capability/Discovery/DiscoveryTest.php @@ -135,7 +135,8 @@ public function testDoesNotDiscoverElementsFromExcludedDirectories() public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() { $this->discoverer->discover(__DIR__, ['EmptyDir']); - $this->assertEmpty($this->registry->getTools()); + $tools = $this->registry->getTools(); + $this->assertEmpty($tools->references); } public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() diff --git a/tests/Capability/Registry/RegistryProviderTest.php b/tests/Capability/Registry/RegistryProviderTest.php index 8669afab..604311be 100644 --- a/tests/Capability/Registry/RegistryProviderTest.php +++ b/tests/Capability/Registry/RegistryProviderTest.php @@ -161,10 +161,10 @@ public function testGetToolsReturnsAllRegisteredTools(): void $tools = $this->registry->getTools(); $this->assertCount(2, $tools); - $this->assertArrayHasKey('tool1', $tools); - $this->assertArrayHasKey('tool2', $tools); - $this->assertInstanceOf(Tool::class, $tools['tool1']); - $this->assertInstanceOf(Tool::class, $tools['tool2']); + $this->assertArrayHasKey('tool1', $tools->references); + $this->assertArrayHasKey('tool2', $tools->references); + $this->assertInstanceOf(Tool::class, $tools->references['tool1']); + $this->assertInstanceOf(Tool::class, $tools->references['tool2']); } public function testGetResourcesReturnsAllRegisteredResources(): void @@ -177,10 +177,10 @@ public function testGetResourcesReturnsAllRegisteredResources(): void $resources = $this->registry->getResources(); $this->assertCount(2, $resources); - $this->assertArrayHasKey('test://resource1', $resources); - $this->assertArrayHasKey('test://resource2', $resources); - $this->assertInstanceOf(Resource::class, $resources['test://resource1']); - $this->assertInstanceOf(Resource::class, $resources['test://resource2']); + $this->assertArrayHasKey('test://resource1', $resources->references); + $this->assertArrayHasKey('test://resource2', $resources->references); + $this->assertInstanceOf(Resource::class, $resources->references['test://resource1']); + $this->assertInstanceOf(Resource::class, $resources->references['test://resource2']); } public function testGetPromptsReturnsAllRegisteredPrompts(): void @@ -193,10 +193,10 @@ public function testGetPromptsReturnsAllRegisteredPrompts(): void $prompts = $this->registry->getPrompts(); $this->assertCount(2, $prompts); - $this->assertArrayHasKey('prompt1', $prompts); - $this->assertArrayHasKey('prompt2', $prompts); - $this->assertInstanceOf(Prompt::class, $prompts['prompt1']); - $this->assertInstanceOf(Prompt::class, $prompts['prompt2']); + $this->assertArrayHasKey('prompt1', $prompts->references); + $this->assertArrayHasKey('prompt2', $prompts->references); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt1']); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt2']); } public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void @@ -209,10 +209,10 @@ public function testGetResourceTemplatesReturnsAllRegisteredTemplates(): void $templates = $this->registry->getResourceTemplates(); $this->assertCount(2, $templates); - $this->assertArrayHasKey('test1://{id}', $templates); - $this->assertArrayHasKey('test2://{category}', $templates); - $this->assertInstanceOf(ResourceTemplate::class, $templates['test1://{id}']); - $this->assertInstanceOf(ResourceTemplate::class, $templates['test2://{category}']); + $this->assertArrayHasKey('test1://{id}', $templates->references); + $this->assertArrayHasKey('test2://{category}', $templates->references); + $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test1://{id}']); + $this->assertInstanceOf(ResourceTemplate::class, $templates->references['test2://{category}']); } public function testHasElementsReturnsFalseForEmptyRegistry(): void diff --git a/tests/Server/RequestHandler/ListPromptsHandlerTest.php b/tests/Server/RequestHandler/ListPromptsHandlerTest.php new file mode 100644 index 00000000..b10d3e56 --- /dev/null +++ b/tests/Server/RequestHandler/ListPromptsHandlerTest.php @@ -0,0 +1,247 @@ +registry = new Registry(); + $this->handler = new ListPromptsHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $request = $this->createListPromptsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_0', $result->prompts[0]->name); + $this->assertEquals('prompt_1', $result->prompts[1]->name); + $this->assertEquals('prompt_2', $result->prompts[2]->name); + } + + #[TestDox('Returns paginated prompts with cursor')] + public function testReturnsPaginatedPromptsWithCursor(): void + { + // Arrange + $this->addPromptsToRegistry(10); + $request = $this->createListPromptsRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_0', $result->prompts[0]->name); + $this->assertEquals('prompt_1', $result->prompts[1]->name); + $this->assertEquals('prompt_2', $result->prompts[2]->name); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addPromptsToRegistry(10); + $firstPageRequest = $this->createListPromptsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListPromptsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(3, $result->prompts); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('prompt_3', $result->prompts[0]->name); + $this->assertEquals('prompt_4', $result->prompts[1]->name); + $this->assertEquals('prompt_5', $result->prompts[2]->name); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $firstPageRequest = $this->createListPromptsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListPromptsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListPromptsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(2, $result->prompts); + $this->assertNull($result->nextCursor); + + $this->assertEquals('prompt_3', $result->prompts[0]->name); + $this->assertEquals('prompt_4', $result->prompts[1]->name); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListPromptsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(0, $result->prompts); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $request = $this->createListPromptsRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addPromptsToRegistry(5); + $outOfBoundsCursor = base64_encode('1000'); + $request = $this->createListPromptsRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addPromptsToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); // Exactly at the end + $request = $this->createListPromptsRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListPromptsResult::class, $result); + $this->assertCount(0, $result->prompts); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addPromptsToRegistry(10); + + // Act + $request = $this->createListPromptsRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListPromptsResult $result1 */ + $result1 = $response1->result; + /** @var ListPromptsResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->prompts, $result2->prompts); + } + + private function addPromptsToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $prompt = new Prompt( + name: "prompt_$i", + description: "Test prompt $i" + ); + + $this->registry->registerPrompt($prompt, fn () => null); + } + } + + private function createListPromptsRequest(?string $cursor = null): ListPromptsRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'prompts/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListPromptsRequest::fromArray($data); + } +} diff --git a/tests/Server/RequestHandler/ListResourcesHandlerTest.php b/tests/Server/RequestHandler/ListResourcesHandlerTest.php new file mode 100644 index 00000000..13044a73 --- /dev/null +++ b/tests/Server/RequestHandler/ListResourcesHandlerTest.php @@ -0,0 +1,248 @@ +registry = new Registry(); + $this->handler = new ListResourcesHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); + } + + #[TestDox('Returns paginated resources with cursor')] + public function testReturnsPaginatedResourcesWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $request = $this->createListResourcesRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_0', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_1', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_2', $result->resources[2]->uri); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourcesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(3, $result->resources); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); + $this->assertEquals('resource://test/resource_5', $result->resources[2]->uri); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourcesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(2, $result->resources); + $this->assertNull($result->nextCursor); + + $this->assertEquals('resource://test/resource_3', $result->resources[0]->uri); + $this->assertEquals('resource://test/resource_4', $result->resources[1]->uri); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(0, $result->resources); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $outOfBoundsCursor = base64_encode('100'); + $request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addResourcesToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); + $request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(0, $result->resources); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addResourcesToRegistry(10); + + // Act + $request = $this->createListResourcesRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourcesResult $result1 */ + $result1 = $response1->result; + /** @var ListResourcesResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->resources, $result2->resources); + } + + private function addResourcesToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $resource = new Resource( + uri: "resource://test/resource_$i", + name: "resource_$i", + description: "Test resource $i" + ); + // Use a simple callable as handler + $this->registry->registerResource($resource, fn () => null); + } + } + + private function createListResourcesRequest(?string $cursor = null): ListResourcesRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'resources/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListResourcesRequest::fromArray($data); + } +} diff --git a/tests/Server/RequestHandler/ListToolsHandlerTest.php b/tests/Server/RequestHandler/ListToolsHandlerTest.php new file mode 100644 index 00000000..85ae9b63 --- /dev/null +++ b/tests/Server/RequestHandler/ListToolsHandlerTest.php @@ -0,0 +1,274 @@ +registry = new Registry(); + $this->handler = new ListToolsHandler($this->registry, pageSize: 3); // Use small page size for testing + $this->session = new Session(new InMemorySessionStore()); + } + + #[TestDox('Returns first page when no cursor provided')] + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addToolsToRegistry(5); + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + $this->assertEquals('tool_2', $result->tools[2]->name); + } + + #[TestDox('Returns paginated tools with cursor')] + public function testReturnsPaginatedToolsWithCursor(): void + { + // Arrange + $this->addToolsToRegistry(10); + $request = $this->createListToolsRequest(cursor: null); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + $this->assertEquals('tool_2', $result->tools[2]->name); + } + + #[TestDox('Returns second page with cursor')] + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addToolsToRegistry(10); + $firstPageRequest = $this->createListToolsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListToolsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(3, $result->tools); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('tool_3', $result->tools[0]->name); + $this->assertEquals('tool_4', $result->tools[1]->name); + $this->assertEquals('tool_5', $result->tools[2]->name); + } + + #[TestDox('Returns last page with null cursor')] + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addToolsToRegistry(5); + $firstPageRequest = $this->createListToolsRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListToolsResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListToolsRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(2, $result->tools); + $this->assertNull($result->nextCursor); + + $this->assertEquals('tool_3', $result->tools[0]->name); + $this->assertEquals('tool_4', $result->tools[1]->name); + } + + #[TestDox('Returns all tools when count is less than page size')] + public function testReturnsAllToolsWhenCountIsLessThanPageSize(): void + { + // Arrange + $this->addToolsToRegistry(2); // Less than page size (3) + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(2, $result->tools); + $this->assertNull($result->nextCursor); + + $this->assertEquals('tool_0', $result->tools[0]->name); + $this->assertEquals('tool_1', $result->tools[1]->name); + } + + #[TestDox('Handles empty registry')] + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListToolsRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(0, $result->tools); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Throws exception for invalid cursor')] + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addToolsToRegistry(5); + $request = $this->createListToolsRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Throws exception for cursor beyond bounds')] + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addToolsToRegistry(5); + $outOfBoundsCursor = base64_encode('100'); + $request = $this->createListToolsRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + #[TestDox('Handles cursor at exact boundary')] + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addToolsToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); + $request = $this->createListToolsRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(0, $result->tools); + $this->assertNull($result->nextCursor); + } + + #[TestDox('Maintains stable cursors across calls')] + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addToolsToRegistry(10); + + // Act + $request = $this->createListToolsRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result1 */ + $result1 = $response1->result; + /** @var ListToolsResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->tools, $result2->tools); + } + + private function addToolsToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $tool = new Tool( + name: "tool_$i", + inputSchema: [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ], + description: "Test tool $i", + annotations: null + ); + + $this->registry->registerTool($tool, fn () => null); + } + } + + private function createListToolsRequest(?string $cursor = null): ListToolsRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'tools/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListToolsRequest::fromArray($data); + } +} From 32b8b2ea612e82f1fd7420e4e7b6dd002b6eb469 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 24 Sep 2025 20:45:50 +0100 Subject: [PATCH 12/66] [Server] Fix: ensure JSON-RPC error responses include message IDs (#77) * fix: ensure JSON-RPC error responses include message IDs * fix: replace method_exists with instanceof for message ID extraction --- src/JsonRpc/Handler.php | 39 +++++++++++++++++++++++++++------------ src/Server.php | 17 +++++------------ tests/ServerTest.php | 17 ++++++----------- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index e7e66964..a16f7347 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -23,6 +23,7 @@ use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\MethodHandlerInterface; @@ -94,9 +95,6 @@ public static function make( /** * @return iterable}> - * - * @throws ExceptionInterface When a handler throws an exception during message processing - * @throws \JsonException When JSON encoding of the response fails */ public function process(string $input, ?Uuid $sessionId): iterable { @@ -163,7 +161,7 @@ public function process(string $input, ?Uuid $sessionId): iterable foreach ($messages as $message) { if ($message instanceof InvalidInputMessageException) { $this->logger->warning('Failed to create message.', ['exception' => $message]); - $error = Error::forInvalidRequest($message->getMessage(), 0); + $error = Error::forInvalidRequest($message->getMessage()); yield [$this->encodeResponse($error), []]; continue; } @@ -172,6 +170,8 @@ public function process(string $input, ?Uuid $sessionId): iterable 'method' => $message->getMethod(), ]); + $messageId = $message instanceof Request ? $message->getId() : 0; + try { $response = $this->handle($message, $session); yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; @@ -183,17 +183,17 @@ public function process(string $input, ?Uuid $sessionId): iterable ['exception' => $e], ); - $error = Error::forMethodNotFound($e->getMessage()); + $error = Error::forMethodNotFound($e->getMessage(), $messageId); yield [$this->encodeResponse($error), []]; } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - $error = Error::forInvalidParams($e->getMessage()); + $error = Error::forInvalidParams($e->getMessage(), $messageId); yield [$this->encodeResponse($error), []]; } catch (\Throwable $e) { $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - $error = Error::forInternalError($e->getMessage()); + $error = Error::forInternalError($e->getMessage(), $messageId); yield [$this->encodeResponse($error), []]; } } @@ -202,7 +202,7 @@ public function process(string $input, ?Uuid $sessionId): iterable } /** - * @throws \JsonException When JSON encoding fails + * Encodes a response to JSON, handling encoding errors gracefully. */ private function encodeResponse(Response|Error|null $response): ?string { @@ -214,11 +214,26 @@ private function encodeResponse(Response|Error|null $response): ?string $this->logger->info('Encoding response.', ['response' => $response]); - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } + try { + if ($response instanceof Response && [] === $response->result) { + return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + } - return json_encode($response, \JSON_THROW_ON_ERROR); + return json_encode($response, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message_id' => $response->getId(), + 'exception' => $e, + ]); + + $fallbackError = new Error( + id: $response->getId(), + code: Error::INTERNAL_ERROR, + message: 'Response could not be encoded to JSON' + ); + + return json_encode($fallbackError, \JSON_THROW_ON_ERROR); + } } /** diff --git a/src/Server.php b/src/Server.php index 55b84159..239a3132 100644 --- a/src/Server.php +++ b/src/Server.php @@ -44,19 +44,12 @@ public function connect(TransportInterface $transport): void ]); $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { - try { - foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { - if (null === $response) { - continue; - } - - $transport->send($response, $context); + foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { + if (null === $response) { + continue; } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); + + $transport->send($response, $context); } }); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index c65c3ca4..046583d1 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -14,27 +14,19 @@ use Mcp\JsonRpc\Handler; use Mcp\Server; use Mcp\Server\Transport\InMemoryTransport; -use PHPUnit\Framework\MockObject\Stub\Exception; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; class ServerTest extends TestCase { public function testJsonExceptions() { - $logger = $this->getMockBuilder(NullLogger::class) - ->disableOriginalConstructor() - ->onlyMethods(['error']) - ->getMock(); - $logger->expects($this->once())->method('error'); - $handler = $this->getMockBuilder(Handler::class) ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( - new Exception(new \JsonException('foobar')), + [['{"jsonrpc":"2.0","id":0,"error":{"code":-32700,"message":"Parse error"}}', []]], [['success', []]] ); @@ -42,9 +34,12 @@ public function testJsonExceptions() ->setConstructorArgs([['foo', 'bar']]) ->onlyMethods(['send']) ->getMock(); - $transport->expects($this->once())->method('send')->with('success', []); + $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( + null, + null + ); - $server = new Server($handler, $logger); + $server = new Server($handler); $server->connect($transport); $transport->listen(); From 8defd9b17f6fa49ad97c0da523212ae6507d7666 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 24 Sep 2025 21:50:06 +0200 Subject: [PATCH 13/66] Fix & clean up examples (#78) --- README.md | 23 +- .../McpElements.php | 2 +- .../McpElements.php | 24 +- .../UserIdCompletionProvider.php | 8 +- .../02-discovery-http-userprofile/server.php | 2 +- .../SimpleHandlers.php | 6 +- .../DiscoveredElements.php | 2 +- .../ManualHandlers.php | 4 +- .../04-combined-registration-http/server.php | 2 +- .../05-stdio-env-variables/EnvToolHandler.php | 4 +- .../McpTaskHandlers.php | 23 +- .../Service/InMemoryTaskRepository.php | 17 +- .../Service/StatsServiceInterface.php | 3 + .../Service/SystemStatsService.php | 10 +- .../Service/TaskRepositoryInterface.php | 12 + .../06-custom-dependencies-stdio/server.php | 13 +- .../McpEventScheduler.php | 20 +- .../SchemaShowcaseElements.php | 38 ++- .../08-schema-showcase-streamable/server.php | 1 + examples/README.md | 4 + examples/bootstrap.php | 9 +- phpstan-baseline.neon | 306 ------------------ 22 files changed, 136 insertions(+), 397 deletions(-) diff --git a/README.md b/README.md index 121d8dd4..3369243f 100644 --- a/README.md +++ b/README.md @@ -22,29 +22,16 @@ Until the first major release, this SDK is considered Features - [x] Bring back PHP-MCP examples -- [ ] Glue handler, registry and reference handlers -- [ ] Revive `ServerBuilder` -- [ ] Revive transports - - [ ] Streamable Transport https://github.com/modelcontextprotocol/php-sdk/issues/7 - - [ ] Http/SSE-based Transport https://github.com/modelcontextprotocol/php-sdk/issues/8 +- [x] Glue handler, registry and reference handlers +- [x] Revive `ServerBuilder` +- [x] Revive transports + - [x] Streamable Transport https://github.com/modelcontextprotocol/php-sdk/issues/7 + - [ ] ~~Http/SSE-based Transport https://github.com/modelcontextprotocol/php-sdk/issues/8~~ - [ ] Support pagination - [ ] Support Schema validation - [ ] Support multiple versions of the MCP specification https://github.com/modelcontextprotocol/php-sdk/issues/14 - [ ] (Re-)Implement missing Notification & Request Handlers https://github.com/modelcontextprotocol/php-sdk/issues/9 ---- - -Examples working -- [x] 01-discovery-stdio-calculator -- [ ] 02-discovery-http-userprofile -- [x] 03-manual-registration-stdio -- [ ] 04-combined-registration-http -- [ ] 05-stdio-env-variables -- [ ] 06-custom-dependencies-stdio -- [ ] 07-complex-tool-schema-http -- [ ] 08-schema-showcase-streamable -- [ ] 09-standalone-cli - ## Installation ```bash diff --git a/examples/01-discovery-stdio-calculator/McpElements.php b/examples/01-discovery-stdio-calculator/McpElements.php index 8f05b96c..cde2ecc4 100644 --- a/examples/01-discovery-stdio-calculator/McpElements.php +++ b/examples/01-discovery-stdio-calculator/McpElements.php @@ -19,7 +19,7 @@ /** * @phpstan-type Config array{precision: int, allow_negative: bool} */ -class McpElements +final class McpElements { /** * @var Config diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php index 600f6ecc..815d3d54 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/02-discovery-http-userprofile/McpElements.php @@ -18,9 +18,16 @@ use Mcp\Capability\Attribute\McpTool; use Psr\Log\LoggerInterface; -class McpElements +/** + * @phpstan-type User array{name: string, email: string, role: string} + */ +final class McpElements { - // Simulate a simple user database + /** + * Simulate a simple user database. + * + * @var array + */ private array $users = [ '101' => ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'], '102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'], @@ -28,7 +35,7 @@ class McpElements ]; public function __construct( - private LoggerInterface $logger, + private readonly LoggerInterface $logger, ) { $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); } @@ -38,7 +45,7 @@ public function __construct( * * @param string $userId the ID of the user (from URI) * - * @return array user profile data + * @return User user profile data * * @throws McpServerException if the user is not found */ @@ -64,7 +71,7 @@ public function getUserProfile( /** * Retrieves a list of all known user IDs. * - * @return array list of user IDs + * @return int[] list of user IDs */ #[McpResource( uri: 'user://list/ids', @@ -86,7 +93,7 @@ public function listUserIds(): array * @param string $userId the ID of the user to message * @param string|null $customMessage an optional custom message part * - * @return array status of the operation + * @return array status of the operation */ #[McpTool(name: 'send_welcome')] public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array @@ -106,6 +113,9 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null return ['success' => true, 'message_sent' => $message]; } + /** + * @return array + */ #[McpTool(name: 'test_tool_without_params')] public function testToolWithoutParams(): array { @@ -118,7 +128,7 @@ public function testToolWithoutParams(): array * @param string $userId the user ID to generate the bio for * @param string $tone Desired tone (e.g., 'formal', 'casual'). * - * @return array prompt messages + * @return array[] prompt messages * * @throws McpServerException if user not found */ diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php index c11fb609..2b1d0555 100644 --- a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php +++ b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php @@ -13,12 +13,12 @@ use Mcp\Capability\Prompt\Completion\ProviderInterface; -class UserIdCompletionProvider implements ProviderInterface +final class UserIdCompletionProvider implements ProviderInterface { + private const AVAILABLE_USER_IDS = ['101', '102', '103']; + public function getCompletions(string $currentValue): array { - $availableUserIds = ['101', '102', '103']; - - return array_filter($availableUserIds, fn (string $userId) => str_contains($userId, $currentValue)); + return array_filter(self::AVAILABLE_USER_IDS, fn (string $userId) => str_contains($userId, $currentValue)); } } diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 163d495c..ce3ac347 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -54,7 +54,7 @@ function (float $a, float $b, string $operation = 'add'): array { function (): array { $memoryUsage = memory_get_usage(true); $memoryPeak = memory_get_peak_usage(true); - $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $uptime = time() - ($_SERVER['REQUEST_TIME_FLOAT'] ?? time()); $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; return [ diff --git a/examples/03-manual-registration-stdio/SimpleHandlers.php b/examples/03-manual-registration-stdio/SimpleHandlers.php index d646486f..e581c406 100644 --- a/examples/03-manual-registration-stdio/SimpleHandlers.php +++ b/examples/03-manual-registration-stdio/SimpleHandlers.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; -class SimpleHandlers +final class SimpleHandlers { private string $appVersion = '1.0-manual'; @@ -54,7 +54,7 @@ public function getAppVersion(): string * * @param string $userName the name of the user * - * @return array the prompt messages + * @return array[] the prompt messages */ public function greetingPrompt(string $userName): array { @@ -70,7 +70,7 @@ public function greetingPrompt(string $userName): array * * @param string $itemId the ID of the item * - * @return array item details + * @return array item details */ public function getItemDetails(string $itemId): array { diff --git a/examples/04-combined-registration-http/DiscoveredElements.php b/examples/04-combined-registration-http/DiscoveredElements.php index aacaf2a9..3c3edf81 100644 --- a/examples/04-combined-registration-http/DiscoveredElements.php +++ b/examples/04-combined-registration-http/DiscoveredElements.php @@ -14,7 +14,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -class DiscoveredElements +final class DiscoveredElements { /** * A tool discovered via attributes. diff --git a/examples/04-combined-registration-http/ManualHandlers.php b/examples/04-combined-registration-http/ManualHandlers.php index ebac86e0..fef7216a 100644 --- a/examples/04-combined-registration-http/ManualHandlers.php +++ b/examples/04-combined-registration-http/ManualHandlers.php @@ -13,10 +13,10 @@ use Psr\Log\LoggerInterface; -class ManualHandlers +final class ManualHandlers { public function __construct( - private LoggerInterface $logger, + private readonly LoggerInterface $logger, ) { } diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index e69c5aa5..69225020 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -14,7 +14,7 @@ chdir(__DIR__); use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\CombinedHttpExample\Manual\ManualHandlers; +use Mcp\Example\CombinedHttpExample\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; diff --git a/examples/05-stdio-env-variables/EnvToolHandler.php b/examples/05-stdio-env-variables/EnvToolHandler.php index 002f7cf0..49c914d5 100644 --- a/examples/05-stdio-env-variables/EnvToolHandler.php +++ b/examples/05-stdio-env-variables/EnvToolHandler.php @@ -13,7 +13,7 @@ use Mcp\Capability\Attribute\McpTool; -class EnvToolHandler +final class EnvToolHandler { /** * Performs an action that can be modified by an environment variable. @@ -21,7 +21,7 @@ class EnvToolHandler * * @param string $input some input data * - * @return array the result, varying by APP_MODE + * @return array the result, varying by APP_MODE */ #[McpTool(name: 'process_data_by_mode')] public function processData(string $input): array diff --git a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php index ea32adb6..bf5ce556 100644 --- a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php +++ b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php @@ -13,16 +13,19 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\DependenciesStdioExample\Services\StatsServiceInterface; -use Mcp\DependenciesStdioExample\Services\TaskRepositoryInterface; +use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; +use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; -class McpTaskHandlers +/** + * @phpstan-import-type Task from TaskRepositoryInterface + */ +final class McpTaskHandlers { public function __construct( - private TaskRepositoryInterface $taskRepo, - private StatsServiceInterface $statsService, - private LoggerInterface $logger, + private readonly TaskRepositoryInterface $taskRepo, + private readonly StatsServiceInterface $statsService, + private readonly LoggerInterface $logger, ) { $this->logger->info('McpTaskHandlers instantiated with dependencies.'); } @@ -33,7 +36,7 @@ public function __construct( * @param string $userId the ID of the user * @param string $description the task description * - * @return array the created task details + * @return Task the created task details */ #[McpTool(name: 'add_task')] public function addTask(string $userId, string $description): array @@ -48,7 +51,7 @@ public function addTask(string $userId, string $description): array * * @param string $userId the ID of the user * - * @return array a list of tasks + * @return Task[] a list of tasks */ #[McpTool(name: 'list_user_tasks')] public function listUserTasks(string $userId): array @@ -63,7 +66,7 @@ public function listUserTasks(string $userId): array * * @param int $taskId the ID of the task to complete * - * @return array status of the operation + * @return array status of the operation */ #[McpTool(name: 'complete_task')] public function completeTask(int $taskId): array @@ -77,7 +80,7 @@ public function completeTask(int $taskId): array /** * Provides current system statistics. * - * @return array system statistics + * @return array system statistics */ #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] public function getSystemStatistics(): array diff --git a/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php b/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php index a6c5315c..47526c4c 100644 --- a/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php +++ b/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php @@ -13,17 +13,20 @@ use Psr\Log\LoggerInterface; -class InMemoryTaskRepository implements TaskRepositoryInterface +/** + * @phpstan-import-type Task from TaskRepositoryInterface + */ +final class InMemoryTaskRepository implements TaskRepositoryInterface { + /** + * @var array + */ private array $tasks = []; - private int $nextTaskId = 1; - private LoggerInterface $logger; - - public function __construct(LoggerInterface $logger) - { - $this->logger = $logger; + public function __construct( + private readonly LoggerInterface $logger, + ) { // Add some initial tasks $this->addTask('user1', 'Buy groceries'); $this->addTask('user1', 'Write MCP example'); diff --git a/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php b/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php index 2c94c002..b8485d2a 100644 --- a/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php +++ b/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php @@ -13,5 +13,8 @@ interface StatsServiceInterface { + /** + * @return array + */ public function getSystemStats(): array; } diff --git a/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php b/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php index 845e3486..075dd8a0 100644 --- a/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php +++ b/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php @@ -11,13 +11,11 @@ namespace Mcp\Example\DependenciesStdioExample\Service; -class SystemStatsService implements StatsServiceInterface +final class SystemStatsService implements StatsServiceInterface { - private TaskRepositoryInterface $taskRepository; - - public function __construct(TaskRepositoryInterface $taskRepository) - { - $this->taskRepository = $taskRepository; + public function __construct( + private readonly TaskRepositoryInterface $taskRepository, + ) { } public function getSystemStats(): array diff --git a/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php b/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php index 7ae1c8a4..975cc711 100644 --- a/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php +++ b/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php @@ -11,12 +11,24 @@ namespace Mcp\Example\DependenciesStdioExample\Service; +/** + * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} + */ interface TaskRepositoryInterface { + /** + * @return Task + */ public function addTask(string $userId, string $description): array; + /** + * @return Task[] + */ public function getTasksForUser(string $userId): array; + /** + * @return Task[] + */ public function getAllTasks(): array; public function completeTask(int $taskId): bool; diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index 3a6d86ff..ac85d0ac 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -13,7 +13,10 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\DependenciesStdioExample\Services; +use Mcp\Example\DependenciesStdioExample\Service\InMemoryTaskRepository; +use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; +use Mcp\Example\DependenciesStdioExample\Service\SystemStatsService; +use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; @@ -21,11 +24,11 @@ $container = container(); -$taskRepo = new Services\InMemoryTaskRepository(logger()); -$container->set(Services\TaskRepositoryInterface::class, $taskRepo); +$taskRepo = new InMemoryTaskRepository(logger()); +$container->set(TaskRepositoryInterface::class, $taskRepo); -$statsService = new Services\SystemStatsService($taskRepo); -$container->set(Services\StatsServiceInterface::class, $statsService); +$statsService = new SystemStatsService($taskRepo); +$container->set(StatsServiceInterface::class, $statsService); $server = Server::make() ->setServerInfo('Task Manager Server', '1.0.0') diff --git a/examples/07-complex-tool-schema-http/McpEventScheduler.php b/examples/07-complex-tool-schema-http/McpEventScheduler.php index 0b29cdeb..a6d9db92 100644 --- a/examples/07-complex-tool-schema-http/McpEventScheduler.php +++ b/examples/07-complex-tool-schema-http/McpEventScheduler.php @@ -12,14 +12,14 @@ namespace Mcp\Example\ComplexSchemaHttpExample; use Mcp\Capability\Attribute\McpTool; -use Mcp\ComplexSchemaHttpExample\Model\EventPriority; -use Mcp\ComplexSchemaHttpExample\Model\EventType; +use Mcp\Example\ComplexSchemaHttpExample\Model\EventPriority; +use Mcp\Example\ComplexSchemaHttpExample\Model\EventType; use Psr\Log\LoggerInterface; -class McpEventScheduler +final class McpEventScheduler { public function __construct( - private LoggerInterface $logger, + private readonly LoggerInterface $logger, ) { } @@ -35,17 +35,17 @@ public function __construct( * @param string[]|null $attendees an optional list of attendee email addresses * @param bool $sendInvites send calendar invites to attendees? Defaults to true if attendees are provided * - * @return array confirmation of the scheduled event + * @return array confirmation of the scheduled event */ #[McpTool(name: 'schedule_event')] public function scheduleEvent( string $title, string $date, EventType $type, - ?string $time = null, // Optional, nullable - EventPriority $priority = EventPriority::Normal, // Optional with enum default - ?array $attendees = null, // Optional array of strings, nullable - bool $sendInvites = true, // Optional with default + ?string $time = null, + EventPriority $priority = EventPriority::Normal, + ?array $attendees = null, + bool $sendInvites = true, ): array { $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); @@ -65,7 +65,7 @@ public function scheduleEvent( return [ 'success' => true, - 'message' => "Event '{$title}' scheduled successfully for {$date}.", + 'message' => \sprintf('Event "%s" scheduled successfully for "%s".', $title, $date), 'event_details' => $eventDetails, ]; } diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php index 3521e2e3..ea11b687 100644 --- a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php +++ b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php @@ -13,12 +13,20 @@ use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; +use Psr\Log\LoggerInterface; -class SchemaShowcaseElements +final class SchemaShowcaseElements { + public function __construct( + private readonly LoggerInterface $logger, + ) { + } + /** * Validates and formats text with string constraints. * Demonstrates: minLength, maxLength, pattern validation. + * + * @return array */ #[McpTool( name: 'format_text', @@ -41,7 +49,7 @@ enum: ['uppercase', 'lowercase', 'title', 'sentence'] )] string $format = 'sentence', ): array { - fwrite(\STDERR, "Format text tool called: text='$text', format='$format'\n"); + $this->logger->info(\sprintf('Tool format_text called with text: %s and format: %s', $text, $format)); $formatted = match ($format) { 'uppercase' => strtoupper($text), @@ -63,6 +71,8 @@ enum: ['uppercase', 'lowercase', 'title', 'sentence'] * Performs mathematical operations with numeric constraints. * * Demonstrates: METHOD-LEVEL Schema + * + * @return array */ #[McpTool(name: 'calculate_range')] #[Schema( @@ -97,7 +107,7 @@ enum: ['uppercase', 'lowercase', 'title', 'sentence'] )] public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array { - fwrite(\STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); + $this->logger->info(\sprintf('Tool calculate_range called with: %f %s %f (precision: %d)', $first, $operation, $second, $precision)); $result = match ($operation) { 'add' => $first + $second, @@ -126,6 +136,10 @@ public function calculateRange(float $first, float $second, string $operation, i /** * Processes user profile data with object schema validation. * Demonstrates: object properties, required fields, additionalProperties. + * + * @param array $profile + * + * @return array */ #[McpTool( name: 'validate_profile', @@ -172,7 +186,7 @@ public function validateProfile( )] array $profile, ): array { - fwrite(\STDERR, 'Validate profile tool called with: '.json_encode($profile)."\n"); + $this->logger->info(\sprintf('Tool validate_profile called: %s', json_encode($profile))); $errors = []; $warnings = []; @@ -203,6 +217,10 @@ public function validateProfile( /** * Manages a list of items with array constraints. * Demonstrates: array items, minItems, maxItems, uniqueItems. + * + * @param string[] $items + * + * @return array */ #[McpTool( name: 'manage_list', @@ -230,7 +248,7 @@ enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long )] string $action = 'sort', ): array { - fwrite(\STDERR, 'Manage list tool called with '.\count($items)." items, action: $action\n"); + $this->logger->info(\sprintf('Tool manage_list called with %d items, action: %s', \count($items), $action)); $original = $items; $processed = $items; @@ -273,6 +291,8 @@ enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long /** * Generates configuration with format validation. * Demonstrates: format constraints (date-time, uri, etc). + * + * @return array */ #[McpTool( name: 'generate_config', @@ -316,7 +336,7 @@ enum: ['development', 'staging', 'production'] )] int $port = 8080, ): array { - fwrite(\STDERR, "Generate config tool called for app: $appName\n"); + $this->logger->info(\sprintf('Tool generate_config called for app: %s at %s', $appName, $baseUrl)); $config = [ 'app' => [ @@ -350,6 +370,10 @@ enum: ['development', 'staging', 'production'] /** * Processes time-based data with date-time format validation. * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. + * + * @param string[] $attendees + * + * @return array */ #[McpTool( name: 'schedule_event', @@ -398,7 +422,7 @@ enum: ['low', 'medium', 'high', 'urgent'] )] array $attendees = [], ): array { - fwrite(\STDERR, "Schedule event tool called: $title at $startTime\n"); + $this->logger->info(\sprintf('Tool schedule_event called: %s at %s for %.1f hours', $title, $startTime, $durationHours)); $start = \DateTime::createFromFormat(\DateTime::ISO8601, $startTime); if (!$start) { diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php index c7a323ea..777b5dda 100644 --- a/examples/08-schema-showcase-streamable/server.php +++ b/examples/08-schema-showcase-streamable/server.php @@ -27,6 +27,7 @@ $server = Server::make() ->setServerInfo('Schema Showcase', '1.0.0') + ->setContainer(container()) ->setLogger(logger()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setDiscovery(__DIR__, ['.']) diff --git a/examples/README.md b/examples/README.md index eb7a63fb..b621c229 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,11 @@ README in the `examples/09-standalone-cli` directory. For running an example, you execute the `server.php` like this: ```bash +# For examples using STDIO transport php examples/01-discovery-stdio-calculator/server.php + +# For examples using Streamable HTTP transport +php -S localhost:8000 examples/02-discovery-http-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. diff --git a/examples/bootstrap.php b/examples/bootstrap.php index fef29802..e4a4b98d 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -16,10 +16,7 @@ require_once dirname(__DIR__).'/vendor/autoload.php'; set_exception_handler(function (Throwable $t): never { - fwrite(\STDERR, "[MCP SERVER CRITICAL ERROR]\n"); - fwrite(\STDERR, 'Error: '.$t->getMessage()."\n"); - fwrite(\STDERR, 'File: '.$t->getFile().':'.$t->getLine()."\n"); - fwrite(\STDERR, $t->getTraceAsString()."\n"); + logger()->critical('Uncaught exception: '.$t->getMessage(), ['exception' => $t]); exit(1); }); @@ -42,9 +39,9 @@ public function log($level, Stringable|string $message, array $context = []): vo ([] === $context || !$debug) ? '' : json_encode($context), ); - if ($_SERVER['FILE_LOG'] ?? false) { + if (($_SERVER['FILE_LOG'] ?? false) || !defined('STDERR')) { file_put_contents('dev.log', $logMessage, \FILE_APPEND); - } elseif (defined('STDERR')) { + } else { fwrite(\STDERR, $logMessage); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 23418901..4dca0b25 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -6,318 +6,12 @@ parameters: count: 2 path: examples/02-discovery-http-userprofile/McpElements.php - - - message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:generateBio\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - - - message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:getUserProfile\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - - - message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:listUserIds\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - - - message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:sendWelcomeMessage\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - - - message: '#^Method Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:testToolWithoutParams\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpUserProfileExample\\McpServerException is not subtype of Throwable$#' identifier: throws.notThrowable count: 2 path: examples/02-discovery-http-userprofile/McpElements.php - - - message: '#^Property Mcp\\Example\\HttpUserProfileExample\\McpElements\:\:\$users type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/02-discovery-http-userprofile/McpElements.php - - - - message: '#^Expression on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.expr - count: 1 - path: examples/02-discovery-http-userprofile/server.php - - - - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:getItemDetails\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/03-manual-registration-stdio/SimpleHandlers.php - - - - message: '#^Method Mcp\\Example\\ManualStdioExample\\SimpleHandlers\:\:greetingPrompt\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/03-manual-registration-stdio/SimpleHandlers.php - - - - message: '#^Class Mcp\\CombinedHttpExample\\Manual\\ManualHandlers not found\.$#' - identifier: class.notFound - count: 2 - path: examples/04-combined-registration-http/server.php - - - - message: '#^Method Mcp\\Example\\StdioEnvVariables\\EnvToolHandler\:\:processData\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/05-stdio-env-variables/EnvToolHandler.php - - - - message: '#^Call to method addTask\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Call to method completeTask\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Call to method getSystemStats\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Call to method getTasksForUser\(\) on an unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:completeTask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:getSystemStatistics\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:listUserTasks\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Parameter \$statsService of method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:__construct\(\) has invalid type Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Parameter \$taskRepo of method Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:__construct\(\) has invalid type Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Property Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:\$statsService has unknown class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface as its type\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Property Mcp\\Example\\DependenciesStdioExample\\McpTaskHandlers\:\:\$taskRepo has unknown class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface as its type\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/McpTaskHandlers.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:getAllTasks\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:getTasksForUser\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php - - - - message: '#^Property Mcp\\Example\\DependenciesStdioExample\\Service\\InMemoryTaskRepository\:\:\$tasks type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\StatsServiceInterface\:\:getSystemStats\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\SystemStatsService\:\:getSystemStats\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/SystemStatsService.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:addTask\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:getAllTasks\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php - - - - message: '#^Method Mcp\\Example\\DependenciesStdioExample\\Service\\TaskRepositoryInterface\:\:getTasksForUser\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php - - - - message: '#^Class Mcp\\DependenciesStdioExample\\Services\\StatsServiceInterface not found\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/server.php - - - - message: '#^Class Mcp\\DependenciesStdioExample\\Services\\TaskRepositoryInterface not found\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/server.php - - - - message: '#^Instantiated class Mcp\\DependenciesStdioExample\\Services\\InMemoryTaskRepository not found\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/server.php - - - - message: '#^Instantiated class Mcp\\DependenciesStdioExample\\Services\\SystemStatsService not found\.$#' - identifier: class.notFound - count: 1 - path: examples/06-custom-dependencies-stdio/server.php - - - - message: '#^Access to constant Normal on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' - identifier: class.notFound - count: 1 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Access to property \$name on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' - identifier: class.notFound - count: 1 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Access to property \$value on an unknown class Mcp\\ComplexSchemaHttpExample\\Model\\EventType\.$#' - identifier: class.notFound - count: 1 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Parameter \$priority of method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) has invalid type Mcp\\ComplexSchemaHttpExample\\Model\\EventPriority\.$#' - identifier: class.notFound - count: 2 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Parameter \$type of method Mcp\\Example\\ComplexSchemaHttpExample\\McpEventScheduler\:\:scheduleEvent\(\) has invalid type Mcp\\ComplexSchemaHttpExample\\Model\\EventType\.$#' - identifier: class.notFound - count: 2 - path: examples/07-complex-tool-schema-http/McpEventScheduler.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:calculateRange\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:formatText\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:generateConfig\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:manageList\(\) has parameter \$items with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:manageList\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:scheduleEvent\(\) has parameter \$attendees with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:scheduleEvent\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:validateProfile\(\) has parameter \$profile with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - - - message: '#^Method Mcp\\Example\\SchemaShowcaseExample\\SchemaShowcaseElements\:\:validateProfile\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: examples/08-schema-showcase-streamable/SchemaShowcaseElements.php - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' identifier: method.notFound From e46b7c139d3833284c9d1b4ea5188f2af4888937 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 25 Sep 2025 08:53:09 +0200 Subject: [PATCH 14/66] Support for Symfony 8.0 (#79) --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index b520d0f6..4dbf09e3 100644 --- a/composer.json +++ b/composer.json @@ -28,8 +28,8 @@ "psr/http-factory": "^1.1", "psr/http-message": "^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/finder": "^6.4 || ^7.3", - "symfony/uid": "^6.4 || ^7.3" + "symfony/finder": "^6.4 || ^7.3 || ^8.0", + "symfony/uid": "^6.4 || ^7.3 || ^8.0" }, "require-dev": { "php-cs-fixer/shim": "^3.84", @@ -37,9 +37,9 @@ "phpunit/phpunit": "^10.5", "psr/cache": "^3.0", "psr/simple-cache": "^3.0", - "symfony/cache": "^6.4 || ^7.3", - "symfony/console": "^6.4 || ^7.3", - "symfony/process": "^6.4 || ^7.3", + "symfony/cache": "^6.4 || ^7.3 || ^8.0", + "symfony/console": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", "laminas/laminas-httphandlerrunner": "^2.12" From 7d599f4f6440faf4175291976bcd62c23bb74b0e Mon Sep 17 00:00:00 2001 From: xentixar <152050438+xentixar@users.noreply.github.com> Date: Thu, 25 Sep 2025 20:26:11 +0545 Subject: [PATCH 15/66] refactor: integrate cache parameter into setDiscovery() method (#80) * refactor: integrate cache parameter into setDiscovery() method - Add cache as 4th parameter to setDiscovery() - Rename $cache to $discoveryCache for clarity - Remove separate setCache() method - Update example to use new API * docs: update discovery caching documentation to use new API - Update examples to use cache parameter in setDiscovery() method - Remove references to separate setCache() method calls --- docs/discovery-caching.md | 9 +++------ examples/09-cached-discovery-stdio/server.php | 3 +-- src/Server/ServerBuilder.php | 18 +++++------------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/docs/discovery-caching.md b/docs/discovery-caching.md index 8ba63f0a..423fdc09 100644 --- a/docs/discovery-caching.md +++ b/docs/discovery-caching.md @@ -21,8 +21,7 @@ use Symfony\Component\Cache\Psr16Cache; $server = Server::make() ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.']) - ->setCache(new Psr16Cache(new ArrayAdapter())) // Enable caching + ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) // Enable caching ->build(); ``` @@ -69,8 +68,7 @@ $cache = DoctrineProvider::wrap(new ArrayCache()); $cache = new Psr16Cache(new ArrayAdapter()); $server = Server::make() - ->setDiscovery(__DIR__, ['.']) - ->setCache($cache) + ->setDiscovery(__DIR__, ['.'], [], $cache) ->build(); ``` @@ -81,8 +79,7 @@ $server = Server::make() $cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); $server = Server::make() - ->setDiscovery(__DIR__, ['.']) - ->setCache($cache) + ->setDiscovery(__DIR__, ['.'], [], $cache) ->build(); ``` diff --git a/examples/09-cached-discovery-stdio/server.php b/examples/09-cached-discovery-stdio/server.php index 5210a8ed..c2cbd24e 100644 --- a/examples/09-cached-discovery-stdio/server.php +++ b/examples/09-cached-discovery-stdio/server.php @@ -26,8 +26,7 @@ ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) - ->setCache(new Psr16Cache(new ArrayAdapter())) + ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index a231aec3..40fd5647 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -58,7 +58,7 @@ final class ServerBuilder private ?LoggerInterface $logger = null; - private ?CacheInterface $cache = null; + private ?CacheInterface $discoveryCache = null; private ?ToolCallerInterface $toolCaller = null; @@ -220,20 +220,12 @@ public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], array $excludeDirs = [], + ?CacheInterface $cache = null, ): self { $this->discoveryBasePath = $basePath; $this->discoveryScanDirs = $scanDirs; $this->discoveryExcludeDirs = $excludeDirs; - - return $this; - } - - /** - * Enables discovery caching with the provided cache implementation. - */ - public function setCache(CacheInterface $cache): self - { - $this->cache = $cache; + $this->discoveryCache = $cache; return $this; } @@ -323,8 +315,8 @@ public function build(): Server if (null !== $this->discoveryBasePath) { $discovery = new Discoverer($registry, $logger); - if (null !== $this->cache) { - $discovery = new CachedDiscoverer($discovery, $this->cache, $logger); + if (null !== $this->discoveryCache) { + $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); } $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); From 3ec4cf612a7057f51d1415ba03c15b5c4ab11865 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 25 Sep 2025 23:27:41 +0200 Subject: [PATCH 16/66] Split testsuite for unit and inspector based tests (#82) --- .github/workflows/pipeline.yaml | 30 +++++++++++++++---- .gitignore | 1 + Makefile | 8 ++++- phpstan.dist.neon | 4 +-- phpunit.xml.dist | 7 +++-- .../InspectorSnapshotTestCase.php | 2 +- tests/Inspector/ManualStdioExampleTest.php | 29 ++++++++++++++++++ .../StdioCalculatorExampleTest.php | 5 +--- .../ManualStdioExampleTest-prompts_list.json | 15 ++++++++++ ...ManualStdioExampleTest-resources_list.json | 10 +++++++ .../ManualStdioExampleTest-tools_list.json | 20 +++++++++++++ ...dioCalculatorExampleTest-prompts_list.json | 0 ...oCalculatorExampleTest-resources_list.json | 0 ...StdioCalculatorExampleTest-tools_list.json | 0 .../Attribute/CompletionProviderFixture.php | 2 +- .../Attribute/CompletionProviderTest.php | 4 +-- .../Capability/Attribute/McpPromptTest.php | 2 +- .../Attribute/McpResourceTemplateTest.php | 2 +- .../Capability/Attribute/McpResourceTest.php | 2 +- .../Capability/Attribute/McpToolTest.php | 2 +- .../Discovery/CachedDiscovererTest.php | 2 +- .../Capability/Discovery/DiscoveryTest.php | 14 ++++----- .../Discovery/DocBlockParserTest.php | 2 +- .../Discovery/DocBlockTestFixture.php | 4 ++- .../Fixtures/DiscoverablePromptHandler.php | 4 +-- .../Fixtures/DiscoverableResourceHandler.php | 2 +- .../Fixtures/DiscoverableTemplateHandler.php | 4 +-- .../Fixtures/DiscoverableToolHandler.php | 4 +-- .../Fixtures/EnhancedCompletionHandler.php | 6 ++-- .../Fixtures/InvocablePromptFixture.php | 2 +- .../Fixtures/InvocableResourceFixture.php | 2 +- .../InvocableResourceTemplateFixture.php | 2 +- .../Fixtures/InvocableToolFixture.php | 2 +- .../Fixtures/NonDiscoverableClass.php | 2 +- .../Discovery/Fixtures/SubDir/HiddenTool.php | 2 +- .../Discovery/HandlerResolverTest.php | 6 ++-- .../Discovery/SchemaGeneratorFixture.php | 8 ++--- .../Discovery/SchemaGeneratorTest.php | 2 +- .../Discovery/SchemaValidatorTest.php | 2 +- .../Completion/EnumCompletionProviderTest.php | 8 ++--- .../Completion/ListCompletionProviderTest.php | 2 +- .../Capability/Prompt/PromptGetterTest.php | 2 +- .../Registry/RegistryProviderTest.php | 2 +- .../Capability/Registry/RegistryTest.php | 2 +- .../Resource/ResourceReaderTest.php | 2 +- .../Capability/Tool/ToolCallerTest.php | 2 +- .../Fixtures/Enum/BackedIntEnum.php | 2 +- .../Fixtures/Enum/BackedStringEnum.php | 2 +- .../{ => Unit}/Fixtures/Enum/PriorityEnum.php | 2 +- tests/{ => Unit}/Fixtures/Enum/StatusEnum.php | 2 +- tests/{ => Unit}/Fixtures/Enum/UnitEnum.php | 2 +- tests/{ => Unit}/JsonRpc/HandlerTest.php | 2 +- .../{ => Unit}/JsonRpc/MessageFactoryTest.php | 2 +- .../Schema/JsonRpc/NotificationTest.php | 2 +- .../{ => Unit}/Schema/JsonRpc/RequestTest.php | 2 +- .../Schema/ServerCapabilitiesTest.php | 2 +- .../RequestHandler/CallToolHandlerTest.php | 2 +- .../RequestHandler/GetPromptHandlerTest.php | 2 +- .../RequestHandler/ListPromptsHandlerTest.php | 2 +- .../ListResourcesHandlerTest.php | 2 +- .../RequestHandler/ListToolsHandlerTest.php | 2 +- .../Server/RequestHandler/PingHandlerTest.php | 2 +- .../ReadResourceHandlerTest.php | 2 +- tests/{ => Unit}/ServerTest.php | 2 +- 64 files changed, 188 insertions(+), 85 deletions(-) rename tests/{Example => Inspector}/InspectorSnapshotTestCase.php (98%) create mode 100644 tests/Inspector/ManualStdioExampleTest.php rename tests/{Example => Inspector}/StdioCalculatorExampleTest.php (87%) create mode 100644 tests/Inspector/snapshots/ManualStdioExampleTest-prompts_list.json create mode 100644 tests/Inspector/snapshots/ManualStdioExampleTest-resources_list.json create mode 100644 tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json rename tests/{Example => Inspector}/snapshots/StdioCalculatorExampleTest-prompts_list.json (100%) rename tests/{Example => Inspector}/snapshots/StdioCalculatorExampleTest-resources_list.json (100%) rename tests/{Example => Inspector}/snapshots/StdioCalculatorExampleTest-tools_list.json (100%) rename tests/{ => Unit}/Capability/Attribute/CompletionProviderFixture.php (94%) rename tests/{ => Unit}/Capability/Attribute/CompletionProviderTest.php (96%) rename tests/{ => Unit}/Capability/Attribute/McpPromptTest.php (96%) rename tests/{ => Unit}/Capability/Attribute/McpResourceTemplateTest.php (97%) rename tests/{ => Unit}/Capability/Attribute/McpResourceTest.php (97%) rename tests/{ => Unit}/Capability/Attribute/McpToolTest.php (96%) rename tests/{ => Unit}/Capability/Discovery/CachedDiscovererTest.php (98%) rename tests/{ => Unit}/Capability/Discovery/DiscoveryTest.php (94%) rename tests/{ => Unit}/Capability/Discovery/DocBlockParserTest.php (99%) rename tests/{ => Unit}/Capability/Discovery/DocBlockTestFixture.php (96%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php (91%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php (95%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php (92%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/DiscoverableToolHandler.php (94%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php (91%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/InvocablePromptFixture.php (90%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/InvocableResourceFixture.php (90%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php (91%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/InvocableToolFixture.php (90%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/NonDiscoverableClass.php (90%) rename tests/{ => Unit}/Capability/Discovery/Fixtures/SubDir/HiddenTool.php (86%) rename tests/{ => Unit}/Capability/Discovery/HandlerResolverTest.php (97%) rename tests/{ => Unit}/Capability/Discovery/SchemaGeneratorFixture.php (98%) rename tests/{ => Unit}/Capability/Discovery/SchemaGeneratorTest.php (99%) rename tests/{ => Unit}/Capability/Discovery/SchemaValidatorTest.php (99%) rename tests/{ => Unit}/Capability/Prompt/Completion/EnumCompletionProviderTest.php (92%) rename tests/{ => Unit}/Capability/Prompt/Completion/ListCompletionProviderTest.php (97%) rename tests/{ => Unit}/Capability/Prompt/PromptGetterTest.php (99%) rename tests/{ => Unit}/Capability/Registry/RegistryProviderTest.php (99%) rename tests/{ => Unit}/Capability/Registry/RegistryTest.php (99%) rename tests/{ => Unit}/Capability/Resource/ResourceReaderTest.php (99%) rename tests/{ => Unit}/Capability/Tool/ToolCallerTest.php (99%) rename tests/{ => Unit}/Fixtures/Enum/BackedIntEnum.php (89%) rename tests/{ => Unit}/Fixtures/Enum/BackedStringEnum.php (89%) rename tests/{ => Unit}/Fixtures/Enum/PriorityEnum.php (89%) rename tests/{ => Unit}/Fixtures/Enum/StatusEnum.php (90%) rename tests/{ => Unit}/Fixtures/Enum/UnitEnum.php (88%) rename tests/{ => Unit}/JsonRpc/HandlerTest.php (99%) rename tests/{ => Unit}/JsonRpc/MessageFactoryTest.php (98%) rename tests/{ => Unit}/Schema/JsonRpc/NotificationTest.php (97%) rename tests/{ => Unit}/Schema/JsonRpc/RequestTest.php (97%) rename tests/{ => Unit}/Schema/ServerCapabilitiesTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/CallToolHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/GetPromptHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/ListPromptsHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/ListResourcesHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/ListToolsHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/PingHandlerTest.php (99%) rename tests/{ => Unit}/Server/RequestHandler/ReadResourceHandlerTest.php (99%) rename tests/{ => Unit}/ServerTest.php (98%) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 98e173a3..d7fd0de1 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -6,7 +6,7 @@ permissions: pull-requests: write jobs: - tests: + unit: runs-on: ubuntu-latest strategy: matrix: @@ -22,16 +22,36 @@ jobs: php-version: ${{ matrix.php }} coverage: "none" - - name: Composer Validation - run: composer validate --strict - - name: Install Composer uses: "ramsey/composer-install@v3" with: dependency-versions: "${{ matrix.dependencies }}" - name: Tests - run: vendor/bin/phpunit --exclude-group inspector + run: vendor/bin/phpunit --testsuite=unit + + inspector: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: "none" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Install Composer + uses: "ramsey/composer-install@v3" + + - name: Tests + run: vendor/bin/phpunit --testsuite=inspector qa: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 825fe72f..dda65497 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .phpunit.cache .php-cs-fixer.cache composer.lock +coverage vendor examples/**/dev.log examples/**/sessions diff --git a/Makefile b/Makefile index 12e4f321..119e9d40 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,14 @@ phpstan: tests: vendor/bin/phpunit +unit-tests: + vendor/bin/phpunit --testsuite=unit + +inspector-tests: + vendor/bin/phpunit --testsuite=inspector + coverage: - XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage + XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --coverage-html=coverage ci: ci-stable diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 108b384b..17f4c3b0 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,8 +9,8 @@ parameters: - tests/ excludePaths: - examples/cli/vendor/* (?) - - tests/Capability/Discovery/Fixtures/ - - tests/Capability/Discovery/SchemaGeneratorFixture.php + - tests/Unit/Capability/Discovery/Fixtures/ + - tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php treatPhpDocTypesAsCertain: false ignoreErrors: - diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1dfe524e..d5adf9e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,8 +9,11 @@ failOnWarning="true"> - - tests + + tests/Unit + + + tests/Inspector diff --git a/tests/Example/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php similarity index 98% rename from tests/Example/InspectorSnapshotTestCase.php rename to tests/Inspector/InspectorSnapshotTestCase.php index fae80d51..bca8179f 100644 --- a/tests/Example/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Example; +namespace Mcp\Tests\Inspector; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Inspector/ManualStdioExampleTest.php b/tests/Inspector/ManualStdioExampleTest.php new file mode 100644 index 00000000..affe4462 --- /dev/null +++ b/tests/Inspector/ManualStdioExampleTest.php @@ -0,0 +1,29 @@ +expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invokable handler class "Mcp\\Tests\\Capability\\Discovery\\NonInvokableClass" must have a public "__invoke" method.'); + $this->expectExceptionMessage('Invokable handler class "Mcp\Tests\Unit\Capability\Discovery\NonInvokableClass" must have a public "__invoke" method.'); HandlerResolver::resolve(NonInvokableClass::class); } @@ -129,7 +129,7 @@ public function testThrowsForDestructorAsHandler() public function testThrowsForAbstractMethodHandler() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Handler method "Mcp\Tests\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.'); + $this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.'); HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']); } diff --git a/tests/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php similarity index 98% rename from tests/Capability/Discovery/SchemaGeneratorFixture.php rename to tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 736487ed..5a7fcaeb 100644 --- a/tests/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery; +namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Attribute\Schema; -use Mcp\Tests\Fixtures\Enum\BackedIntEnum; -use Mcp\Tests\Fixtures\Enum\BackedStringEnum; -use Mcp\Tests\Fixtures\Enum\UnitEnum; +use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum; +use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; +use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; /** * Comprehensive fixture for testing SchemaGenerator with various scenarios. diff --git a/tests/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php similarity index 99% rename from tests/Capability/Discovery/SchemaGeneratorTest.php rename to tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 19fb2288..4cbfce52 100644 --- a/tests/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery; +namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\SchemaGenerator; diff --git a/tests/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php similarity index 99% rename from tests/Capability/Discovery/SchemaValidatorTest.php rename to tests/Unit/Capability/Discovery/SchemaValidatorTest.php index b1c8a705..417ddbde 100644 --- a/tests/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery; +namespace Mcp\Tests\Unit\Capability\Discovery; use Mcp\Capability\Attribute\Schema; use Mcp\Capability\Discovery\SchemaValidator; diff --git a/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php similarity index 92% rename from tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php rename to tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php index 7d568f3c..8d81d0e6 100644 --- a/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php +++ b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Prompt\Completion; +namespace Mcp\Tests\Unit\Capability\Prompt\Completion; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Exception\InvalidArgumentException; -use Mcp\Tests\Fixtures\Enum\PriorityEnum; -use Mcp\Tests\Fixtures\Enum\StatusEnum; -use Mcp\Tests\Fixtures\Enum\UnitEnum; +use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; +use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; +use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum; use PHPUnit\Framework\TestCase; class EnumCompletionProviderTest extends TestCase diff --git a/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php similarity index 97% rename from tests/Capability/Prompt/Completion/ListCompletionProviderTest.php rename to tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php index b5def78d..80c58100 100644 --- a/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php +++ b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Prompt\Completion; +namespace Mcp\Tests\Unit\Capability\Prompt\Completion; use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Capability/Prompt/PromptGetterTest.php b/tests/Unit/Capability/Prompt/PromptGetterTest.php similarity index 99% rename from tests/Capability/Prompt/PromptGetterTest.php rename to tests/Unit/Capability/Prompt/PromptGetterTest.php index 46bee1cf..fe68ddcb 100644 --- a/tests/Capability/Prompt/PromptGetterTest.php +++ b/tests/Unit/Capability/Prompt/PromptGetterTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Prompt; +namespace Mcp\Tests\Unit\Capability\Prompt; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\PromptGetter; diff --git a/tests/Capability/Registry/RegistryProviderTest.php b/tests/Unit/Capability/Registry/RegistryProviderTest.php similarity index 99% rename from tests/Capability/Registry/RegistryProviderTest.php rename to tests/Unit/Capability/Registry/RegistryProviderTest.php index 604311be..b1eaa857 100644 --- a/tests/Capability/Registry/RegistryProviderTest.php +++ b/tests/Unit/Capability/Registry/RegistryProviderTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Registry; +namespace Mcp\Tests\Unit\Capability\Registry; use Mcp\Capability\Registry; use Mcp\Capability\Registry\PromptReference; diff --git a/tests/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php similarity index 99% rename from tests/Capability/Registry/RegistryTest.php rename to tests/Unit/Capability/Registry/RegistryTest.php index 14548b63..91ce0f71 100644 --- a/tests/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Registry; +namespace Mcp\Tests\Unit\Capability\Registry; use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; diff --git a/tests/Capability/Resource/ResourceReaderTest.php b/tests/Unit/Capability/Resource/ResourceReaderTest.php similarity index 99% rename from tests/Capability/Resource/ResourceReaderTest.php rename to tests/Unit/Capability/Resource/ResourceReaderTest.php index 2206ce92..73c967ff 100644 --- a/tests/Capability/Resource/ResourceReaderTest.php +++ b/tests/Unit/Capability/Resource/ResourceReaderTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Resource; +namespace Mcp\Tests\Unit\Capability\Resource; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; diff --git a/tests/Capability/Tool/ToolCallerTest.php b/tests/Unit/Capability/Tool/ToolCallerTest.php similarity index 99% rename from tests/Capability/Tool/ToolCallerTest.php rename to tests/Unit/Capability/Tool/ToolCallerTest.php index 8894dc93..889c2ec4 100644 --- a/tests/Capability/Tool/ToolCallerTest.php +++ b/tests/Unit/Capability/Tool/ToolCallerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Tool; +namespace Mcp\Tests\Unit\Capability\Tool; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; diff --git a/tests/Fixtures/Enum/BackedIntEnum.php b/tests/Unit/Fixtures/Enum/BackedIntEnum.php similarity index 89% rename from tests/Fixtures/Enum/BackedIntEnum.php rename to tests/Unit/Fixtures/Enum/BackedIntEnum.php index 32fa8ca8..75079c77 100644 --- a/tests/Fixtures/Enum/BackedIntEnum.php +++ b/tests/Unit/Fixtures/Enum/BackedIntEnum.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Fixtures\Enum; +namespace Mcp\Tests\Unit\Fixtures\Enum; enum BackedIntEnum: int { diff --git a/tests/Fixtures/Enum/BackedStringEnum.php b/tests/Unit/Fixtures/Enum/BackedStringEnum.php similarity index 89% rename from tests/Fixtures/Enum/BackedStringEnum.php rename to tests/Unit/Fixtures/Enum/BackedStringEnum.php index ae5cbcaa..85961949 100644 --- a/tests/Fixtures/Enum/BackedStringEnum.php +++ b/tests/Unit/Fixtures/Enum/BackedStringEnum.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Fixtures\Enum; +namespace Mcp\Tests\Unit\Fixtures\Enum; enum BackedStringEnum: string { diff --git a/tests/Fixtures/Enum/PriorityEnum.php b/tests/Unit/Fixtures/Enum/PriorityEnum.php similarity index 89% rename from tests/Fixtures/Enum/PriorityEnum.php rename to tests/Unit/Fixtures/Enum/PriorityEnum.php index 5bf466e6..2d3c3706 100644 --- a/tests/Fixtures/Enum/PriorityEnum.php +++ b/tests/Unit/Fixtures/Enum/PriorityEnum.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Fixtures\Enum; +namespace Mcp\Tests\Unit\Fixtures\Enum; enum PriorityEnum: int { diff --git a/tests/Fixtures/Enum/StatusEnum.php b/tests/Unit/Fixtures/Enum/StatusEnum.php similarity index 90% rename from tests/Fixtures/Enum/StatusEnum.php rename to tests/Unit/Fixtures/Enum/StatusEnum.php index 66bfce32..0387d44a 100644 --- a/tests/Fixtures/Enum/StatusEnum.php +++ b/tests/Unit/Fixtures/Enum/StatusEnum.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Fixtures\Enum; +namespace Mcp\Tests\Unit\Fixtures\Enum; enum StatusEnum: string { diff --git a/tests/Fixtures/Enum/UnitEnum.php b/tests/Unit/Fixtures/Enum/UnitEnum.php similarity index 88% rename from tests/Fixtures/Enum/UnitEnum.php rename to tests/Unit/Fixtures/Enum/UnitEnum.php index a751e12c..65947683 100644 --- a/tests/Fixtures/Enum/UnitEnum.php +++ b/tests/Unit/Fixtures/Enum/UnitEnum.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Fixtures\Enum; +namespace Mcp\Tests\Unit\Fixtures\Enum; enum UnitEnum { diff --git a/tests/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php similarity index 99% rename from tests/JsonRpc/HandlerTest.php rename to tests/Unit/JsonRpc/HandlerTest.php index 20781def..2535449a 100644 --- a/tests/JsonRpc/HandlerTest.php +++ b/tests/Unit/JsonRpc/HandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\JsonRpc; +namespace Mcp\Tests\Unit\JsonRpc; use Mcp\JsonRpc\Handler; use Mcp\JsonRpc\MessageFactory; diff --git a/tests/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php similarity index 98% rename from tests/JsonRpc/MessageFactoryTest.php rename to tests/Unit/JsonRpc/MessageFactoryTest.php index 9f43aad6..12d2b233 100644 --- a/tests/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\JsonRpc; +namespace Mcp\Tests\Unit\JsonRpc; use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; diff --git a/tests/Schema/JsonRpc/NotificationTest.php b/tests/Unit/Schema/JsonRpc/NotificationTest.php similarity index 97% rename from tests/Schema/JsonRpc/NotificationTest.php rename to tests/Unit/Schema/JsonRpc/NotificationTest.php index c2ab0fe5..af98671c 100644 --- a/tests/Schema/JsonRpc/NotificationTest.php +++ b/tests/Unit/Schema/JsonRpc/NotificationTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Schema\JsonRpc; +namespace Mcp\Tests\Unit\Schema\JsonRpc; use Mcp\Schema\JsonRpc\Notification; use PHPUnit\Framework\TestCase; diff --git a/tests/Schema/JsonRpc/RequestTest.php b/tests/Unit/Schema/JsonRpc/RequestTest.php similarity index 97% rename from tests/Schema/JsonRpc/RequestTest.php rename to tests/Unit/Schema/JsonRpc/RequestTest.php index 279d0953..86e01d3d 100644 --- a/tests/Schema/JsonRpc/RequestTest.php +++ b/tests/Unit/Schema/JsonRpc/RequestTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Schema\JsonRpc; +namespace Mcp\Tests\Unit\Schema\JsonRpc; use Mcp\Schema\JsonRpc\Request; use PHPUnit\Framework\TestCase; diff --git a/tests/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php similarity index 99% rename from tests/Schema/ServerCapabilitiesTest.php rename to tests/Unit/Schema/ServerCapabilitiesTest.php index 3a9f2b99..9d1562c1 100644 --- a/tests/Schema/ServerCapabilitiesTest.php +++ b/tests/Unit/Schema/ServerCapabilitiesTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Schema; +namespace Mcp\Tests\Unit\Schema; use Mcp\Schema\ServerCapabilities; use PHPUnit\Framework\TestCase; diff --git a/tests/Server/RequestHandler/CallToolHandlerTest.php b/tests/Unit/Server/RequestHandler/CallToolHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/CallToolHandlerTest.php rename to tests/Unit/Server/RequestHandler/CallToolHandlerTest.php index 1b2187ff..ea0b8c17 100644 --- a/tests/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/CallToolHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ToolCallException; diff --git a/tests/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/GetPromptHandlerTest.php rename to tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php index 120b0e08..e941ef41 100644 --- a/tests/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Exception\PromptGetException; diff --git a/tests/Server/RequestHandler/ListPromptsHandlerTest.php b/tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/ListPromptsHandlerTest.php rename to tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php index b10d3e56..c3079e2d 100644 --- a/tests/Server/RequestHandler/ListPromptsHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; diff --git a/tests/Server/RequestHandler/ListResourcesHandlerTest.php b/tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/ListResourcesHandlerTest.php rename to tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php index 13044a73..f1b6a6ca 100644 --- a/tests/Server/RequestHandler/ListResourcesHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; diff --git a/tests/Server/RequestHandler/ListToolsHandlerTest.php b/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/ListToolsHandlerTest.php rename to tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php index 85ae9b63..a7e4902c 100644 --- a/tests/Server/RequestHandler/ListToolsHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; diff --git a/tests/Server/RequestHandler/PingHandlerTest.php b/tests/Unit/Server/RequestHandler/PingHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/PingHandlerTest.php rename to tests/Unit/Server/RequestHandler/PingHandlerTest.php index 3be1176b..6c75e74a 100644 --- a/tests/Server/RequestHandler/PingHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/PingHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; diff --git a/tests/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php similarity index 99% rename from tests/Server/RequestHandler/ReadResourceHandlerTest.php rename to tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php index a78aa853..ab6c8fb3 100644 --- a/tests/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\RequestHandler; use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Exception\ResourceNotFoundException; diff --git a/tests/ServerTest.php b/tests/Unit/ServerTest.php similarity index 98% rename from tests/ServerTest.php rename to tests/Unit/ServerTest.php index 046583d1..34c04efc 100644 --- a/tests/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests; +namespace Mcp\Tests\Unit; use Mcp\JsonRpc\Handler; use Mcp\Server; From 095ea2af5077a6034288d59d5acba0e7c2cb7af8 Mon Sep 17 00:00:00 2001 From: Sule-Balogun Olanrewaju Date: Sat, 27 Sep 2025 12:43:40 +0100 Subject: [PATCH 17/66] Address issue with pagination limit builder class class (#83) --- phpstan-baseline.neon | 18 ------- src/JsonRpc/Handler.php | 7 +-- src/Server/RequestHandler/Reference/Page.php | 25 ++-------- src/Server/ServerBuilder.php | 6 ++- .../RequestHandler/ListToolsHandlerTest.php | 47 ++++++++++++++++++- 5 files changed, 58 insertions(+), 45 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4dca0b25..7a4071ba 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -78,30 +78,12 @@ parameters: count: 1 path: src/Server/ServerBuilder.php - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$discoveryExcludeDirs type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$instructions is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: src/Server/ServerBuilder.php - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit \(int\|null\) is never assigned null so it can be removed from the property type\.$#' - identifier: property.unusedType - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$paginationLimit is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Server/ServerBuilder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/src/JsonRpc/Handler.php b/src/JsonRpc/Handler.php index a16f7347..b73f7769 100644 --- a/src/JsonRpc/Handler.php +++ b/src/JsonRpc/Handler.php @@ -73,6 +73,7 @@ public static function make( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory, LoggerInterface $logger = new NullLogger(), + int $paginationLimit = 50, ): self { return new self( messageFactory: MessageFactory::make(), @@ -82,12 +83,12 @@ public static function make( new NotificationHandler\InitializedHandler(), new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($referenceProvider), + new RequestHandler\ListPromptsHandler($referenceProvider, $paginationLimit), new RequestHandler\GetPromptHandler($promptGetter), - new RequestHandler\ListResourcesHandler($referenceProvider), + new RequestHandler\ListResourcesHandler($referenceProvider, $paginationLimit), new RequestHandler\ReadResourceHandler($resourceReader), new RequestHandler\CallToolHandler($toolCaller, $logger), - new RequestHandler\ListToolsHandler($referenceProvider), + new RequestHandler\ListToolsHandler($referenceProvider, $paginationLimit), ], logger: $logger, ); diff --git a/src/Server/RequestHandler/Reference/Page.php b/src/Server/RequestHandler/Reference/Page.php index b76aa0ba..b5b31863 100644 --- a/src/Server/RequestHandler/Reference/Page.php +++ b/src/Server/RequestHandler/Reference/Page.php @@ -12,9 +12,9 @@ namespace Mcp\Server\RequestHandler\Reference; /** - * @implements \ArrayAccess + * @extends \ArrayObject */ -final class Page implements \Countable, \ArrayAccess +final class Page extends \ArrayObject { /** * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource @@ -23,30 +23,11 @@ public function __construct( public readonly array $references, public readonly ?string $nextCursor, ) { + parent::__construct($references, \ArrayObject::ARRAY_AS_PROPS); } public function count(): int { return \count($this->references); } - - public function offsetExists(mixed $offset): bool - { - return isset($this->references[$offset]); - } - - public function offsetGet(mixed $offset): mixed - { - return $this->references[$offset] ?? null; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - return; - } - - public function offsetUnset(mixed $offset): void - { - return; - } } diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php index 40fd5647..f22cd9bc 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/ServerBuilder.php @@ -76,7 +76,7 @@ final class ServerBuilder private int $sessionTtl = 3600; - private ?int $paginationLimit = 50; + private int $paginationLimit = 50; private ?string $instructions = null; @@ -119,6 +119,9 @@ final class ServerBuilder * @var array|string[] */ private array $discoveryScanDirs = []; + /** + * @var array|string[] + */ private array $discoveryExcludeDirs = []; /** @@ -337,6 +340,7 @@ public function build(): Server sessionStore: $sessionStore, sessionFactory: $sessionFactory, logger: $logger, + paginationLimit: $this->paginationLimit, ), logger: $logger, ); diff --git a/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php b/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php index a7e4902c..d85c2bf7 100644 --- a/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php +++ b/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php @@ -137,7 +137,7 @@ public function testReturnsLastPageWithNullCursor(): void public function testReturnsAllToolsWhenCountIsLessThanPageSize(): void { // Arrange - $this->addToolsToRegistry(2); // Less than page size (3) + $this->addToolsToRegistry(2); // Less than page size 3 $request = $this->createListToolsRequest(); // Act @@ -239,6 +239,51 @@ public function testMaintainsStableCursorsAcrossCalls(): void $this->assertEquals($result1->tools, $result2->tools); } + #[TestDox('Uses custom page size when provided')] + public function testUsesCustomPageSizeWhenProvided(): void + { + // Arrange + $customPageSize = 5; + $customHandler = new ListToolsHandler($this->registry, pageSize: $customPageSize); + $this->addToolsToRegistry(10); + $request = $this->createListToolsRequest(); + + // Act + $response = $customHandler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount($customPageSize, $result->tools); + $this->assertNotNull($result->nextCursor); + } + + #[TestDox('Different page sizes produce different pagination results')] + public function testDifferentPageSizesProduceDifferentPaginationResults(): void + { + // Arrange + $this->addToolsToRegistry(10); + $smallPageHandler = new ListToolsHandler($this->registry, pageSize: 2); + $largePageHandler = new ListToolsHandler($this->registry, pageSize: 7); + $request = $this->createListToolsRequest(); + + // Act + $smallPageResponse = $smallPageHandler->handle($request, $this->session); + $largePageResponse = $largePageHandler->handle($request, $this->session); + + // Assert + /** @var ListToolsResult $smallResult */ + $smallResult = $smallPageResponse->result; + /** @var ListToolsResult $largeResult */ + $largeResult = $largePageResponse->result; + + $this->assertCount(2, $smallResult->tools); + $this->assertCount(7, $largeResult->tools); + $this->assertNotNull($smallResult->nextCursor); + $this->assertNotNull($largeResult->nextCursor); + } + private function addToolsToRegistry(int $count): void { for ($i = 0; $i < $count; ++$i) { From 38441b8dc7529e290912f0b82c73049204bee544 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Sep 2025 17:51:44 +0100 Subject: [PATCH 18/66] refactor: rename Server::make() to Server::builder() (#84) --- README.md | 2 +- docs/discovery-caching.md | 6 +++--- examples/01-discovery-stdio-calculator/server.php | 2 +- examples/02-discovery-http-userprofile/server.php | 2 +- examples/03-manual-registration-stdio/server.php | 2 +- examples/04-combined-registration-http/server.php | 2 +- examples/05-stdio-env-variables/server.php | 2 +- examples/06-custom-dependencies-stdio/server.php | 2 +- examples/07-complex-tool-schema-http/server.php | 2 +- examples/08-schema-showcase-streamable/server.php | 2 +- examples/09-cached-discovery-stdio/server.php | 2 +- src/Server.php | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3369243f..94001f1c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ require_once __DIR__ . '/vendor/autoload.php'; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -Server::make() +Server::builder() ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') ->setDiscovery(__DIR__, ['.']) ->build() diff --git a/docs/discovery-caching.md b/docs/discovery-caching.md index 423fdc09..9f100822 100644 --- a/docs/discovery-caching.md +++ b/docs/discovery-caching.md @@ -19,7 +19,7 @@ use Mcp\Server; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -$server = Server::make() +$server = Server::builder() ->setServerInfo('My Server', '1.0.0') ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) // Enable caching ->build(); @@ -67,7 +67,7 @@ $cache = DoctrineProvider::wrap(new ArrayCache()); // Use in-memory cache for fast development cycles $cache = new Psr16Cache(new ArrayAdapter()); -$server = Server::make() +$server = Server::builder() ->setDiscovery(__DIR__, ['.'], [], $cache) ->build(); ``` @@ -78,7 +78,7 @@ $server = Server::make() // Use persistent cache $cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); -$server = Server::make() +$server = Server::builder() ->setDiscovery(__DIR__, ['.'], [], $cache) ->build(); ``` diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php index 4bff8b8b..1c5be71d 100644 --- a/examples/01-discovery-stdio-calculator/server.php +++ b/examples/01-discovery-stdio-calculator/server.php @@ -18,7 +18,7 @@ logger()->info('Starting MCP Stdio Calculator Server...'); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') ->setContainer(container()) ->setLogger(logger()) diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index ce3ac347..b1cba000 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -25,7 +25,7 @@ $request = $creator->fromGlobals(); -$server = Server::make() +$server = Server::builder() ->setServerInfo('HTTP User Profiles', '1.0.0') ->setLogger(logger()) ->setContainer(container()) diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php index 9b83d82e..8b717658 100644 --- a/examples/03-manual-registration-stdio/server.php +++ b/examples/03-manual-registration-stdio/server.php @@ -19,7 +19,7 @@ logger()->info('Starting MCP Manual Registration (Stdio) Server...'); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Manual Reg Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index 69225020..b78c680c 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -26,7 +26,7 @@ $request = $creator->fromGlobals(); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php index af159f40..08848bba 100644 --- a/examples/05-stdio-env-variables/server.php +++ b/examples/05-stdio-env-variables/server.php @@ -49,7 +49,7 @@ logger()->info('Starting MCP Stdio Environment Variable Example Server...'); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Env Var Server', '1.0.0') ->setLogger(logger()) ->setDiscovery(__DIR__, ['.']) diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index ac85d0ac..42d5b053 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -30,7 +30,7 @@ $statsService = new SystemStatsService($taskRepo); $container->set(StatsServiceInterface::class, $statsService); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Task Manager Server', '1.0.0') ->setLogger(logger()) ->setContainer($container) diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php index 25e3039b..fbbe45a8 100644 --- a/examples/07-complex-tool-schema-http/server.php +++ b/examples/07-complex-tool-schema-http/server.php @@ -25,7 +25,7 @@ $request = $creator->fromGlobals(); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Event Scheduler Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php index 777b5dda..8b35b6a2 100644 --- a/examples/08-schema-showcase-streamable/server.php +++ b/examples/08-schema-showcase-streamable/server.php @@ -25,7 +25,7 @@ $request = $creator->fromGlobals(); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Schema Showcase', '1.0.0') ->setContainer(container()) ->setLogger(logger()) diff --git a/examples/09-cached-discovery-stdio/server.php b/examples/09-cached-discovery-stdio/server.php index c2cbd24e..dcd849ba 100644 --- a/examples/09-cached-discovery-stdio/server.php +++ b/examples/09-cached-discovery-stdio/server.php @@ -22,7 +22,7 @@ logger()->info('Starting MCP Cached Discovery Calculator Server...'); -$server = Server::make() +$server = Server::builder() ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) ->setLogger(logger()) diff --git a/src/Server.php b/src/Server.php index 239a3132..7f1468ab 100644 --- a/src/Server.php +++ b/src/Server.php @@ -30,7 +30,7 @@ public function __construct( ) { } - public static function make(): ServerBuilder + public static function builder(): ServerBuilder { return new ServerBuilder(); } From 3dcd2f717a66ecd3111c29bf8a4b7ef69560688d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Sep 2025 17:59:48 +0100 Subject: [PATCH 19/66] refactor: rename ServerBuilder to Builder (#85) Remove redundant "Server" prefix since class is already in Server namespace. --- phpstan-baseline.neon | 52 +++++++++---------- src/Server.php | 6 +-- src/Server/{ServerBuilder.php => Builder.php} | 8 ++- src/Server/Configuration.php | 2 +- 4 files changed, 36 insertions(+), 32 deletions(-) rename src/Server/{ServerBuilder.php => Builder.php} (99%) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7a4071ba..c1e53ebe 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -31,79 +31,79 @@ parameters: path: src/Schema/Result/ReadResourceResult.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' + message: '#^Method Mcp\\Server\\Builder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$instructions is never read, only written\.$#' + message: '#^Property Mcp\\Server\\Builder\:\:\$instructions is never read, only written\.$#' identifier: property.onlyWritten count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$prompts type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\Builder\:\:\$prompts type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\Builder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$resources type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\Builder\:\:\$resources type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\ServerBuilder\:\:\$tools type has no value type specified in iterable type array\.$#' + message: '#^Property Mcp\\Server\\Builder\:\:\$tools type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue count: 1 - path: src/Server/ServerBuilder.php + path: src/Server/Builder.php diff --git a/src/Server.php b/src/Server.php index 7f1468ab..a529134e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,7 +12,7 @@ namespace Mcp; use Mcp\JsonRpc\Handler; -use Mcp\Server\ServerBuilder; +use Mcp\Server\Builder; use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,9 +30,9 @@ public function __construct( ) { } - public static function builder(): ServerBuilder + public static function builder(): Builder { - return new ServerBuilder(); + return new Builder(); } public function connect(TransportInterface $transport): void diff --git a/src/Server/ServerBuilder.php b/src/Server/Builder.php similarity index 99% rename from src/Server/ServerBuilder.php rename to src/Server/Builder.php index f22cd9bc..b3d36ca4 100644 --- a/src/Server/ServerBuilder.php +++ b/src/Server/Builder.php @@ -52,7 +52,7 @@ /** * @author Kyrian Obikwelu */ -final class ServerBuilder +final class Builder { private ?Implementation $serverInfo = null; @@ -108,17 +108,21 @@ final class ServerBuilder * annotations: Annotations|null} * > */ private array $resourceTemplates = []; + /** @var array< * array{handler: array|string|\Closure, * name: string|null, * description: string|null} * > */ private array $prompts = []; + private ?string $discoveryBasePath = null; + /** - * @var array|string[] + * @var string[] */ private array $discoveryScanDirs = []; + /** * @var array|string[] */ diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php index f5c4fabb..ab629b58 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -17,7 +17,7 @@ /** * Value Object holding core configuration and shared dependencies for the MCP Server instance. * - * This object is typically assembled by the ServerBuilder and passed to the Server constructor. + * This object is typically assembled by the Builder and passed to the Server constructor. * * @author Kyrian Obikwelu */ From 41486bfaa492005b60564a03bddfd004fdfbacce Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Sep 2025 23:58:04 +0100 Subject: [PATCH 20/66] [Server] Reorganize server handlers and transport structure for better organization (#86) * refactor: reorganize server handlers and transport structure * feat: move the test files to corresponding directories * fix: linter issues * fix: correct PHPDoc formatting and streamline handler imports * refactor: update import statement for ServerBuilder to Builder --- src/Capability/Registry.php | 2 +- .../Registry/ReferenceProviderInterface.php | 2 +- .../Reference => Schema}/Page.php | 8 +++--- src/Server.php | 6 ++--- src/Server/Builder.php | 4 +-- .../Handler/JsonRpcHandler.php} | 27 +++++++++---------- .../{ => Handler}/MethodHandlerInterface.php | 2 +- .../Notification}/InitializedHandler.php | 4 +-- .../Request}/CallToolHandler.php | 4 +-- .../Request}/GetPromptHandler.php | 4 +-- .../Request}/InitializeHandler.php | 4 +-- .../Request}/ListPromptsHandler.php | 4 +-- .../Request}/ListResourcesHandler.php | 4 +-- .../Request}/ListToolsHandler.php | 4 +-- .../Request}/PingHandler.php | 4 +-- .../Request}/ReadResourceHandler.php | 4 +-- src/Server/Transport/InMemoryTransport.php | 1 - src/Server/Transport/StdioTransport.php | 1 - .../Transport/StreamableHttpTransport.php | 1 - .../{ => Transport}/TransportInterface.php | 2 +- tests/Unit/JsonRpc/HandlerTest.php | 8 +++--- .../Request}/CallToolHandlerTest.php | 4 +-- .../Request}/GetPromptHandlerTest.php | 4 +-- .../Request}/ListPromptsHandlerTest.php | 4 +-- .../Request}/ListResourcesHandlerTest.php | 4 +-- .../Request}/ListToolsHandlerTest.php | 4 +-- .../Request}/PingHandlerTest.php | 4 +-- .../Request}/ReadResourceHandlerTest.php | 4 +-- tests/Unit/ServerTest.php | 4 +-- 29 files changed, 65 insertions(+), 67 deletions(-) rename src/{Server/RequestHandler/Reference => Schema}/Page.php (71%) rename src/{JsonRpc/Handler.php => Server/Handler/JsonRpcHandler.php} (92%) rename src/Server/{ => Handler}/MethodHandlerInterface.php (96%) rename src/Server/{NotificationHandler => Handler/Notification}/InitializedHandler.php (91%) rename src/Server/{RequestHandler => Handler/Request}/CallToolHandler.php (95%) rename src/Server/{RequestHandler => Handler/Request}/GetPromptHandler.php (94%) rename src/Server/{RequestHandler => Handler/Request}/InitializeHandler.php (94%) rename src/Server/{RequestHandler => Handler/Request}/ListPromptsHandler.php (94%) rename src/Server/{RequestHandler => Handler/Request}/ListResourcesHandler.php (94%) rename src/Server/{RequestHandler => Handler/Request}/ListToolsHandler.php (94%) rename src/Server/{RequestHandler => Handler/Request}/PingHandler.php (91%) rename src/Server/{RequestHandler => Handler/Request}/ReadResourceHandler.php (94%) rename src/Server/{ => Transport}/TransportInterface.php (98%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/CallToolHandlerTest.php (99%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/GetPromptHandlerTest.php (99%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/ListPromptsHandlerTest.php (98%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/ListResourcesHandlerTest.php (98%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/ListToolsHandlerTest.php (99%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/PingHandlerTest.php (98%) rename tests/Unit/Server/{RequestHandler => Handler/Request}/ReadResourceHandlerTest.php (99%) diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index ff836745..6e54cfc2 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -23,12 +23,12 @@ use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\InvalidCursorException; +use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; -use Mcp\Server\RequestHandler\Reference\Page; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 1d40359f..2f60014b 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,7 +11,7 @@ namespace Mcp\Capability\Registry; -use Mcp\Server\RequestHandler\Reference\Page; +use Mcp\Schema\Page; /** * Interface for providing access to registered MCP elements. diff --git a/src/Server/RequestHandler/Reference/Page.php b/src/Schema/Page.php similarity index 71% rename from src/Server/RequestHandler/Reference/Page.php rename to src/Schema/Page.php index b5b31863..bad46e9c 100644 --- a/src/Server/RequestHandler/Reference/Page.php +++ b/src/Schema/Page.php @@ -9,15 +9,17 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler\Reference; +namespace Mcp\Schema; /** - * @extends \ArrayObject + * @phpstan-type PageItem Tool|Prompt|ResourceTemplate|Resource + * + * @extends \ArrayObject */ final class Page extends \ArrayObject { /** - * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource */ public function __construct( public readonly array $references, diff --git a/src/Server.php b/src/Server.php index a529134e..ec3d6542 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,9 +11,9 @@ namespace Mcp; -use Mcp\JsonRpc\Handler; use Mcp\Server\Builder; -use Mcp\Server\TransportInterface; +use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; @@ -25,7 +25,7 @@ final class Server { public function __construct( - private readonly Handler $jsonRpcHandler, + private readonly JsonRpcHandler $jsonRpcHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index b3d36ca4..7afd5bd1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -29,7 +29,6 @@ use Mcp\Capability\Tool\ToolCaller; use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; -use Mcp\JsonRpc\Handler; use Mcp\Schema\Annotations; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -39,6 +38,7 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; +use Mcp\Server\Handler\JsonRpcHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -334,7 +334,7 @@ public function build(): Server $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); return new Server( - jsonRpcHandler: Handler::make( + jsonRpcHandler: JsonRpcHandler::make( registry: $registry, referenceProvider: $registry, implementation: $this->serverInfo, diff --git a/src/JsonRpc/Handler.php b/src/Server/Handler/JsonRpcHandler.php similarity index 92% rename from src/JsonRpc/Handler.php rename to src/Server/Handler/JsonRpcHandler.php index b73f7769..a29dc998 100644 --- a/src/JsonRpc/Handler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\JsonRpc; +namespace Mcp\Server\Handler; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; @@ -20,15 +20,14 @@ use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; use Mcp\Exception\NotFoundExceptionInterface; +use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; -use Mcp\Server\MethodHandlerInterface; -use Mcp\Server\NotificationHandler; -use Mcp\Server\RequestHandler; +use Mcp\Server\Handler; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; @@ -41,7 +40,7 @@ * * @author Christopher Hertel */ -class Handler +class JsonRpcHandler { /** * @var array @@ -80,15 +79,15 @@ public static function make( sessionFactory: $sessionFactory, sessionStore: $sessionStore, methodHandlers: [ - new NotificationHandler\InitializedHandler(), - new RequestHandler\InitializeHandler($registry->getCapabilities(), $implementation), - new RequestHandler\PingHandler(), - new RequestHandler\ListPromptsHandler($referenceProvider, $paginationLimit), - new RequestHandler\GetPromptHandler($promptGetter), - new RequestHandler\ListResourcesHandler($referenceProvider, $paginationLimit), - new RequestHandler\ReadResourceHandler($resourceReader), - new RequestHandler\CallToolHandler($toolCaller, $logger), - new RequestHandler\ListToolsHandler($referenceProvider, $paginationLimit), + new Notification\InitializedHandler(), + new Handler\Request\InitializeHandler($registry->getCapabilities(), $implementation), + new Handler\Request\PingHandler(), + new Handler\Request\ListPromptsHandler($referenceProvider, $paginationLimit), + new Handler\Request\GetPromptHandler($promptGetter), + new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit), + new Handler\Request\ReadResourceHandler($resourceReader), + new Handler\Request\CallToolHandler($toolCaller, $logger), + new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit), ], logger: $logger, ); diff --git a/src/Server/MethodHandlerInterface.php b/src/Server/Handler/MethodHandlerInterface.php similarity index 96% rename from src/Server/MethodHandlerInterface.php rename to src/Server/Handler/MethodHandlerInterface.php index 4abca854..0f61631b 100644 --- a/src/Server/MethodHandlerInterface.php +++ b/src/Server/Handler/MethodHandlerInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server; +namespace Mcp\Server\Handler; use Mcp\Exception\ExceptionInterface; use Mcp\Schema\JsonRpc\Error; diff --git a/src/Server/NotificationHandler/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php similarity index 91% rename from src/Server/NotificationHandler/InitializedHandler.php rename to src/Server/Handler/Notification/InitializedHandler.php index 55af7759..01881a13 100644 --- a/src/Server/NotificationHandler/InitializedHandler.php +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\NotificationHandler; +namespace Mcp\Server\Handler\Notification; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php similarity index 95% rename from src/Server/RequestHandler/CallToolHandler.php rename to src/Server/Handler/Request/CallToolHandler.php index fd6bddae..9e83037e 100644 --- a/src/Server/RequestHandler/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; @@ -17,7 +17,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php similarity index 94% rename from src/Server/RequestHandler/GetPromptHandler.php rename to src/Server/Handler/Request/GetPromptHandler.php index a6b98909..07a5bebf 100644 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Exception\ExceptionInterface; @@ -17,7 +17,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php similarity index 94% rename from src/Server/RequestHandler/InitializeHandler.php rename to src/Server/Handler/Request/InitializeHandler.php index e27d1fbb..124829f0 100644 --- a/src/Server/RequestHandler/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\HasMethodInterface; @@ -17,7 +17,7 @@ use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php similarity index 94% rename from src/Server/RequestHandler/ListPromptsHandler.php rename to src/Server/Handler/Request/ListPromptsHandler.php index 90459b3a..4a5b0556 100644 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; @@ -17,7 +17,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php similarity index 94% rename from src/Server/RequestHandler/ListResourcesHandler.php rename to src/Server/Handler/Request/ListResourcesHandler.php index f70e63d5..383a5f43 100644 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; @@ -17,7 +17,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php similarity index 94% rename from src/Server/RequestHandler/ListToolsHandler.php rename to src/Server/Handler/Request/ListToolsHandler.php index 757eb742..d34b9a5b 100644 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; @@ -17,7 +17,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/PingHandler.php b/src/Server/Handler/Request/PingHandler.php similarity index 91% rename from src/Server/RequestHandler/PingHandler.php rename to src/Server/Handler/Request/PingHandler.php index 30701332..bc2cae32 100644 --- a/src/Server/RequestHandler/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php similarity index 94% rename from src/Server/RequestHandler/ReadResourceHandler.php rename to src/Server/Handler/Request/ReadResourceHandler.php index 455e23a5..fd208917 100644 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server\RequestHandler; +namespace Mcp\Server\Handler\Request; use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Exception\ExceptionInterface; @@ -18,7 +18,7 @@ use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index de80edee..2da8d215 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -11,7 +11,6 @@ namespace Mcp\Server\Transport; -use Mcp\Server\TransportInterface; use Symfony\Component\Uid\Uuid; /** diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 89132cae..6b4a337a 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,7 +11,6 @@ namespace Mcp\Server\Transport; -use Mcp\Server\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Uid\Uuid; diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 428315b4..7f5035fe 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -12,7 +12,6 @@ namespace Mcp\Server\Transport; use Mcp\Schema\JsonRpc\Error; -use Mcp\Server\TransportInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/Server/TransportInterface.php b/src/Server/Transport/TransportInterface.php similarity index 98% rename from src/Server/TransportInterface.php rename to src/Server/Transport/TransportInterface.php index 6e975188..a8040a0f 100644 --- a/src/Server/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Server; +namespace Mcp\Server\Transport; use Symfony\Component\Uid\Uuid; diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php index 2535449a..63c8e0f6 100644 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ b/tests/Unit/JsonRpc/HandlerTest.php @@ -11,10 +11,10 @@ namespace Mcp\Tests\Unit\JsonRpc; -use Mcp\JsonRpc\Handler; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Response; -use Mcp\Server\MethodHandlerInterface; +use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; @@ -56,7 +56,7 @@ public function testHandleMultipleNotifications() $sessionFactory->method('createWithId')->willReturn($session); $sessionStore->method('exists')->willReturn(true); - $jsonRpc = new Handler( + $jsonRpc = new JsonRpcHandler( MessageFactory::make(), $sessionFactory, $sessionStore, @@ -102,7 +102,7 @@ public function testHandleMultipleRequests() $sessionFactory->method('createWithId')->willReturn($session); $sessionStore->method('exists')->willReturn(true); - $jsonRpc = new Handler( + $jsonRpc = new JsonRpcHandler( MessageFactory::make(), $sessionFactory, $sessionStore, diff --git a/tests/Unit/Server/RequestHandler/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php similarity index 99% rename from tests/Unit/Server/RequestHandler/CallToolHandlerTest.php rename to tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index ea0b8c17..da092c7e 100644 --- a/tests/Unit/Server/RequestHandler/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ToolCallException; @@ -19,7 +19,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; -use Mcp\Server\RequestHandler\CallToolHandler; +use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php similarity index 99% rename from tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php rename to tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index e941ef41..9fba1b29 100644 --- a/tests/Unit/Server/RequestHandler/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Exception\PromptGetException; @@ -21,7 +21,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; -use Mcp\Server\RequestHandler\GetPromptHandler; +use Mcp\Server\Handler\Request\GetPromptHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php similarity index 98% rename from tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php rename to tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php index c3079e2d..21a73a9e 100644 --- a/tests/Unit/Server/RequestHandler/ListPromptsHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Prompt; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\RequestHandler\ListPromptsHandler; +use Mcp\Server\Handler\Request\ListPromptsHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use Mcp\Server\Session\SessionInterface; diff --git a/tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php similarity index 98% rename from tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php rename to tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php index f1b6a6ca..232aef41 100644 --- a/tests/Unit/Server/RequestHandler/ListResourcesHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Resource; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\RequestHandler\ListResourcesHandler; +use Mcp\Server\Handler\Request\ListResourcesHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use Mcp\Server\Session\SessionInterface; diff --git a/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php similarity index 99% rename from tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php rename to tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php index d85c2bf7..32f2c35e 100644 --- a/tests/Unit/Server/RequestHandler/ListToolsHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Registry; use Mcp\Exception\InvalidCursorException; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; use Mcp\Schema\Tool; -use Mcp\Server\RequestHandler\ListToolsHandler; +use Mcp\Server\Handler\Request\ListToolsHandler; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use Mcp\Server\Session\SessionInterface; diff --git a/tests/Unit/Server/RequestHandler/PingHandlerTest.php b/tests/Unit/Server/Handler/Request/PingHandlerTest.php similarity index 98% rename from tests/Unit/Server/RequestHandler/PingHandlerTest.php rename to tests/Unit/Server/Handler/Request/PingHandlerTest.php index 6c75e74a..2a33ecd1 100644 --- a/tests/Unit/Server/RequestHandler/PingHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/PingHandlerTest.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\RequestHandler\PingHandler; +use Mcp\Server\Handler\Request\PingHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php similarity index 99% rename from tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php rename to tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index ab6c8fb3..fd67becd 100644 --- a/tests/Unit/Server/RequestHandler/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Unit\Server\RequestHandler; +namespace Mcp\Tests\Unit\Server\Handler\Request; use Mcp\Capability\Resource\ResourceReaderInterface; use Mcp\Exception\ResourceNotFoundException; @@ -20,7 +20,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; -use Mcp\Server\RequestHandler\ReadResourceHandler; +use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 34c04efc..1abba9f6 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -11,8 +11,8 @@ namespace Mcp\Tests\Unit; -use Mcp\JsonRpc\Handler; use Mcp\Server; +use Mcp\Server\Handler\JsonRpcHandler; use Mcp\Server\Transport\InMemoryTransport; use PHPUnit\Framework\TestCase; @@ -20,7 +20,7 @@ class ServerTest extends TestCase { public function testJsonExceptions() { - $handler = $this->getMockBuilder(Handler::class) + $handler = $this->getMockBuilder(JsonRpcHandler::class) ->disableOriginalConstructor() ->onlyMethods(['process']) ->getMock(); From cecfa0c3c62ac1e119e3fcaaa13fc36413eef823 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 4 Oct 2025 19:43:41 +0200 Subject: [PATCH 21/66] Add request handler for resource templates (#89) --- src/Server/Handler/JsonRpcHandler.php | 1 + .../Request/ListResourceTemplatesHandler.php | 53 +++++ tests/Inspector/InspectorSnapshotTestCase.php | 2 +- ...oExampleTest-resources_templates_list.json | 10 + ...rExampleTest-resources_templates_list.json | 3 + .../ListResourceTemplatesHandlerTest.php | 217 ++++++++++++++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/Server/Handler/Request/ListResourceTemplatesHandler.php create mode 100644 tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json create mode 100644 tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json create mode 100644 tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index a29dc998..1ee39a22 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -86,6 +86,7 @@ public static function make( new Handler\Request\GetPromptHandler($promptGetter), new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit), new Handler\Request\ReadResourceHandler($resourceReader), + new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit), new Handler\Request\CallToolHandler($toolCaller, $logger), new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit), ], diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php new file mode 100644 index 00000000..eadd3427 --- /dev/null +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -0,0 +1,53 @@ + + */ +final class ListResourceTemplatesHandler implements MethodHandlerInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof ListResourceTemplatesRequest; + } + + /** + * @throws InvalidCursorException + */ + public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response + { + \assert($message instanceof ListResourceTemplatesRequest); + + $page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor); + + return new Response( + $message->getId(), + new ListResourceTemplatesResult($page->references, $page->nextCursor), + ); + } +} diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index bca8179f..e5767952 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -54,7 +54,7 @@ protected static function provideListMethods(): array return [ 'Prompt Listing' => ['method' => 'prompts/list'], 'Resource Listing' => ['method' => 'resources/list'], - // 'Resource Template Listing' => ['method' => 'resources/templates/list'], + 'Resource Template Listing' => ['method' => 'resources/templates/list'], 'Tool Listing' => ['method' => 'tools/list'], ]; } diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json b/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json new file mode 100644 index 00000000..65883802 --- /dev/null +++ b/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json @@ -0,0 +1,10 @@ +{ + "resourceTemplates": [ + { + "name": "get_item_details", + "uriTemplate": "item://{itemId}/details", + "description": "A manually registered resource template.", + "mimeType": "application/json" + } + ] +} diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php new file mode 100644 index 00000000..9d7c2ecb --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ListResourceTemplatesHandlerTest.php @@ -0,0 +1,217 @@ +registry = new Registry(); + $this->handler = new ListResourceTemplatesHandler($this->registry, pageSize: 3); + $this->session = new Session(new InMemorySessionStore()); + } + + public function testReturnsFirstPageWhenNoCursorProvided(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(3, $result->resourceTemplates); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_0', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_1', $result->resourceTemplates[1]->uriTemplate); + $this->assertEquals('resource://{test}/resource_2', $result->resourceTemplates[2]->uriTemplate); + } + + public function testReturnsSecondPageWithCursor(): void + { + // Arrange + $this->addResourcesToRegistry(10); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourceTemplatesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(3, $result->resourceTemplates); + $this->assertNotNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); + $this->assertEquals('resource://{test}/resource_5', $result->resourceTemplates[2]->uriTemplate); + } + + public function testReturnsLastPageWithNullCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $firstPageRequest = $this->createListResourcesRequest(); + $firstPageResponse = $this->handler->handle($firstPageRequest, $this->session); + + /** @var ListResourceTemplatesResult $firstPageResult */ + $firstPageResult = $firstPageResponse->result; + $secondPageRequest = $this->createListResourcesRequest(cursor: $firstPageResult->nextCursor); + + // Act + $response = $this->handler->handle($secondPageRequest, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(2, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + + $this->assertEquals('resource://{test}/resource_3', $result->resourceTemplates[0]->uriTemplate); + $this->assertEquals('resource://{test}/resource_4', $result->resourceTemplates[1]->uriTemplate); + } + + public function testHandlesEmptyRegistry(): void + { + // Arrange + $request = $this->createListResourcesRequest(); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(0, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + } + + public function testThrowsExceptionForInvalidCursor(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $request = $this->createListResourcesRequest(cursor: 'invalid-cursor'); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + public function testThrowsExceptionForCursorBeyondBounds(): void + { + // Arrange + $this->addResourcesToRegistry(5); + $outOfBoundsCursor = base64_encode('100'); + $request = $this->createListResourcesRequest(cursor: $outOfBoundsCursor); + + // Assert + $this->expectException(InvalidCursorException::class); + + // Act + $this->handler->handle($request, $this->session); + } + + public function testHandlesCursorAtExactBoundary(): void + { + // Arrange + $this->addResourcesToRegistry(6); + $exactBoundaryCursor = base64_encode('6'); + $request = $this->createListResourcesRequest(cursor: $exactBoundaryCursor); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result */ + $result = $response->result; + $this->assertInstanceOf(ListResourceTemplatesResult::class, $result); + $this->assertCount(0, $result->resourceTemplates); + $this->assertNull($result->nextCursor); + } + + public function testMaintainsStableCursorsAcrossCalls(): void + { + // Arrange + $this->addResourcesToRegistry(10); + + // Act + $request = $this->createListResourcesRequest(); + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + /** @var ListResourceTemplatesResult $result1 */ + $result1 = $response1->result; + /** @var ListResourceTemplatesResult $result2 */ + $result2 = $response2->result; + $this->assertEquals($result1->nextCursor, $result2->nextCursor); + $this->assertEquals($result1->resourceTemplates, $result2->resourceTemplates); + } + + private function addResourcesToRegistry(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $resourceTemplate = new ResourceTemplate( + uriTemplate: "resource://{test}/resource_$i", + name: "resource_$i", + description: "Test resource $i" + ); + // Use a simple callable as handler + $this->registry->registerResourceTemplate($resourceTemplate, fn () => null); + } + } + + private function createListResourcesRequest(?string $cursor = null): ListResourceTemplatesRequest + { + $data = [ + 'jsonrpc' => '2.0', + 'id' => 'test-request-id', + 'method' => 'resources/list', + ]; + + if (null !== $cursor) { + $data['params'] = ['cursor' => $cursor]; + } + + return ListResourceTemplatesRequest::fromArray($data); + } +} From 345b94dfbbf124daed3f98e52e9c02c09af023e0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 4 Oct 2025 19:45:40 +0200 Subject: [PATCH 22/66] Rename example folders (#87) --- composer.json | 18 +++++++++--------- .../DiscoveredElements.php | 2 +- .../ManualHandlers.php | 2 +- .../server.php | 2 +- .../McpEventScheduler.php | 6 +++--- .../Model/EventPriority.php | 2 +- .../Model/EventType.php | 2 +- .../server.php | 0 .../McpElements.php | 4 ++-- .../UserIdCompletionProvider.php | 2 +- .../server.php | 0 .../SchemaShowcaseElements.php | 2 +- .../server.php | 0 .../CachedCalculatorElements.php | 2 +- .../server.php | 0 .../McpTaskHandlers.php | 2 +- .../Service/InMemoryTaskRepository.php | 0 .../Service/StatsServiceInterface.php | 0 .../Service/SystemStatsService.php | 0 .../Service/TaskRepositoryInterface.php | 0 .../server.php | 0 .../McpElements.php | 2 +- .../server.php | 0 .../EnvToolHandler.php | 0 .../server.php | 0 .../SimpleHandlers.php | 2 +- .../server.php | 2 +- phpstan-baseline.neon | 8 ++++---- tests/Inspector/ManualStdioExampleTest.php | 2 +- tests/Inspector/StdioCalculatorExampleTest.php | 2 +- 30 files changed, 32 insertions(+), 32 deletions(-) rename examples/{04-combined-registration-http => http-combined-registration}/DiscoveredElements.php (95%) rename examples/{04-combined-registration-http => http-combined-registration}/ManualHandlers.php (95%) rename examples/{04-combined-registration-http => http-combined-registration}/server.php (96%) rename examples/{07-complex-tool-schema-http => http-complex-tool-schema}/McpEventScheduler.php (93%) rename examples/{07-complex-tool-schema-http => http-complex-tool-schema}/Model/EventPriority.php (86%) rename examples/{07-complex-tool-schema-http => http-complex-tool-schema}/Model/EventType.php (88%) rename examples/{07-complex-tool-schema-http => http-complex-tool-schema}/server.php (100%) rename examples/{02-discovery-http-userprofile => http-discovery-userprofile}/McpElements.php (97%) rename examples/{02-discovery-http-userprofile => http-discovery-userprofile}/UserIdCompletionProvider.php (93%) rename examples/{02-discovery-http-userprofile => http-discovery-userprofile}/server.php (100%) rename examples/{08-schema-showcase-streamable => http-schema-showcase}/SchemaShowcaseElements.php (99%) rename examples/{08-schema-showcase-streamable => http-schema-showcase}/server.php (100%) rename examples/{09-cached-discovery-stdio => stdio-cached-discovery}/CachedCalculatorElements.php (96%) rename examples/{09-cached-discovery-stdio => stdio-cached-discovery}/server.php (100%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/McpTaskHandlers.php (98%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/Service/InMemoryTaskRepository.php (100%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/Service/StatsServiceInterface.php (100%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/Service/SystemStatsService.php (100%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/Service/TaskRepositoryInterface.php (100%) rename examples/{06-custom-dependencies-stdio => stdio-custom-dependencies}/server.php (100%) rename examples/{01-discovery-stdio-calculator => stdio-discovery-calculator}/McpElements.php (99%) rename examples/{01-discovery-stdio-calculator => stdio-discovery-calculator}/server.php (100%) rename examples/{05-stdio-env-variables => stdio-env-variables}/EnvToolHandler.php (100%) rename examples/{05-stdio-env-variables => stdio-env-variables}/server.php (100%) rename examples/{03-manual-registration-stdio => stdio-explicit-registration}/SimpleHandlers.php (97%) rename examples/{03-manual-registration-stdio => stdio-explicit-registration}/server.php (95%) diff --git a/composer.json b/composer.json index 4dbf09e3..00188f46 100644 --- a/composer.json +++ b/composer.json @@ -51,15 +51,15 @@ }, "autoload-dev": { "psr-4": { - "Mcp\\Example\\StdioCalculatorExample\\": "examples/01-discovery-stdio-calculator/", - "Mcp\\Example\\HttpUserProfileExample\\": "examples/02-discovery-http-userprofile/", - "Mcp\\Example\\ManualStdioExample\\": "examples/03-manual-registration-stdio/", - "Mcp\\Example\\CombinedHttpExample\\": "examples/04-combined-registration-http/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/05-stdio-env-variables/", - "Mcp\\Example\\DependenciesStdioExample\\": "examples/06-custom-dependencies-stdio/", - "Mcp\\Example\\ComplexSchemaHttpExample\\": "examples/07-complex-tool-schema-http/", - "Mcp\\Example\\SchemaShowcaseExample\\": "examples/08-schema-showcase-streamable/", - "Mcp\\Example\\CachedDiscoveryExample\\": "examples/09-cached-discovery-stdio/", + "Mcp\\Example\\HttpCombinedRegistration\\": "examples/http-combined-registration/", + "Mcp\\Example\\HttpComplexToolSchema\\": "examples/http-complex-tool-schema/", + "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", + "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", + "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", + "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", + "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", + "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", + "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", "Mcp\\Tests\\": "tests/" } }, diff --git a/examples/04-combined-registration-http/DiscoveredElements.php b/examples/http-combined-registration/DiscoveredElements.php similarity index 95% rename from examples/04-combined-registration-http/DiscoveredElements.php rename to examples/http-combined-registration/DiscoveredElements.php index 3c3edf81..7d030679 100644 --- a/examples/04-combined-registration-http/DiscoveredElements.php +++ b/examples/http-combined-registration/DiscoveredElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedHttpExample; +namespace Mcp\Example\HttpCombinedRegistration; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/04-combined-registration-http/ManualHandlers.php b/examples/http-combined-registration/ManualHandlers.php similarity index 95% rename from examples/04-combined-registration-http/ManualHandlers.php rename to examples/http-combined-registration/ManualHandlers.php index fef7216a..21f86e9d 100644 --- a/examples/04-combined-registration-http/ManualHandlers.php +++ b/examples/http-combined-registration/ManualHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedHttpExample; +namespace Mcp\Example\HttpCombinedRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/04-combined-registration-http/server.php b/examples/http-combined-registration/server.php similarity index 96% rename from examples/04-combined-registration-http/server.php rename to examples/http-combined-registration/server.php index b78c680c..6eefcfd3 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/http-combined-registration/server.php @@ -14,7 +14,7 @@ chdir(__DIR__); use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Example\CombinedHttpExample\ManualHandlers; +use Mcp\Example\HttpCombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; diff --git a/examples/07-complex-tool-schema-http/McpEventScheduler.php b/examples/http-complex-tool-schema/McpEventScheduler.php similarity index 93% rename from examples/07-complex-tool-schema-http/McpEventScheduler.php rename to examples/http-complex-tool-schema/McpEventScheduler.php index a6d9db92..a9b3edc7 100644 --- a/examples/07-complex-tool-schema-http/McpEventScheduler.php +++ b/examples/http-complex-tool-schema/McpEventScheduler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexSchemaHttpExample; +namespace Mcp\Example\HttpComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\ComplexSchemaHttpExample\Model\EventPriority; -use Mcp\Example\ComplexSchemaHttpExample\Model\EventType; +use Mcp\Example\HttpComplexToolSchema\Model\EventPriority; +use Mcp\Example\HttpComplexToolSchema\Model\EventType; use Psr\Log\LoggerInterface; final class McpEventScheduler diff --git a/examples/07-complex-tool-schema-http/Model/EventPriority.php b/examples/http-complex-tool-schema/Model/EventPriority.php similarity index 86% rename from examples/07-complex-tool-schema-http/Model/EventPriority.php rename to examples/http-complex-tool-schema/Model/EventPriority.php index 1ec29543..1654be0e 100644 --- a/examples/07-complex-tool-schema-http/Model/EventPriority.php +++ b/examples/http-complex-tool-schema/Model/EventPriority.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexSchemaHttpExample\Model; +namespace Mcp\Example\HttpComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/07-complex-tool-schema-http/Model/EventType.php b/examples/http-complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/07-complex-tool-schema-http/Model/EventType.php rename to examples/http-complex-tool-schema/Model/EventType.php index 2965c634..5711662d 100644 --- a/examples/07-complex-tool-schema-http/Model/EventType.php +++ b/examples/http-complex-tool-schema/Model/EventType.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexSchemaHttpExample\Model; +namespace Mcp\Example\HttpComplexToolSchema\Model; enum EventType: string { diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/http-complex-tool-schema/server.php similarity index 100% rename from examples/07-complex-tool-schema-http/server.php rename to examples/http-complex-tool-schema/server.php diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/http-discovery-userprofile/McpElements.php similarity index 97% rename from examples/02-discovery-http-userprofile/McpElements.php rename to examples/http-discovery-userprofile/McpElements.php index 815d3d54..7120a5ff 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/http-discovery-userprofile/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpUserProfileExample; +namespace Mcp\Example\HttpDiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; @@ -37,7 +37,7 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger, ) { - $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); + $this->logger->debug('HttpDiscoveryUserProfile McpElements instantiated.'); } /** diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/http-discovery-userprofile/UserIdCompletionProvider.php similarity index 93% rename from examples/02-discovery-http-userprofile/UserIdCompletionProvider.php rename to examples/http-discovery-userprofile/UserIdCompletionProvider.php index 2b1d0555..bc4022e4 100644 --- a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php +++ b/examples/http-discovery-userprofile/UserIdCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpUserProfileExample; +namespace Mcp\Example\HttpDiscoveryUserProfile; use Mcp\Capability\Prompt\Completion\ProviderInterface; diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/http-discovery-userprofile/server.php similarity index 100% rename from examples/02-discovery-http-userprofile/server.php rename to examples/http-discovery-userprofile/server.php diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/http-schema-showcase/SchemaShowcaseElements.php similarity index 99% rename from examples/08-schema-showcase-streamable/SchemaShowcaseElements.php rename to examples/http-schema-showcase/SchemaShowcaseElements.php index ea11b687..d19bfa1f 100644 --- a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php +++ b/examples/http-schema-showcase/SchemaShowcaseElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\SchemaShowcaseExample; +namespace Mcp\Example\HttpSchemaShowcase; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/http-schema-showcase/server.php similarity index 100% rename from examples/08-schema-showcase-streamable/server.php rename to examples/http-schema-showcase/server.php diff --git a/examples/09-cached-discovery-stdio/CachedCalculatorElements.php b/examples/stdio-cached-discovery/CachedCalculatorElements.php similarity index 96% rename from examples/09-cached-discovery-stdio/CachedCalculatorElements.php rename to examples/stdio-cached-discovery/CachedCalculatorElements.php index 03930fea..2d5249df 100644 --- a/examples/09-cached-discovery-stdio/CachedCalculatorElements.php +++ b/examples/stdio-cached-discovery/CachedCalculatorElements.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CachedDiscoveryExample; +namespace Mcp\Example\StdioCachedDiscovery; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/09-cached-discovery-stdio/server.php b/examples/stdio-cached-discovery/server.php similarity index 100% rename from examples/09-cached-discovery-stdio/server.php rename to examples/stdio-cached-discovery/server.php diff --git a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php b/examples/stdio-custom-dependencies/McpTaskHandlers.php similarity index 98% rename from examples/06-custom-dependencies-stdio/McpTaskHandlers.php rename to examples/stdio-custom-dependencies/McpTaskHandlers.php index bf5ce556..262d1a86 100644 --- a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php +++ b/examples/stdio-custom-dependencies/McpTaskHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample; +namespace Mcp\Example\StdioCustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php b/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php similarity index 100% rename from examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php rename to examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php diff --git a/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php b/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php similarity index 100% rename from examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php rename to examples/stdio-custom-dependencies/Service/StatsServiceInterface.php diff --git a/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php b/examples/stdio-custom-dependencies/Service/SystemStatsService.php similarity index 100% rename from examples/06-custom-dependencies-stdio/Service/SystemStatsService.php rename to examples/stdio-custom-dependencies/Service/SystemStatsService.php diff --git a/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php b/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php similarity index 100% rename from examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php rename to examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/stdio-custom-dependencies/server.php similarity index 100% rename from examples/06-custom-dependencies-stdio/server.php rename to examples/stdio-custom-dependencies/server.php diff --git a/examples/01-discovery-stdio-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php similarity index 99% rename from examples/01-discovery-stdio-calculator/McpElements.php rename to examples/stdio-discovery-calculator/McpElements.php index cde2ecc4..71aea372 100644 --- a/examples/01-discovery-stdio-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCalculatorExample; +namespace Mcp\Example\StdioDiscoveryCalculator; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/stdio-discovery-calculator/server.php similarity index 100% rename from examples/01-discovery-stdio-calculator/server.php rename to examples/stdio-discovery-calculator/server.php diff --git a/examples/05-stdio-env-variables/EnvToolHandler.php b/examples/stdio-env-variables/EnvToolHandler.php similarity index 100% rename from examples/05-stdio-env-variables/EnvToolHandler.php rename to examples/stdio-env-variables/EnvToolHandler.php diff --git a/examples/05-stdio-env-variables/server.php b/examples/stdio-env-variables/server.php similarity index 100% rename from examples/05-stdio-env-variables/server.php rename to examples/stdio-env-variables/server.php diff --git a/examples/03-manual-registration-stdio/SimpleHandlers.php b/examples/stdio-explicit-registration/SimpleHandlers.php similarity index 97% rename from examples/03-manual-registration-stdio/SimpleHandlers.php rename to examples/stdio-explicit-registration/SimpleHandlers.php index e581c406..0a119e77 100644 --- a/examples/03-manual-registration-stdio/SimpleHandlers.php +++ b/examples/stdio-explicit-registration/SimpleHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ManualStdioExample; +namespace Mcp\Example\StdioExplicitRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/03-manual-registration-stdio/server.php b/examples/stdio-explicit-registration/server.php similarity index 95% rename from examples/03-manual-registration-stdio/server.php rename to examples/stdio-explicit-registration/server.php index 8b717658..f225f989 100644 --- a/examples/03-manual-registration-stdio/server.php +++ b/examples/stdio-explicit-registration/server.php @@ -13,7 +13,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\ManualStdioExample\SimpleHandlers; +use Mcp\Example\StdioExplicitRegistration\SimpleHandlers; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c1e53ebe..9520acdc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,16 +1,16 @@ parameters: ignoreErrors: - - message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpUserProfileExample\\McpServerException\.$#' + message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException\.$#' identifier: class.notFound count: 2 - path: examples/02-discovery-http-userprofile/McpElements.php + path: examples/http-discovery-userprofile/McpElements.php - - message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpUserProfileExample\\McpServerException is not subtype of Throwable$#' + message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException is not subtype of Throwable$#' identifier: throws.notThrowable count: 2 - path: examples/02-discovery-http-userprofile/McpElements.php + path: examples/http-discovery-userprofile/McpElements.php - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' diff --git a/tests/Inspector/ManualStdioExampleTest.php b/tests/Inspector/ManualStdioExampleTest.php index affe4462..582de0dc 100644 --- a/tests/Inspector/ManualStdioExampleTest.php +++ b/tests/Inspector/ManualStdioExampleTest.php @@ -24,6 +24,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 2).'/examples/03-manual-registration-stdio/server.php'; + return \dirname(__DIR__, 2).'/examples/stdio-explicit-registration/server.php'; } } diff --git a/tests/Inspector/StdioCalculatorExampleTest.php b/tests/Inspector/StdioCalculatorExampleTest.php index 237ef648..b1b1bc5e 100644 --- a/tests/Inspector/StdioCalculatorExampleTest.php +++ b/tests/Inspector/StdioCalculatorExampleTest.php @@ -22,6 +22,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 2).'/examples/01-discovery-stdio-calculator/server.php'; + return \dirname(__DIR__, 2).'/examples/stdio-discovery-calculator/server.php'; } } From f0807f149e93514a65dd03e7e934a0686fb9f421 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 7 Oct 2025 00:42:26 +0200 Subject: [PATCH 23/66] Avoid broken inspector cli version by pinning (#98) --- tests/Inspector/InspectorSnapshotTestCase.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index e5767952..71a065a6 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -17,12 +17,20 @@ abstract class InspectorSnapshotTestCase extends TestCase { + private const INSPECTOR_VERSION = '0.16.8'; + #[DataProvider('provideMethods')] public function testResourcesListOutputMatchesSnapshot(string $method): void { - $process = Process::fromShellCommandline( - \sprintf('npx @modelcontextprotocol/inspector --cli php %s --method %s', $this->getServerScript(), $method) - )->mustRun(); + $process = (new Process([ + 'npx', + \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION), + '--cli', + 'php', + $this->getServerScript(), + '--method', + $method, + ]))->mustRun(); $output = $process->getOutput(); $snapshotFile = $this->getSnapshotFilePath($method); From 4b91567dbd073764ff0801b92b8486f3aa5459ad Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 7 Oct 2025 10:34:50 +0100 Subject: [PATCH 24/66] feat: add completion/complete handler, centralize provider logic, and move completion capability (#97) --- .../UserIdCompletionProvider.php | 2 +- phpstan-baseline.neon | 6 -- .../Attribute/CompletionProvider.php | 2 +- src/Capability/Completion/Completer.php | 75 +++++++++++++++++++ .../Completion/CompleterInterface.php | 25 +++++++ .../Completion/EnumCompletionProvider.php | 2 +- .../Completion/ListCompletionProvider.php | 2 +- .../Completion/ProviderInterface.php | 2 +- src/Capability/Discovery/Discoverer.php | 6 +- src/Capability/Registry/PromptReference.php | 29 ------- .../Registry/ResourceTemplateReference.php | 31 +------- src/Server/Builder.php | 4 +- src/Server/Handler/JsonRpcHandler.php | 2 + .../Request/CompletionCompleteHandler.php | 52 +++++++++++++ .../Attribute/CompletionProviderFixture.php | 2 +- .../Capability/Discovery/DiscoveryTest.php | 4 +- .../Completion/EnumCompletionProviderTest.php | 2 +- .../Completion/ListCompletionProviderTest.php | 2 +- .../Capability/Prompt/PromptGetterTest.php | 2 +- .../Unit/Capability/Registry/RegistryTest.php | 2 +- 20 files changed, 173 insertions(+), 81 deletions(-) create mode 100644 src/Capability/Completion/Completer.php create mode 100644 src/Capability/Completion/CompleterInterface.php rename src/Capability/{Prompt => }/Completion/EnumCompletionProvider.php (96%) rename src/Capability/{Prompt => }/Completion/ListCompletionProvider.php (94%) rename src/Capability/{Prompt => }/Completion/ProviderInterface.php (93%) create mode 100644 src/Server/Handler/Request/CompletionCompleteHandler.php diff --git a/examples/http-discovery-userprofile/UserIdCompletionProvider.php b/examples/http-discovery-userprofile/UserIdCompletionProvider.php index bc4022e4..37b1c5b7 100644 --- a/examples/http-discovery-userprofile/UserIdCompletionProvider.php +++ b/examples/http-discovery-userprofile/UserIdCompletionProvider.php @@ -11,7 +11,7 @@ namespace Mcp\Example\HttpDiscoveryUserProfile; -use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Completion\ProviderInterface; final class UserIdCompletionProvider implements ProviderInterface { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9520acdc..7c7caee0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,12 +12,6 @@ parameters: count: 2 path: examples/http-discovery-userprofile/McpElements.php - - - message: '#^Call to an undefined method Mcp\\Capability\\Registry\\ResourceTemplateReference\:\:handle\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Capability/Registry/ResourceTemplateReference.php - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' identifier: return.phpDocType diff --git a/src/Capability/Attribute/CompletionProvider.php b/src/Capability/Attribute/CompletionProvider.php index 2ef139b7..9e8dc802 100644 --- a/src/Capability/Attribute/CompletionProvider.php +++ b/src/Capability/Attribute/CompletionProvider.php @@ -11,7 +11,7 @@ namespace Mcp\Capability\Attribute; -use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Exception\InvalidArgumentException; /** diff --git a/src/Capability/Completion/Completer.php b/src/Capability/Completion/Completer.php new file mode 100644 index 00000000..eaffaee1 --- /dev/null +++ b/src/Capability/Completion/Completer.php @@ -0,0 +1,75 @@ + + */ +final class Completer implements CompleterInterface +{ + public function __construct( + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ?ContainerInterface $container = null, + ) { + } + + public function complete(CompletionCompleteRequest $request): CompletionCompleteResult + { + $argumentName = $request->argument['name'] ?? ''; + $currentValue = $request->argument['value'] ?? ''; + + $reference = match (true) { + 'ref/prompt' === $request->ref->type => $this->referenceProvider->getPrompt($request->ref->name), + 'ref/resource' === $request->ref->type => $this->referenceProvider->getResourceTemplate($request->ref->uri), + default => null, + }; + + if (null === $reference) { + return new CompletionCompleteResult([]); + } + + $providerClassOrInstance = $reference->completionProviders[$argumentName] ?? null; + if (null === $providerClassOrInstance) { + return new CompletionCompleteResult([]); + } + + if (\is_string($providerClassOrInstance)) { + if (!class_exists($providerClassOrInstance)) { + throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance)); + } + + $provider = $this->container?->has($providerClassOrInstance) + ? $this->container->get($providerClassOrInstance) + : new $providerClassOrInstance(); + } else { + $provider = $providerClassOrInstance; + } + + if (!$provider instanceof ProviderInterface) { + throw new RuntimeException('Completion provider must implement ProviderInterface.'); + } + + $completions = $provider->getCompletions($currentValue); + + $total = \count($completions); + $hasMore = $total > 100; + $pagedCompletions = \array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); + } +} diff --git a/src/Capability/Completion/CompleterInterface.php b/src/Capability/Completion/CompleterInterface.php new file mode 100644 index 00000000..9f9f871c --- /dev/null +++ b/src/Capability/Completion/CompleterInterface.php @@ -0,0 +1,25 @@ + + */ +interface CompleterInterface +{ + public function complete(CompletionCompleteRequest $request): CompletionCompleteResult; +} diff --git a/src/Capability/Prompt/Completion/EnumCompletionProvider.php b/src/Capability/Completion/EnumCompletionProvider.php similarity index 96% rename from src/Capability/Prompt/Completion/EnumCompletionProvider.php rename to src/Capability/Completion/EnumCompletionProvider.php index dd09662e..4c2a014f 100644 --- a/src/Capability/Prompt/Completion/EnumCompletionProvider.php +++ b/src/Capability/Completion/EnumCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Prompt\Completion; +namespace Mcp\Capability\Completion; use Mcp\Exception\InvalidArgumentException; diff --git a/src/Capability/Prompt/Completion/ListCompletionProvider.php b/src/Capability/Completion/ListCompletionProvider.php similarity index 94% rename from src/Capability/Prompt/Completion/ListCompletionProvider.php rename to src/Capability/Completion/ListCompletionProvider.php index 73a5dd4c..05e7811c 100644 --- a/src/Capability/Prompt/Completion/ListCompletionProvider.php +++ b/src/Capability/Completion/ListCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Prompt\Completion; +namespace Mcp\Capability\Completion; /** * @author Kyrian Obikwelu diff --git a/src/Capability/Prompt/Completion/ProviderInterface.php b/src/Capability/Completion/ProviderInterface.php similarity index 93% rename from src/Capability/Prompt/Completion/ProviderInterface.php rename to src/Capability/Completion/ProviderInterface.php index 04ec23b8..84f3f234 100644 --- a/src/Capability/Prompt/Completion/ProviderInterface.php +++ b/src/Capability/Completion/ProviderInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Prompt\Completion; +namespace Mcp\Capability\Completion; /** * @author Kyrian Obikwelu diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index a75705f0..83dafa09 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -16,9 +16,9 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; -use Mcp\Capability\Prompt\Completion\ListCompletionProvider; -use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\ListCompletionProvider; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Registry\ResourceReference; diff --git a/src/Capability/Registry/PromptReference.php b/src/Capability/Registry/PromptReference.php index 50b2dd55..cff89241 100644 --- a/src/Capability/Registry/PromptReference.php +++ b/src/Capability/Registry/PromptReference.php @@ -22,8 +22,6 @@ use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Enum\Role; use Mcp\Schema\Prompt; -use Mcp\Schema\Result\CompletionCompleteResult; -use Psr\Container\ContainerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -45,33 +43,6 @@ public function __construct( parent::__construct($handler, $isManual); } - public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult - { - $providerClassOrInstance = $this->completionProviders[$argument] ?? null; - if (null === $providerClassOrInstance) { - return new CompletionCompleteResult([]); - } - - if (\is_string($providerClassOrInstance)) { - if (!class_exists($providerClassOrInstance)) { - throw new RuntimeException("Completion provider class '{$providerClassOrInstance}' does not exist."); - } - - $provider = $container->get($providerClassOrInstance); - } else { - $provider = $providerClassOrInstance; - } - - $completions = $provider->getCompletions($value); - - $total = \count($completions); - $hasMore = $total > 100; - - $pagedCompletions = \array_slice($completions, 0, 100); - - return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); - } - /** * Formats the raw result of a prompt generator into an array of MCP PromptMessages. * diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 322e6a57..c6f7ec46 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\ResourceTemplate; -use Mcp\Schema\Result\CompletionCompleteResult; use Psr\Container\ContainerInterface; /** @@ -64,38 +63,12 @@ public function read(ContainerInterface $container, string $uri): array { $arguments = array_merge($this->uriVariables, ['uri' => $uri]); - $result = $this->handle($container, $arguments); + $referenceHandler = new ReferenceHandler($container); + $result = $referenceHandler->handle($this, $arguments); return $this->formatResult($result, $uri, $this->resourceTemplate->mimeType); } - public function complete(ContainerInterface $container, string $argument, string $value): CompletionCompleteResult - { - $providerClassOrInstance = $this->completionProviders[$argument] ?? null; - if (null === $providerClassOrInstance) { - return new CompletionCompleteResult([]); - } - - if (\is_string($providerClassOrInstance)) { - if (!class_exists($providerClassOrInstance)) { - throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance)); - } - - $provider = $container->get($providerClassOrInstance); - } else { - $provider = $providerClassOrInstance; - } - - $completions = $provider->getCompletions($value); - - $total = \count($completions); - $hasMore = $total > 100; - - $pagedCompletions = \array_slice($completions, 0, 100); - - return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); - } - /** * @return array */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 7afd5bd1..73bb53eb 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -12,13 +12,13 @@ namespace Mcp\Server; use Mcp\Capability\Attribute\CompletionProvider; +use Mcp\Capability\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; -use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Prompt\PromptGetter; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index 1ee39a22..e700caf3 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Handler; +use Mcp\Capability\Completion\Completer; use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ReferenceRegistryInterface; @@ -89,6 +90,7 @@ public static function make( new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit), new Handler\Request\CallToolHandler($toolCaller, $logger), new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit), + new Handler\Request\CompletionCompleteHandler(new Completer($referenceProvider)), ], logger: $logger, ); diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php new file mode 100644 index 00000000..ef4b3020 --- /dev/null +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -0,0 +1,52 @@ + + */ +final class CompletionCompleteHandler implements MethodHandlerInterface +{ + public function __construct( + private readonly CompleterInterface $completer, + ) { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof CompletionCompleteRequest; + } + + public function handle(CompletionCompleteRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + { + \assert($message instanceof CompletionCompleteRequest); + + try { + $result = $this->completer->complete($message); + } catch (ExceptionInterface) { + return Error::forInternalError('Error while handling completion request', $message->getId()); + } + + return new Response($message->getId(), $result); + } +} diff --git a/tests/Unit/Capability/Attribute/CompletionProviderFixture.php b/tests/Unit/Capability/Attribute/CompletionProviderFixture.php index 839635ed..6c623cf7 100644 --- a/tests/Unit/Capability/Attribute/CompletionProviderFixture.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderFixture.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Attribute; -use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\Completion\ProviderInterface; class CompletionProviderFixture implements ProviderInterface { diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 54fce076..0767ff12 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -11,9 +11,9 @@ namespace Mcp\Tests\Unit\Capability\Discovery; +use Mcp\Capability\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; -use Mcp\Capability\Prompt\Completion\ListCompletionProvider; use Mcp\Capability\Registry; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ResourceReference; diff --git a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php index 8d81d0e6..6321fdc6 100644 --- a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php +++ b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Prompt\Completion; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Exception\InvalidArgumentException; use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; diff --git a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php index 80c58100..dd3439d8 100644 --- a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php +++ b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Prompt\Completion; -use Mcp\Capability\Prompt\Completion\ListCompletionProvider; +use Mcp\Capability\Completion\ListCompletionProvider; use PHPUnit\Framework\TestCase; class ListCompletionProviderTest extends TestCase diff --git a/tests/Unit/Capability/Prompt/PromptGetterTest.php b/tests/Unit/Capability/Prompt/PromptGetterTest.php index fe68ddcb..874dbe3c 100644 --- a/tests/Unit/Capability/Prompt/PromptGetterTest.php +++ b/tests/Unit/Capability/Prompt/PromptGetterTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Prompt; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Prompt\PromptGetter; use Mcp\Capability\Registry\PromptReference; use Mcp\Capability\Registry\ReferenceHandlerInterface; diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index 91ce0f71..e18f9d1f 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -11,7 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Registry; -use Mcp\Capability\Prompt\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; From d06b57d285f76963af9d0bcf852fb0665b710507 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 7 Oct 2025 23:16:47 +0200 Subject: [PATCH 25/66] Address open PHPStan issues from baseline (#92) --- phpstan-baseline.neon | 77 ------------------- src/Schema/Result/EmptyResult.php | 3 - src/Server/Builder.php | 119 ++++++++++++++++++++---------- 3 files changed, 79 insertions(+), 120 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7c7caee0..4953437d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,92 +12,15 @@ parameters: count: 2 path: examples/http-discovery-userprofile/McpElements.php - - - message: '#^PHPDoc tag @return with type array is incompatible with native type object\.$#' - identifier: return.phpDocType - count: 1 - path: src/Schema/Result/EmptyResult.php - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php - - - message: '#^Method Mcp\\Server\\Builder\:\:addPrompt\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:addResource\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:addResourceTemplate\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:addTool\(\) has parameter \$handler with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:addTool\(\) has parameter \$inputSchema with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:getCompletionProviders\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:setDiscovery\(\) has parameter \$excludeDirs with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Method Mcp\\Server\\Builder\:\:setDiscovery\(\) has parameter \$scanDirs with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - message: '#^Property Mcp\\Server\\Builder\:\:\$instructions is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: src/Server/Builder.php - - - message: '#^Property Mcp\\Server\\Builder\:\:\$prompts type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Property Mcp\\Server\\Builder\:\:\$resourceTemplates type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Property Mcp\\Server\\Builder\:\:\$resources type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php - - - - message: '#^Property Mcp\\Server\\Builder\:\:\$tools type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/Builder.php diff --git a/src/Schema/Result/EmptyResult.php b/src/Schema/Result/EmptyResult.php index 0570b212..26416983 100644 --- a/src/Schema/Result/EmptyResult.php +++ b/src/Schema/Result/EmptyResult.php @@ -32,9 +32,6 @@ public static function fromArray(): self return new self(); } - /** - * @return array{} - */ public function jsonSerialize(): object { return new \stdClass(); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 73bb53eb..f63003da 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -14,6 +14,7 @@ use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; +use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DocBlockParser; @@ -23,6 +24,7 @@ use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; +use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\Resource\ResourceReader; use Mcp\Capability\Resource\ResourceReaderInterface; @@ -50,6 +52,8 @@ use Psr\SimpleCache\CacheInterface; /** + * @phpstan-import-type Handler from ElementReference + * * @author Kyrian Obikwelu */ final class Builder @@ -80,40 +84,48 @@ final class Builder private ?string $instructions = null; - /** @var array< - * array{handler: array|string|\Closure, - * name: string|null, - * description: string|null, - * annotations: ToolAnnotations|null} - * > */ + /** + * @var array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * annotations: ?ToolAnnotations, + * }[] + */ private array $tools = []; - /** @var array< - * array{handler: array|string|\Closure, + /** + * @var array{ + * handler: Handler, * uri: string, - * name: string|null, - * description: string|null, - * mimeType: string|null, + * name: ?string, + * description: ?string, + * mimeType: ?string, * size: int|null, - * annotations: Annotations|null} - * > */ + * annotations: ?Annotations, + * }[] + */ private array $resources = []; - /** @var array< - * array{handler: array|string|\Closure, + /** + * @var array{ + * handler: Handler, * uriTemplate: string, - * name: string|null, - * description: string|null, - * mimeType: string|null, - * annotations: Annotations|null} - * > */ + * name: ?string, + * description: ?string, + * mimeType: ?string, + * annotations: ?Annotations, + * }[] + */ private array $resourceTemplates = []; - /** @var array< - * array{handler: array|string|\Closure, - * name: string|null, - * description: string|null} - * > */ + /** + * @var array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * }[] + */ private array $prompts = []; private ?string $discoveryBasePath = null; @@ -223,6 +235,10 @@ public function setSession( return $this; } + /** + * @param string[] $scanDirs + * @param string[] $excludeDirs + */ public function setDiscovery( string $basePath, array $scanDirs = ['.', 'src'], @@ -239,6 +255,9 @@ public function setDiscovery( /** * Manually registers a tool handler. + * + * @param Handler $handler + * @param array|null $inputSchema */ public function addTool( callable|array|string $handler, @@ -254,9 +273,11 @@ public function addTool( /** * Manually registers a resource handler. + * + * @param Handler $handler */ public function addResource( - callable|array|string $handler, + \Closure|array|string $handler, string $uri, ?string $name = null, ?string $description = null, @@ -271,9 +292,11 @@ public function addResource( /** * Manually registers a resource template handler. + * + * @param Handler $handler */ public function addResourceTemplate( - callable|array|string $handler, + \Closure|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, @@ -294,8 +317,10 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. + * + * @param Handler $handler */ - public function addPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self + public function addPrompt(\Closure|array|string $handler, ?string $name = null, ?string $description = null): self { $this->prompts[] = compact('handler', 'name', 'description'); @@ -387,9 +412,7 @@ private function registerCapabilities( $tool = new Tool($name, $inputSchema, $description, $data['annotations']); $registry->registerTool($tool, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( - $data['handler'], - ) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $this->getHandlerDescription($data['handler']); $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $logger->error( @@ -425,9 +448,7 @@ private function registerCapabilities( $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); $registry->registerResource($resource, $data['handler'], true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( - $data['handler'], - ) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $this->getHandlerDescription($data['handler']); $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $logger->error( @@ -463,9 +484,7 @@ private function registerCapabilities( $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( - $data['handler'], - ) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $this->getHandlerDescription($data['handler']); $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $logger->error( @@ -517,9 +536,7 @@ private function registerCapabilities( $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (\is_array( - $data['handler'], - ) ? implode('::', $data['handler']) : $data['handler']); + $handlerDesc = $this->getHandlerDescription($data['handler']); $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); } catch (\Throwable $e) { $logger->error( @@ -533,6 +550,28 @@ private function registerCapabilities( $logger->debug('Manual element registration complete.'); } + /** + * @param Handler $handler + */ + private function getHandlerDescription(\Closure|array|string $handler): string + { + if ($handler instanceof \Closure) { + return 'Closure'; + } + + if (\is_array($handler)) { + return \sprintf('%s::%s', + \is_object($handler[0]) ? $handler[0]::class : $handler[0], + $handler[1], + ); + } + + return (string) $handler; + } + + /** + * @return array + */ private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array { $completionProviders = []; From cbd1b298e94df69a0c0e15611bd9a11876e881f9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 7 Oct 2025 23:32:51 +0200 Subject: [PATCH 26/66] Fix missing instructions from builder to handler (#90) --- examples/stdio-discovery-calculator/server.php | 1 + phpstan-baseline.neon | 7 ------- src/Server/Builder.php | 7 +++++-- src/Server/Handler/JsonRpcHandler.php | 6 +++--- src/Server/Handler/Request/InitializeHandler.php | 10 +++++++--- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index 1c5be71d..ad5c1cf7 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -20,6 +20,7 @@ $server = Server::builder() ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) ->setLogger(logger()) ->setDiscovery(__DIR__, ['.']) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4953437d..f78906ae 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -17,10 +17,3 @@ parameters: identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php - - - - message: '#^Property Mcp\\Server\\Builder\:\:\$instructions is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/Server/Builder.php - diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f63003da..82c50f2a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -362,14 +362,17 @@ public function build(): Server jsonRpcHandler: JsonRpcHandler::make( registry: $registry, referenceProvider: $registry, - implementation: $this->serverInfo, + configuration: new Configuration( + $this->serverInfo, + $registry->getCapabilities(), + $this->paginationLimit, $this->instructions, + ), toolCaller: $toolCaller, resourceReader: $resourceReader, promptGetter: $promptGetter, sessionStore: $sessionStore, sessionFactory: $sessionFactory, logger: $logger, - paginationLimit: $this->paginationLimit, ), logger: $logger, ); diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index e700caf3..ab1eb106 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -22,12 +22,12 @@ use Mcp\Exception\InvalidInputMessageException; use Mcp\Exception\NotFoundExceptionInterface; use Mcp\JsonRpc\MessageFactory; -use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; +use Mcp\Server\Configuration; use Mcp\Server\Handler; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; @@ -66,7 +66,7 @@ public function __construct( public static function make( ReferenceRegistryInterface $registry, ReferenceProviderInterface $referenceProvider, - Implementation $implementation, + Configuration $configuration, ToolCallerInterface $toolCaller, ResourceReaderInterface $resourceReader, PromptGetterInterface $promptGetter, @@ -81,7 +81,7 @@ public static function make( sessionStore: $sessionStore, methodHandlers: [ new Notification\InitializedHandler(), - new Handler\Request\InitializeHandler($registry->getCapabilities(), $implementation), + new Handler\Request\InitializeHandler($configuration), new Handler\Request\PingHandler(), new Handler\Request\ListPromptsHandler($referenceProvider, $paginationLimit), new Handler\Request\GetPromptHandler($promptGetter), diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 124829f0..e9d7a751 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -17,6 +17,7 @@ use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; +use Mcp\Server\Configuration; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; @@ -26,8 +27,7 @@ final class InitializeHandler implements MethodHandlerInterface { public function __construct( - public readonly ?ServerCapabilities $capabilities = new ServerCapabilities(), - public readonly ?Implementation $serverInfo = new Implementation(), + public readonly ?Configuration $configuration = null, ) { } @@ -44,7 +44,11 @@ public function handle(InitializeRequest|HasMethodInterface $message, SessionInt return new Response( $message->getId(), - new InitializeResult($this->capabilities, $this->serverInfo), + new InitializeResult( + $this->configuration->capabilities ?? new ServerCapabilities(), + $this->configuration->serverInfo ?? new Implementation(), + $this->configuration?->instructions, + ), ); } } From e94fd8206183490a34fcefe412f40a6bd5168e4f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 7 Oct 2025 23:42:02 +0200 Subject: [PATCH 27/66] docs: add more about building servers and updating front readme (#81) * docs: add more about building servers and updating front readme * Overhaul documentation structure and content * fix: remove redundant emoji on docs * Remove composer install --dev hint * Add linebreak to make it readable in IDE * rebase after example renaming --------- Co-authored-by: Kyrian Obikwelu --- README.md | 238 +++++++++--- docs/discovery-caching.md | 106 ------ docs/examples.md | 272 ++++++++++++++ docs/mcp-elements.md | 744 ++++++++++++++++++++++++++++++++++++++ docs/server-builder.md | 515 ++++++++++++++++++++++++++ docs/transports.md | 350 ++++++++++++++++++ examples/README.md | 11 +- 7 files changed, 2070 insertions(+), 166 deletions(-) delete mode 100644 docs/discovery-caching.md create mode 100644 docs/examples.md create mode 100644 docs/mcp-elements.md create mode 100644 docs/server-builder.md create mode 100644 docs/transports.md diff --git a/README.md b/README.md index 94001f1c..f441b3ca 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,27 @@ # MCP PHP SDK -The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers in PHP. +The official PHP SDK for Model Context Protocol (MCP). It provides a framework-agnostic API for implementing MCP servers +and clients in PHP. > [!IMPORTANT] -> Currently, we are still in the process of merging [Symfony's MCP SDK](https://github.com/symfony/mcp-sdk) and -> [PHP-MCP](https://github.com/php-mcp) components. Not all code paths are fully tested or complete, and this package -> may still contain duplicate functionality or dead code. +> This SDK is currently in active development with ongoing refinement of its architecture and features. While +> functional, the API may experience changes as we work toward stabilization. > -> If you want to help us stabilize the SDK, please see the -> [issue tracker](https://github.com/modelcontextprotocol/php-sdk/issues). +> If you want to help us stabilize the SDK, please see the [issue tracker](https://github.com/modelcontextprotocol/php-sdk/issues). -This project is a collaboration between [the PHP Foundation](https://thephp.foundation/) and the -[Symfony project](https://symfony.com/). It adopts development practices and standards from the Symfony project, -including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the +This project represents a collaboration between [the PHP Foundation](https://thephp.foundation/) and the [Symfony project](https://symfony.com/). It adopts +development practices and standards from the Symfony project, including [Coding Standards](https://symfony.com/doc/current/contributing/code/standards.html) and the [Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). -Until the first major release, this SDK is considered -[experimental](https://symfony.com/doc/current/contributing/code/experimental.html). +Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html). -## 🚧 Roadmap +## Roadmap -Features -- [x] Bring back PHP-MCP examples -- [x] Glue handler, registry and reference handlers -- [x] Revive `ServerBuilder` -- [x] Revive transports - - [x] Streamable Transport https://github.com/modelcontextprotocol/php-sdk/issues/7 - - [ ] ~~Http/SSE-based Transport https://github.com/modelcontextprotocol/php-sdk/issues/8~~ -- [ ] Support pagination -- [ ] Support Schema validation -- [ ] Support multiple versions of the MCP specification https://github.com/modelcontextprotocol/php-sdk/issues/14 -- [ ] (Re-)Implement missing Notification & Request Handlers https://github.com/modelcontextprotocol/php-sdk/issues/9 +**Features** +- [ ] Stabilize server component with all needed handlers and functional tests +- [ ] Extend documentation, including integration guides for popular frameworks +- [ ] Implement Client component +- [ ] Support multiple schema versions ## Installation @@ -38,19 +29,13 @@ Features composer require mcp/sdk ``` -Since this package has no tagged releases yet, it is required to extend your `composer.json`: -```json -"minimum-stability": "dev", -"prefer-stable": true -``` - -## ⚡ Quick Start: Stdio Server with Discovery +## Quick Start -This example demonstrates the most common usage pattern - a `stdio` server using attribute discovery. +This example demonstrates the most common usage pattern - a STDIO server using attribute discovery. -**1. Define Your MCP Elements** +### 1. Define Your MCP Elements -Create `src/CalculatorElements.php`: +Create a class with MCP capabilities using attributes: ```php $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : 'Error: Division by zero', + default => 'Error: Unknown operation' + }; + } + + #[McpResource( + uri: 'config://calculator/settings', + name: 'calculator_config', + mimeType: 'application/json' + )] + public function getSettings(): array + { + return ['precision' => 2, 'allow_negative' => true]; + } } ``` -**2. Create the Server Script** +### 2. Create the Server Script -Create `mcp-server.php`: +Create your MCP server: ```php #!/usr/bin/env php @@ -84,54 +102,166 @@ require_once __DIR__ . '/vendor/autoload.php'; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -Server::builder() - ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') +$server = Server::builder() + ->setServerInfo('Calculator Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport()); + ->build(); + +$transport = new StdioTransport(); +$server->connect($transport); +$transport->listen(); ``` -**3. Configure Your MCP Client** +### 3. Configure Your MCP Client -Add to your client configuration (e.g., `mcp.json`): +Add to your client configuration (e.g., Claude Desktop's `mcp.json`): ```json { "mcpServers": { "php-calculator": { "command": "php", - "args": ["/absolute/path/to/your/mcp-server.php"] + "args": ["/absolute/path/to/your/server.php"] } } } ``` -**4. Test the Server** +### 4. Test Your Server + +```bash +# Test with MCP Inspector +npx @modelcontextprotocol/inspector php /path/to/server.php + +# Your AI assistant can now call: +# - add: Add two integers +# - calculate: Perform arithmetic operations +# - Read config://calculator/settings resource +``` + +## Key Features + +### Attribute-Based Discovery + +Define MCP elements using PHP attributes with automatic discovery: + +```php +// Tool with automatic name and description from method +#[McpTool] +public function generateReport(): string { /* ... */ } + +// Tool with custom name +#[McpTool(name: 'custom_name')] +public function myMethod(): string { /* ... */ } + +// Resource with URI and metadata +#[McpResource(uri: 'config://app/settings', mimeType: 'application/json')] +public function getConfig(): array { /* ... */ } +``` + +### Manual Registration -Your AI assistant can now call: -- `add_numbers` - Add two integers +Register capabilities programmatically: + +```php +$server = Server::builder() + ->addTool([MyClass::class, 'myMethod'], 'tool_name') + ->addResource([MyClass::class, 'getData'], 'data://config') + ->build(); +``` + +### Multiple Transport Options + +**STDIO Transport** (Command-line integration): +```php +$transport = new StdioTransport(); +$server->connect($transport); +$transport->listen(); +``` + +**HTTP Transport** (Web-based communication): +```php +$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); +$server->connect($transport); +$response = $transport->listen(); +// Handle $response in your web application +``` + +### Session Management + +By default, the SDK uses in-memory sessions. You can configure different session stores: + +```php +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\FileSessionStore; + +// Use default in-memory sessions (TTL only) +$server = Server::builder() + ->setSession(ttl: 7200) // 2 hours + ->build(); + +// Use file-based sessions +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// Use in-memory with custom TTL +$server = Server::builder() + ->setSession(new InMemorySessionStore(3600)) + ->build(); +``` + +### Discovery Caching + +Use any PSR-16 cache implementation to cache discovery results and avoid running discovery on every server start: + +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['.', 'src'], // Default: ['.', 'src'] + excludeDirs: ['vendor'], // Default: ['vendor', 'node_modules'] + cache: $cache + ) + ->build(); +``` ## Documentation -- [SDK documentation](doc/index.rst) +**Core Concepts:** +- [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration +- [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage +- [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts + +**Learning:** +- [Examples](docs/examples.md) - Comprehensive example walkthroughs + +**External Resources:** - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [Model Context Protocol specification](https://spec.modelcontextprotocol.io) - [Officially supported servers](https://github.com/modelcontextprotocol/servers) -## Examples of MCP Tools that use this SDK +## PHP Libraries Using the MCP SDK -- https://github.com/pronskiy/mcp +* [pronskiy/mcp](https://github.com/pronskiy/mcp) - Additional DX layer +* [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle) - Symfony integration bundle ## Contributing We are passionate about supporting contributors of all levels of experience and would love to see you get involved in -the project. See the [contributing guide](CONTRIBUTING.md) to get started before you -[report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and -[send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). +the project. See the [contributing guide](CONTRIBUTING.md) to get started before you [report issues](https://github.com/modelcontextprotocol/php-sdk/issues) and [send pull requests](https://github.com/modelcontextprotocol/php-sdk/pulls). ## Credits -The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by [Kyrian Obikwelu](https://github.com/CodeWithKyrian). We are grateful for the work done by Kyrian and other contributors to that repository, which created a solid foundation for this SDK. + +The starting point for this SDK was the [PHP-MCP](https://github.com/php-mcp/server) project, initiated by +[Kyrian Obikwelu](https://github.com/CodeWithKyrian), and the [Symfony AI initiative](https://github.com/symfony/ai). We are grateful for the work +done by both projects and their contributors, which created a solid foundation for this SDK. ## License -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/discovery-caching.md b/docs/discovery-caching.md deleted file mode 100644 index 9f100822..00000000 --- a/docs/discovery-caching.md +++ /dev/null @@ -1,106 +0,0 @@ -# Discovery Caching - -This document explains how to use the discovery caching feature in the PHP MCP SDK to improve performance. - -## Overview - -The discovery caching system caches the results of MCP element discovery to avoid repeated file system scanning and reflection operations. This is particularly useful in: - -- **Development environments** where the server is restarted frequently -- **Production environments** where discovery happens on every request -- **Large codebases** with many MCP elements to discover - -## Usage - -### Basic Setup - -```php -use Mcp\Server; -use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Psr16Cache; - -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) // Enable caching - ->build(); -``` - -### Available Cache Implementations - -The caching system works with any PSR-16 SimpleCache implementation. Popular options include: - -#### Symfony Cache - -```php -use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; -use Symfony\Component\Cache\Psr16Cache; - -// In-memory cache (development) -$cache = new Psr16Cache(new ArrayAdapter()); - -// Filesystem cache (production) -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); -``` - -#### Other PSR-16 Implementations - -```php -use Doctrine\Common\Cache\Psr6\DoctrineProvider; -use Doctrine\Common\Cache\ArrayCache; - -$cache = DoctrineProvider::wrap(new ArrayCache()); -``` - -## Performance Benefits - -- **First run**: Same as without caching -- **Subsequent runs**: 80-95% faster discovery -- **Memory usage**: Slightly higher due to cache storage -- **Cache hit ratio**: 90%+ in typical development scenarios - -## Best Practices - -### Development Environment - -```php -// Use in-memory cache for fast development cycles -$cache = new Psr16Cache(new ArrayAdapter()); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) - ->build(); -``` - -### Production Environment - -```php -// Use persistent cache -$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery', 0, '/var/cache')); - -$server = Server::builder() - ->setDiscovery(__DIR__, ['.'], [], $cache) - ->build(); -``` - -## Cache Invalidation - -The cache automatically invalidates when: - -- Discovery parameters change (base path, directories, exclude patterns) -- Files are modified (detected through file system state) - -For manual invalidation, restart your application or clear the cache directory. - -## Troubleshooting - -### Cache Not Working - -1. Verify PSR-16 SimpleCache implementation is properly installed -2. Check cache permissions (for filesystem caches) -3. Check logs for cache-related warnings - -### Memory Issues - -- Use filesystem cache instead of in-memory cache for large codebases -- Consider using a dedicated cache server (Redis, Memcached) for high-traffic applications diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000..77fdc1f1 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,272 @@ +# Examples + +The MCP PHP SDK includes comprehensive examples demonstrating different patterns and use cases. Each example showcases +specific features and can be run independently to understand how the SDK works. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Running Examples](#running-examples) +- [STDIO Examples](#stdio-examples) +- [HTTP Examples](#http-examples) +- [Advanced Patterns](#advanced-patterns) +- [Testing and Debugging](#testing-and-debugging) + +## Getting Started + +All examples are located in the `examples/` directory and use the SDK dependencies from the root project. Most examples +can be run directly without additional setup. + +### Prerequisites + +```bash +# Install dependencies (in project root) +composer install +``` + +## Running Examples + +### STDIO Examples + +STDIO examples use standard input/output for communication: + +```bash +# Interactive testing with MCP Inspector +npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php + +# Run with debugging enabled +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php + +# Or configure the script path in your MCP client +# Path: php examples/stdio-discovery-calculator/server.php +``` + +### HTTP Examples + +HTTP examples run as web servers: + +```bash +# Start the server +php -S localhost:8000 examples/http-discovery-userprofile/server.php + +# Test with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 + +# Test with curl +curl -X POST http://localhost:8000 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' +``` + +## STDIO Examples + +### Discovery Calculator + +**File**: `examples/stdio-discovery-calculator/` + +**What it demonstrates:** +- Attribute-based discovery using `#[McpTool]` and `#[McpResource]` +- Basic arithmetic operations +- Configuration management through resources +- State management between tool calls + +**Key Features:** +```php +#[McpTool(name: 'calculate')] +public function calculate(float $a, float $b, string $operation): float|string + +#[McpResource( + uri: 'config://calculator/settings', + name: 'calculator_config', + mimeType: 'application/json' +)] +public function getConfiguration(): array +``` + +**Usage:** +```bash +# Interactive testing +npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php + +# Or configure in MCP client: php examples/stdio-discovery-calculator/server.php +``` + +### Explicit Registration + +**File**: `examples/stdio-explicit-registration/` + +**What it demonstrates:** +- Manual registration of tools, resources, and prompts +- Alternative to attribute-based discovery +- Simple handler functions + +**Key Features:** +```php +$server = Server::builder() + ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') + ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version') + ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') +``` + +### Environment Variables + +**File**: `examples/stdio-env-variables/` + +**What it demonstrates:** +- Environment variable integration +- Server configuration from environment +- Environment-based tool behavior + +**Key Features:** +- Reading environment variables within tools +- Conditional behavior based on environment +- Environment validation and defaults + +### Custom Dependencies + +**File**: `examples/stdio-custom-dependencies/` + +**What it demonstrates:** +- Dependency injection with PSR-11 containers +- Service layer architecture +- Repository pattern implementation +- Complex business logic integration + +**Key Features:** +```php +$container->set(TaskRepositoryInterface::class, $taskRepo); +$container->set(StatsServiceInterface::class, $statsService); + +$server = Server::builder() + ->setContainer($container) + ->setDiscovery(__DIR__, ['.']) +``` + +### Cached Discovery + +**File**: `examples/stdio-cached-discovery/` + +**What it demonstrates:** +- Discovery caching for improved performance +- PSR-16 cache integration +- Cache invalidation strategies + +**Key Features:** +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery(__DIR__, ['.'], [], $cache) +``` + +## HTTP Examples + +### Discovery User Profile + +**File**: `examples/http-discovery-userprofile/` + +**What it demonstrates:** +- HTTP transport with StreamableHttpTransport +- Resource templates with URI parameters +- Completion providers for parameter hints +- User profile management system +- Session persistence with FileSessionStore + +**Key Features:** +```php +#[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + mimeType: 'application/json' +)] +public function getUserProfile( + #[CompletionProvider(values: ['101', '102', '103'])] + string $userId +): array + +#[McpPrompt(name: 'generate_bio_prompt')] +public function generateBio(string $userId, string $tone = 'professional'): array +``` + +**Usage:** +```bash +# Start the HTTP server +php -S localhost:8000 examples/http-discovery-userprofile/server.php + +# Test with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 + +# Or configure in MCP client: http://localhost:8000 +``` + +### Combined Registration + +**File**: `examples/http-combined-registration/` + +**What it demonstrates:** +- Mixing attribute discovery with manual registration +- HTTP server with both discovered and manual capabilities +- Flexible registration patterns + +**Key Features:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) // Automatic discovery + ->addTool([ManualHandlers::class, 'manualGreeter']) // Manual registration + ->addResource([ManualHandlers::class, 'getPriorityConfig'], 'config://priority') +``` + +### Complex Tool Schema + +**File**: `examples/http-complex-tool-schema/` + +**What it demonstrates:** +- Advanced JSON schema definitions +- Complex data structures and validation +- Event scheduling and management +- Enum types and nested objects + +**Key Features:** +```php +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'eventType' => ['type' => 'string', 'enum' => ['meeting', 'deadline', 'reminder']], + 'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high', 'urgent']] + ] +])] +public function scheduleEvent(array $eventData): array +``` + +### Schema Showcase + +**File**: `examples/http-schema-showcase/` + +**What it demonstrates:** +- Comprehensive JSON schema features +- Parameter-level schema validation +- String constraints (minLength, maxLength, pattern) +- Numeric constraints (minimum, maximum, multipleOf) +- Array and object validation + +**Key Features:** +```php +#[McpTool] +public function formatText( + #[Schema( + type: 'string', + minLength: 5, + maxLength: 100, + pattern: '^[a-zA-Z0-9\s\.,!?\-]+$' + )] + string $text, + + #[Schema(enum: ['uppercase', 'lowercase', 'title', 'sentence'])] + string $format = 'sentence' +): array +``` diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md new file mode 100644 index 00000000..911b3d52 --- /dev/null +++ b/docs/mcp-elements.md @@ -0,0 +1,744 @@ +# MCP Elements + +MCP elements are the core capabilities of your server: Tools, Resources, Resource Templates, and Prompts. These elements +define what your server can do and how clients can interact with it. The PHP MCP SDK provides both attribute-based +discovery and manual registration methods. + +## Table of Contents + +- [Overview](#overview) +- [Tools](#tools) +- [Resources](#resources) +- [Resource Templates](#resource-templates) +- [Prompts](#prompts) +- [Completion Providers](#completion-providers) +- [Schema Generation and Validation](#schema-generation-and-validation) +- [Discovery vs Manual Registration](#discovery-vs-manual-registration) + +## Overview + +MCP defines four types of capabilities: + +- **Tools**: Functions that can be called by clients to perform actions +- **Resources**: Data sources that clients can read (static URIs) +- **Resource Templates**: URI templates for dynamic resources with variables +- **Prompts**: Template generators for AI prompts + +### Registration Methods + +Each capability can be registered using two methods: + +1. **Attribute-Based Discovery**: Use PHP attributes (`#[McpTool]`, `#[McpResource]`, etc.) on methods or classes. The + server automatically discovers and registers them. + +2. **Manual Registration**: Explicitly register capabilities using `ServerBuilder` methods (`addTool()`, `addResource()`, etc.). + +**Priority**: Manual registrations **always override** discovered elements with the same identifier: +- **Tools**: Same `name` +- **Resources**: Same `uri` +- **Resource Templates**: Same `uriTemplate` +- **Prompts**: Same `name` + +For manual registration details, see [Server Builder Manual Registration](server-builder.md#manual-capability-registration). + +## Tools + +Tools are callable functions that perform actions and return results. + +```php +use Mcp\Capability\Attribute\McpTool; + +class Calculator +{ + /** + * Performs arithmetic operations with validation. + */ + #[McpTool(name: 'calculate')] + public function performCalculation(float $a, float $b, string $operation): float + { + return match($operation) { + 'add' => $a + $b, + 'subtract' => $a - $b, + 'multiply' => $a * $b, + 'divide' => $b != 0 ? $a / $b : throw new \InvalidArgumentException('Division by zero'), + default => throw new \InvalidArgumentException('Invalid operation') + }; + } +} +``` + +### Parameters + +- **`name`** (optional): Tool identifier. Defaults to method name if not provided. +- **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. +- **`annotations`** (optional): `ToolAnnotations` object for additional metadata. + +**Priority for name/description**: Attribute parameters → DocBlock content → Method name + +For tool parameter validation and JSON schema generation, see [Schema Generation and Validation](#schema-generation-and-validation). + +### Tool Return Values + +Tools can return any data type and the SDK will automatically wrap them in appropriate MCP content types. + +#### Automatic Content Wrapping + +```php +// Primitive types → TextContent +public function getString(): string { return "Hello"; } // TextContent +public function getNumber(): int { return 42; } // TextContent +public function getBool(): bool { return true; } // TextContent +public function getArray(): array { return ['key' => 'value']; } // TextContent (JSON) + +// Special cases +public function getNull(): ?string { return null; } // TextContent("(null)") +public function returnVoid(): void { /* no return */ } // Empty content +``` + +#### Explicit Content Types + +For fine control over output formatting: + +```php +use Mcp\Schema\Content\{TextContent, ImageContent, AudioContent, EmbeddedResource}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'file://data.json', 'text' => 'File content'] + ); +} +``` + +#### Multiple Content Items + +Return an array of content items: + +```php +public function getMultipleContent(): array +{ + return [ + new TextContent('Here is the analysis:'), + TextContent::code($code, 'php'), + new TextContent('And here is the summary.') + ]; +} +``` + +#### Error Handling + +Tools can throw exceptions which are automatically converted to proper JSON-RPC error responses: + +```php +#[McpTool] +public function divideNumbers(float $a, float $b): float +{ + if ($b === 0.0) { + throw new \InvalidArgumentException('Division by zero is not allowed'); + } + + return $a / $b; +} + +#[McpTool] +public function processFile(string $filename): string +{ + if (!file_exists($filename)) { + throw new \InvalidArgumentException("File not found: {$filename}"); + } + + return file_get_contents($filename); +} +``` + +The SDK will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand. + +## Resources + +Resources provide access to static data that clients can read. + +```php +use Mcp\Capability\Attribute\McpResource; + +class ConfigProvider +{ + /** + * Provides the current application configuration. + */ + #[McpResource(uri: 'config://app/settings', name: 'app_settings')] + public function getSettings(): array + { + return [ + 'version' => '1.0.0', + 'debug' => false, + 'features' => ['auth', 'logging'] + ]; + } +} +``` + +### Parameters + +- **`uri`** (required): Unique resource identifier. Must comply with [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- **`name`** (optional): Human-readable name. Defaults to method name if not provided. +- **`description`** (optional): Resource description. Defaults to docblock summary if not provided. +- **`mimeType`** (optional): MIME type of the resource content. +- **`size`** (optional): Size in bytes if known. + +**Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). +**Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. + +### Resource Return Values + +Resource handlers can return various data types that are automatically formatted into appropriate MCP resource content types. + +#### Supported Return Types + +```php +// String content - converted to text resource +public function getTextFile(): string +{ + return "File content here"; +} + +// Array content - converted to JSON +public function getConfig(): array +{ + return ['debug' => true, 'version' => '1.0']; +} + +// Stream resource - read and converted to blob +public function getImageStream(): resource +{ + return fopen('image.png', 'r'); +} + +// SplFileInfo - file content with MIME type detection +public function getFileInfo(): \SplFileInfo +{ + return new \SplFileInfo('document.pdf'); +} +``` + +**Explicit resource content types** + +```php +use Mcp\Schema\Content\{TextResourceContents, BlobResourceContents}; + +public function getExplicitText(): TextResourceContents +{ + return new TextResourceContents( + uri: 'config://app/settings', + mimeType: 'application/json', + text: json_encode(['setting' => 'value']) + ); +} + +public function getExplicitBlob(): BlobResourceContents +{ + return new BlobResourceContents( + uri: 'file://image.png', + mimeType: 'image/png', + blob: base64_encode(file_get_contents('image.png')) + ); +} +``` + +**Special Array Formats** + +```php +// Array with 'text' key - used as text content +public function getTextArray(): array +{ + return ['text' => 'Content here', 'mimeType' => 'text/plain']; +} + +// Array with 'blob' key - used as blob content +public function getBlobArray(): array +{ + return ['blob' => base64_encode($data), 'mimeType' => 'image/png']; +} + +// Multiple resource contents +public function getMultipleResources(): array +{ + return [ + new TextResourceContents('file://readme.txt', 'text/plain', 'README content'), + new TextResourceContents('file://config.json', 'application/json', '{"key": "value"}') + ]; +} +``` + +#### Error Handling + +Resource handlers can throw exceptions for error cases: + +```php +#[McpResource(uri: 'file://{path}')] +public function getFile(string $path): string +{ + if (!file_exists($path)) { + throw new \InvalidArgumentException("File not found: {$path}"); + } + + if (!is_readable($path)) { + throw new \RuntimeException("File not readable: {$path}"); + } + + return file_get_contents($path); +} +``` + +## Resource Templates + +Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules +as static resources (URI schemas, return values, MIME types, etc.) but accept variables using [RFC 6570 URI template syntax](https://datatracker.ietf.org/doc/html/rfc6570). + +```php +use Mcp\Capability\Attribute\McpResourceTemplate; + +class UserProvider +{ + /** + * Retrieves user profile information by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile/{section}', + name: 'user_profile', + description: 'User profile data by section', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId, string $section): array + { + return $this->users[$userId][$section] ?? throw new \InvalidArgumentException("Profile section not found"); + } +} +``` + +### Parameters + +- **`uriTemplate`** (required): URI template with `{variables}` using RFC 6570 syntax. Must comply with RFC 3986. +- **`name`** (optional): Human-readable name. Defaults to method name if not provided. +- **`description`** (optional): Template description. Defaults to docblock summary if not provided. +- **`mimeType`** (optional): MIME type of the resource content. +- **`annotations`** (optional): Additional metadata. + +### Variable Rules + +1. **Variable names must match exactly** between URI template and method parameters +2. **Parameter order matters** - variables are passed in the order they appear in the URI template +3. **All variables are required** - no optional parameters supported +4. **Type hints work normally** - parameters can be typed (string, int, etc.) + +**Example mapping**: `user://123/profile/settings` → `getUserProfile("123", "settings")` + +## Prompts + +Prompts generate templates for AI interactions. + +```php +use Mcp\Capability\Attribute\McpPrompt; + +class PromptGenerator +{ + /** + * Generates a code review request prompt. + */ + #[McpPrompt(name: 'code_review'] + public function reviewCode(string $language, string $code, string $focus = 'general'): array + { + return [ + ['role' => 'system', 'content' => 'You are an expert code reviewer.'], + ['role' => 'user', 'content' => "Review this {$language} code focusing on {$focus}:\n\n```{$language}\n{$code}\n```"] + ]; + } +} +``` + +### Parameters + +- **`name`** (optional): Prompt identifier. Defaults to method name if not provided. +- **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. + +### Prompt Return Values + +Prompt handlers must return an array of message structures that are automatically formatted into MCP prompt messages. + +#### Supported Return Formats + +```php +// Array of message objects with role and content +public function basicPrompt(): array +{ + return [ + ['role' => 'assistant', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello, how are you?'] + ]; +} + +// Single message (automatically wrapped in array) +public function singleMessage(): array +{ + return [ + ['role' => 'user', 'content' => 'Write a poem about PHP'] + ]; +} + +// Associative array with user/assistant keys +public function userAssistantFormat(): array +{ + return [ + 'user' => 'Explain how arrays work in PHP', + 'assistant' => 'Arrays in PHP are ordered maps...' + ]; +} + +// Mixed content types in messages +use Mcp\Schema\Content\{TextContent, ImageContent}; + +public function mixedContent(): array +{ + return [ + [ + 'role' => 'user', + 'content' => [ + new TextContent('Analyze this image:'), + new ImageContent(data: $imageData, mimeType: 'image/png') + ] + ] + ]; +} + +// Using explicit PromptMessage objects +use Mcp\Schema\PromptMessage; +use Mcp\Schema\Enum\Role; + +public function explicitMessages(): array +{ + return [ + new PromptMessage(Role::Assistant, [new TextContent('System instructions')]), + new PromptMessage(Role::User, [new TextContent('User question')]) + ]; +} +``` + +#### Valid Message Roles + +- **`user`**: User input or questions +- **`assistant`**: Assistant responses/system + +#### Error Handling + +Prompt handlers can throw exceptions for invalid inputs: + +```php +#[McpPrompt] +public function generatePrompt(string $topic, string $style): array +{ + $validStyles = ['casual', 'formal', 'technical']; + + if (!in_array($style, $validStyles)) { + throw new \InvalidArgumentException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + +## Completion Providers + +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools +and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have +dynamic parameters that benefit from completion hints. + +### Completion Provider Types + +#### 1. Value Lists + +Provide a static list of possible values: + +```php +use Mcp\Capability\Attribute\CompletionProvider; + +#[McpPrompt] +public function generateContent( + #[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])] + string $contentType, + + #[CompletionProvider(values: ['beginner', 'intermediate', 'advanced'])] + string $difficulty +): array +{ + return [ + ['role' => 'user', 'content' => "Create a {$difficulty} level {$contentType}"] + ]; +} +``` + +#### 2. Enum Classes + +Use enum values for completion: + +```php +enum Priority: string +{ + case LOW = 'low'; + case MEDIUM = 'medium'; + case HIGH = 'high'; +} + +enum Status // Unit enum +{ + case DRAFT; + case PUBLISHED; + case ARCHIVED; +} + +#[McpResourceTemplate(uriTemplate: 'tasks/{taskId}')] +public function getTask( + string $taskId, + + #[CompletionProvider(enum: Priority::class)] // Uses backing values + string $priority, + + #[CompletionProvider(enum: Status::class)] // Uses case names + string $status +): array +{ + // Implementation +} +``` + +#### 3. Custom Provider Classes + +For dynamic completion logic: + +```php +use Mcp\Capability\Prompt\Completion\ProviderInterface; + +class UserIdCompletionProvider implements ProviderInterface +{ + public function __construct(private DatabaseService $db) {} + + public function getCompletions(string $currentValue): array + { + // Return dynamic completions based on current input + return $this->db->searchUserIds($currentValue); + } +} + +#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] +public function getUserProfile( + #[CompletionProvider(provider: UserIdCompletionProvider::class)] + string $userId +): array +{ + // Implementation +} +``` + +**Provider Resolution:** +- **Class strings** (`Provider::class`) → Resolved from PSR-11 container +- **Instances** (`new Provider()`) → Used directly +- **Values** (`['a', 'b']`) → Wrapped in `ListCompletionProvider` +- **Enums** (`MyEnum::class`) → Wrapped in `EnumCompletionProvider` + +> **Important** +> +> Completion providers only offer **suggestions** to users. Users can still input any value, so **always validate +> parameters** in your handlers. Providers don't enforce validation - they're purely for UX improvement. + +## Schema Generation and Validation + +The SDK automatically generates JSON schemas for **tool parameters** using a sophisticated priority system. Schema +generation applies to both attribute-discovered and manually registered tools. + +### Schema Generation Priority + +The server follows this order of precedence: + +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest priority) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide configuration +4. **PHP type hints + docblocks** - Automatic inference (lowest priority) + +### Automatic Schema from PHP Types + +```php +#[McpTool] +public function processUser( + string $email, // Required string + int $age, // Required integer + ?string $name = null, // Optional string + bool $active = true // Boolean with default +): array +{ + // Schema auto-generated from method signature +} +``` + +### Parameter-Level Schema Enhancement + +Add validation rules to specific parameters: + +```php +use Mcp\Capability\Attribute\Schema; + +#[McpTool] +public function validateUser( + #[Schema(format: 'email')] + string $email, + + #[Schema(minimum: 18, maximum: 120)] + int $age, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized first name' + )] + string $firstName +): bool +{ + // PHP types provide base validation + // Schema attributes add constraints +} +``` + +### Method-Level Schema + +Add validation for complex object structures: + +```php +#[McpTool] +#[Schema( + properties: [ + 'userData' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['userData'] +)] +public function createUser(array $userData): array +{ + // Method-level schema adds object structure validation + // PHP array type provides base type +} +``` + +### Complete Schema Override + +**Use sparingly** - bypasses all automatic inference: + +```php +#[McpTool] +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => ['type' => 'string', 'format' => 'uri'], + 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], + 'headers' => [ + 'type' => 'object', + 'patternProperties' => [ + '^[A-Za-z0-9-]+$' => ['type' => 'string'] + ] + ] + ], + 'required' => ['endpoint', 'method'] +])] +public function makeApiRequest(string $endpoint, string $method, array $headers): array +{ + // Complete definition override - PHP types ignored +} +``` + +**Warning:** Only use complete schema override if you're well-versed with JSON Schema specification and have complex +validation requirements that cannot be achieved through the priority system. + +## Discovery vs Manual Registration + +### Attribute-Based Discovery + +**Advantages:** +- Declarative and readable +- Automatic parameter inference +- DocBlock integration +- Type-safe by default +- Caching support + +**Example:** +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) // Automatic discovery + ->build(); +``` + +### Manual Registration + +**Advantages:** +- Fine-grained control +- Runtime configuration +- Conditional registration +- External handler support + +**Example:** +```php +$server = Server::builder() + ->addTool([Calculator::class, 'add'], 'add_numbers') + ->addResource([Config::class, 'get'], 'config://app') + ->addPrompt([Prompts::class, 'email'], 'write_email') + ->build(); +``` + +For detailed information on manual registration, see [Server Builder](server-builder.md#manual-capability-registration). + +### Hybrid Approach + +Combine both methods for maximum flexibility: + +```php +$server = Server::builder() + ->setDiscovery(__DIR__, ['.']) // Discover most capabilities + ->addTool([ExternalService::class, 'process'], 'external') // Add specific ones + ->build(); +``` + +Manual registrations always take precedence over discovered elements with the same identifier. diff --git a/docs/server-builder.md b/docs/server-builder.md new file mode 100644 index 00000000..0d51ed79 --- /dev/null +++ b/docs/server-builder.md @@ -0,0 +1,515 @@ +# Server Builder + +The server `Builder` is a fluent builder class that simplifies the creation and configuration of an MCP server instance. +It provides methods for setting server information, configuring discovery, registering capabilities, and customizing +various aspects of the server behavior. + +## Table of Contents + +- [Basic Usage](#basic-usage) +- [Server Configuration](#server-configuration) +- [Discovery Configuration](#discovery-configuration) +- [Session Management](#session-management) +- [Manual Capability Registration](#manual-capability-registration) +- [Service Dependencies](#service-dependencies) +- [Custom Capability Handlers](#custom-capability-handlers) +- [Complete Example](#complete-example) +- [Method Reference](#method-reference) + +## Basic Usage + +There are two ways to obtain a server builder instance: + +### Method 1: Static Builder Method (Recommended) + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); +``` + +### Method 2: Direct Instantiation + +```php +use Mcp\Server\Builder; + +$server = (new Builder()) + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); +``` + +Both methods return a `Builder` instance that you can configure with fluent methods. The `build()` method returns the +final `Server` instance ready for use. + +## Server Configuration + +### Server Information + +Set the server's identity with name, version, and optional description: + +```php +$server = Server::builder() + ->setServerInfo('Calculator Server', '1.2.0', 'Advanced mathematical calculations'); +``` + +**Parameters:** +- `$name` (string): The server name +- `$version` (string): Version string (semantic versioning recommended) +- `$description` (string|null): Optional description + +### Pagination Limit + +Configure the maximum number of items returned in paginated responses: + +```php +$server = Server::builder() + ->setPaginationLimit(100); // Default: 50 +``` + +### Instructions + +Provide hints to help AI models understand how to use your server: + +```php +$server = Server::builder() + ->setInstructions('This calculator supports basic arithmetic operations. Use the calculate tool for math operations and check the config resource for current settings.'); +``` + +## Discovery Configuration + +**Required when using MCP attributes.** If you're using PHP attributes (`#[McpTool]`, `#[McpResource]`, `#[McpResourceTemplate]`, `#[McpPrompt]`) to define your MCP elements, you **MUST** configure discovery to tell the server where to look for these attributes. + +```php +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['.', 'src', 'lib'], // Where to look for MCP attributes + excludeDirs: ['vendor', 'tests'], // Where NOT to look + cache: $cacheInstance // Optional: cache discovered elements + ); +``` + +**Parameters:** +- `$basePath` (string): Base directory for discovery (typically `__DIR__`) +- `$scanDirs` (array): Directories to recursively scan for `#[McpTool]`, `#[McpResource]`, etc. All subdirectories are included. (default: `['.', 'src']`) +- `$excludeDirs` (array): Directory names to exclude **within** the scanned directories during recursive scanning +- `$cache` (CacheInterface|null): Optional PSR-16 cache to store discovered elements for performance + +**Basic Discovery (scans current directory and `src/`):** +```php +$server = Server::builder() + ->setDiscovery(__DIR__) // Minimal setup + ->build(); +``` + +**Production Setup with Caching:** +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; + +// Cache discovered elements to avoid filesystem scanning on every server start +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); + +$server = Server::builder() + ->setDiscovery( + basePath: __DIR__, + scanDirs: ['src', 'lib'], // Scan these directories recursively + excludeDirs: ['vendor', 'tests', 'temp'], // Skip these directory names within scanned dirs + cache: $cache // Cache for performance + ) + ->build(); +``` + +**How `excludeDirs` works:** +- If scanning `src/` and there's `src/vendor/`, it will be excluded +- If scanning `lib/` and there's `lib/tests/`, it will be excluded +- But if `vendor/` and `tests/` are at the same level as `src/`, they're not scanned anyway (not in `scanDirs`) + +> **Performance**: Always use a cache in production. The first run scans and caches all discovered MCP elements, making +> subsequent server startups nearly instantaneous. + +## Session Management + +Configure session storage and lifecycle. By default, the SDK uses `InMemorySessionStore`: + +```php +use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; + +// Use default in-memory sessions with custom TTL +$server = Server::builder() + ->setSession(ttl: 7200) // 2 hours + ->build(); + +// Override with file-based storage +$server = Server::builder() + ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->build(); + +// Override with in-memory storage and custom TTL +$server = Server::builder() + ->setSession(new InMemorySessionStore(3600)) + ->build(); +``` + +**Available Session Stores:** +- `InMemorySessionStore`: Fast in-memory storage (default) +- `FileSessionStore`: Persistent file-based storage + +**Custom Session Stores:** + +Implement `SessionStoreInterface` to create custom session storage: + +```php +use Mcp\Server\Session\SessionStoreInterface; +use Symfony\Component\Uid\Uuid; + +class RedisSessionStore implements SessionStoreInterface +{ + public function __construct(private $redis, private int $ttl = 3600) {} + + public function exists(Uuid $id): bool + { + return $this->redis->exists($id->toRfc4122()); + } + + public function read(Uuid $sessionId): string|false + { + $data = $this->redis->get($sessionId->toRfc4122()); + return $data !== false ? $data : false; + } + + public function write(Uuid $sessionId, string $data): bool + { + return $this->redis->setex($sessionId->toRfc4122(), $this->ttl, $data); + } + + public function destroy(Uuid $sessionId): bool + { + return $this->redis->del($sessionId->toRfc4122()) > 0; + } + + public function gc(): array + { + // Redis handles TTL automatically + return []; + } +} +``` + +## Manual Capability Registration + +Register MCP elements programmatically without using attributes. The handler is the most important parameter and can be any PHP callable. + +### Handler Types + +**Handler** can be any PHP callable: + +1. **Closure**: `function(int $a, int $b): int { return $a + $b; }` +2. **Class and method name pair**: `[ClassName::class, 'methodName']` - class must be constructable through the container +3. **Class instance and method name**: `[$instance, 'methodName']` +4. **Invokable class name**: `InvokableClass::class` - class must be constructable through the container and have `__invoke` method + +### Manual Tool Registration + +```php +$server = Server::builder() + // Using closure + ->addTool( + handler: function(int $a, int $b): int { return $a + $b; }, + name: 'add_numbers', + description: 'Adds two numbers together' + ) + + // Using class method pair + ->addTool( + handler: [Calculator::class, 'multiply'], + name: 'multiply_numbers' + // name and description are optional - derived from method name and docblock + ) + + // Using instance method + ->addTool( + handler: [$calculatorInstance, 'divide'] + ) + + // Using invokable class + ->addTool( + handler: InvokableCalculator::class + ); +``` + +### Manual Resource Registration + +Register static resources: + +```php +$server = Server::builder() + ->addResource( + handler: [Config::class, 'getSettings'], + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration', + mimeType: 'application/json' + ); +``` + +### Manual Resource Template Registration + +Register dynamic resources with URI templates: + +```php +$server = Server::builder() + ->addResourceTemplate( + handler: [UserService::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json' + ); +``` + +### Manual Prompt Registration + +Register prompt generators: + +```php +$server = Server::builder() + ->addPrompt( + handler: [PromptService::class, 'generatePrompt'], + name: 'custom_prompt', + description: 'A custom prompt generator' + ); +``` + +**Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from +the handler's method name and docblock. + +For more details on MCP elements, handlers, and attribute-based discovery, see [MCP Elements](mcp-elements.md). + +## Service Dependencies + +### Container + +The container is used to resolve handlers and their dependencies when handlers inject dependencies in their constructors. +The SDK includes a basic container with simple auto-wiring capabilities. + +```php +use Mcp\Capability\Registry\Container; + +// Use the default basic container +$container = new Container(); +$container->set(DatabaseService::class, new DatabaseService($pdo)); +$container->set(\PDO::class, $pdo); + +$server = Server::builder() + ->setContainer($container) + ->build(); +``` + +**Basic Container Features:** +- Supports constructor auto-wiring for classes with parameterless constructors +- Resolves dependencies where all parameters are type-hinted classes/interfaces known to the container +- Supports parameters with default values +- Does NOT support scalar/built-in type injection without defaults +- Detects circular dependencies + +You can also use any PSR-11 compatible container (Symfony DI, PHP-DI, Laravel Container, etc.). + +### Logger + +Provide a PSR-3 logger instance for internal server logging (request/response processing, errors, session management, transport events): + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$server = Server::builder() + ->setLogger($logger); +``` + +### Event Dispatcher + +Configure event dispatching: + +```php +$server = Server::builder() + ->setEventDispatcher($eventDispatcher); +``` + +## Custom Capability Handlers + +**Advanced customization for specific use cases.** Override the default capability handlers when you need completely custom +behavior for how tools are executed, resources are read, or prompts are generated. Most users should stick with the default implementations. + +The default handlers work by: +1. Looking up registered tools/resources/prompts by name/URI +2. Resolving the handler from the container +3. Executing the handler with the provided arguments +4. Formatting the result and handling errors + +### Custom Tool Caller + +Replace how tool execution requests are processed. Your custom `ToolCallerInterface` receives a `CallToolRequest` (with +tool name and arguments) and must return a `CallToolResult`. + +```php +use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Result\CallToolResult; + +class CustomToolCaller implements ToolCallerInterface +{ + public function call(CallToolRequest $request): CallToolResult + { + // Custom tool routing, execution, authentication, caching, etc. + // You handle finding the tool, executing it, and formatting results + $toolName = $request->name; + $arguments = $request->arguments ?? []; + + // Your custom logic here + return new CallToolResult([/* content */]); + } +} + +$server = Server::builder() + ->setToolCaller(new CustomToolCaller()); +``` + +### Custom Resource Reader + +Replace how resource reading requests are processed. Your custom `ResourceReaderInterface` receives a `ReadResourceRequest` +(with URI) and must return a `ReadResourceResult`. + +```php +use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Result\ReadResourceResult; + +class CustomResourceReader implements ResourceReaderInterface +{ + public function read(ReadResourceRequest $request): ReadResourceResult + { + // Custom resource resolution, caching, access control, etc. + $uri = $request->uri; + + // Your custom logic here + return new ReadResourceResult([/* content */]); + } +} + +$server = Server::builder() + ->setResourceReader(new CustomResourceReader()); +``` + +### Custom Prompt Getter + +Replace how prompt generation requests are processed. Your custom `PromptGetterInterface` receives a `GetPromptRequest` +(with prompt name and arguments) and must return a `GetPromptResult`. + +```php +use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Result\GetPromptResult; + +class CustomPromptGetter implements PromptGetterInterface +{ + public function get(GetPromptRequest $request): GetPromptResult + { + // Custom prompt generation, template engines, dynamic content, etc. + $promptName = $request->name; + $arguments = $request->arguments ?? []; + + // Your custom logic here + return new GetPromptResult([/* messages */]); + } +} + +$server = Server::builder() + ->setPromptGetter(new CustomPromptGetter()); +``` + +> **Warning**: Custom capability handlers bypass the entire default registration system (discovered attributes, manual +> registration, container resolution, etc.). You become responsible for all aspect of execution, including error handling, +> logging, and result formatting. Only use this for very specific advanced use cases like custom authentication, complex +> routing, or integration with external systems. + +## Complete Example + +Here's a comprehensive example showing all major configuration options: + +```php +use Mcp\Server; +use Mcp\Server\Session\FileSessionStore; +use Mcp\Capability\Registry\Container; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +// Setup dependencies +$logger = new Logger('mcp-server'); +$logger->pushHandler(new StreamHandler('mcp.log', Logger::INFO)); + +$cache = new Psr16Cache(new FilesystemAdapter('mcp-discovery')); +$sessionStore = new FileSessionStore(__DIR__ . '/sessions'); + +// Setup container with dependencies +$container = new Container(); +$container->set(\PDO::class, new \PDO('sqlite::memory:')); +$container->set(DatabaseService::class, new DatabaseService($container->get(\PDO::class))); + +// Build server +$server = Server::builder() + // Server identity + ->setServerInfo('Advanced Calculator', '2.1.0') + + // Performance and behavior + ->setPaginationLimit(100) + ->setInstructions('Use calculate tool for math operations. Check config resource for current settings.') + + // Discovery with caching + ->setDiscovery(__DIR__, ['src'], ['vendor', 'tests'], $cache) + + // Session management + ->setSession($sessionStore) + + // Services + ->setLogger($logger) + ->setContainer($container) + + // Manual capability registration + ->addTool([Calculator::class, 'advancedCalculation'], 'advanced_calc') + ->addResource([Config::class, 'getSettings'], 'config://app/settings', 'app_settings') + + // Build the server + ->build(); +``` + +## Method Reference + +| Method | Parameters | Description | +|--------|------------|-------------| +| `setServerInfo()` | name, version, description? | Set server identity | +| `setPaginationLimit()` | limit | Set max items per page | +| `setInstructions()` | instructions | Set usage instructions | +| `setDiscovery()` | basePath, scanDirs?, excludeDirs?, cache? | Configure attribute discovery | +| `setSession()` | store?, factory?, ttl? | Configure session management | +| `setLogger()` | logger | Set PSR-3 logger | +| `setContainer()` | container | Set PSR-11 container | +| `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | +| `setToolCaller()` | caller | Set custom tool caller | +| `setResourceReader()` | reader | Set custom resource reader | +| `setPromptGetter()` | getter | Set custom prompt getter | +| `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | +| `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | +| `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | +| `addPrompt()` | handler, name?, description? | Register prompt | +| `build()` | - | Create the server instance | diff --git a/docs/transports.md b/docs/transports.md new file mode 100644 index 00000000..dc0f50a2 --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,350 @@ +# Transports + +Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport +implementations: STDIO for command-line integration and HTTP for web-based communication. + +## Table of Contents + +- [Transport Overview](#transport-overview) +- [STDIO Transport](#stdio-transport) +- [HTTP Transport](#http-transport) +- [Choosing a Transport](#choosing-a-transport) + +## Transport Overview + +All transports implement the `TransportInterface` and follow the same basic pattern: + +```php +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new SomeTransport(); + +$server->connect($transport); + +$transport->listen(); // For STDIO, or handle response for HTTP +``` + +## STDIO Transport + +The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations. + +```php +$transport = new StdioTransport( + input: STDIN, // Input stream (default: STDIN) + output: STDOUT, // Output stream (default: STDOUT) + logger: $logger // Optional PSR-3 logger +); +``` + +### Parameters + +- **`input`** (optional): Input stream resource. Defaults to `STDIN`. +- **`output`** (optional): Output stream resource. Defaults to `STDOUT`. +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. + +> [!IMPORTANT] +> When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication. +> Use `STDERR` for debugging instead. + +### Example Server Script + +```php +#!/usr/bin/env php +setServerInfo('STDIO Calculator', '1.0.0') + ->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers') + ->addTool(InvokableCalculator::class) + ->build(); + +$transport = new StdioTransport(); + +$server->connect($transport); + +$transport->listen(); +``` + +### Client Configuration + +For MCP clients like Claude Desktop: + +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` + +## HTTP Transport + +The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive +and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application. + +```php +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; + +$transport = new StreamableHttpTransport( + request: $serverRequest, // PSR-7 server request + responseFactory: $responseFactory, // PSR-17 response factory + streamFactory: $streamFactory, // PSR-17 stream factory + logger: $logger // Optional PSR-3 logger +); +``` + +### Parameters + +- **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request +- **`responseFactory`** (required): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses +- **`streamFactory`** (required): `StreamFactoryInterface` - PSR-17 factory for creating response body streams +- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. + +### Architecture + +The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that +your application can handle however it needs to: + +``` +Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App +``` + +This design allows integration with any PHP framework or application that supports PSR-7. + +### Basic Usage (Standalone) + +Here's an opinionated example using Nyholm PSR-7 and Laminas emitter: + +```php +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Session\FileSessionStore; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +// Create PSR-7 request from globals +$psr17Factory = new Psr17Factory(); +$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); +$request = $creator->fromGlobals(); + +// Build server +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +// Process request and get response +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$server->connect($transport); +$response = $transport->listen(); + +// Emit response +(new SapiEmitter())->emit($response); +``` + +### Framework Integration + +#### Symfony Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that uses Symfony's PSR-7 bridge: + +> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container. + +```php +// In a Symfony controller +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Nyholm\Psr7\Factory\Psr17Factory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint'] + public function handle(Request $request, Server $mcpServer): Response + { + // Create PSR-7 factories + $psr17Factory = new Psr17Factory(); + $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + $httpFoundationFactory = new HttpFoundationFactory(); + + // Convert Symfony request to PSR-7 + $psrRequest = $psrHttpFactory->createRequest($request); + + // Process with MCP + $transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory); + $mcpServer->connect($transport); + $psrResponse = $transport->listen(); + + // Convert PSR-7 response back to Symfony + return $httpFoundationFactory->createResponse($psrResponse); + } +} +``` + +#### Laravel Integration + +First install the required PSR libraries: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +Then create a controller that type-hints `ServerRequestInterface`: + +> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection. + +```php +// In a Laravel controller +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; +use Nyholm\Psr7\Factory\Psr17Factory; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $mcpServer): ResponseInterface + { + $psr17Factory = new Psr17Factory(); + + // Create and connect the MCP HTTP transport + $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); + $mcpServer->connect($transport); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $transport->listen(); + } +} + +// Route registration +Route::any('/mcp', [McpController::class, 'handle']); +``` + +#### Slim Framework Integration + +Slim Framework works natively with PSR-7. + +Create a route handler using Slim's built-in factories and container: + +```php +use Psr\Container\ContainerInterface; +use Slim\Factory\AppFactory; +use Slim\Psr7\Factory\ResponseFactory; +use Slim\Psr7\Factory\StreamFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); +$container = $app->getContainer(); + +$container->set('mcpServer', function (ContainerInterface $container) { + return Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); +}); + +$app->any('/mcp', function ($request, $response) { + $mcpServer = $this->get('mcpServer'); + + $responseFactory = new ResponseFactory(); + $streamFactory = new StreamFactory(); + + $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); + $mcpServer->connect($transport); + + return $transport->listen(); +}); +``` + +### HTTP Method Handling + +The transport handles all HTTP methods automatically: + +- **POST**: Send MCP requests +- **GET**: Not implemented (returns 405) +- **DELETE**: End session +- **OPTIONS**: CORS preflight + +You should route **all methods** to your MCP endpoint, not just POST. + +### Session Management + +HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport +where in-memory sessions work fine, HTTP transport needs a persistent session store: + +```php +use Mcp\Server\Session\FileSessionStore; + +// ✅ Good for HTTP +$server = Server::builder() + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->build(); + +// ❌ Not recommended for HTTP (sessions lost between requests) +$server = Server::builder() + ->setSession(new InMemorySessionStore()) + ->build(); +``` + +### Recommended Route + +It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced: + +```php +// Recommended +Route::any('/mcp', [McpController::class, 'handle']); + +// Also valid +Route::any('/', [McpController::class, 'handle']); +Route::any('/api/mcp', [McpController::class, 'handle']); +``` + +### Testing HTTP Transport + +Use the MCP Inspector to test HTTP servers: + +```bash +# Start your PHP server +php -S localhost:8000 server.php + +# Connect with MCP Inspector +npx @modelcontextprotocol/inspector http://localhost:8000 +``` + +## Choosing a Transport + +The choice between STDIO and HTTP transport depends on the client you want to integrate with. +If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. +If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. + +One additiona difference to consider is that STDIO is process-based (one session per process) while HTTP is +request-based (multiple sessions via headers). diff --git a/examples/README.md b/examples/README.md index b621c229..96b8773b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,16 +2,15 @@ This directory contains various examples of how to use the PHP MCP SDK. -You can run examples 01-08 with the dependencies already installed in the root directory of the SDK. For example 09, see the -README in the `examples/09-standalone-cli` directory. +You can run the examples with the dependencies already installed in the root directory of the SDK. For running an example, you execute the `server.php` like this: ```bash # For examples using STDIO transport -php examples/01-discovery-stdio-calculator/server.php +php examples/stdio-discovery-calculator/server.php # For examples using Streamable HTTP transport -php -S localhost:8000 examples/02-discovery-http-userprofile/server.php +php -S localhost:8000 examples/http-discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -19,7 +18,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/01-discovery-stdio-calculator/server.php +npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php ``` ## Debugging @@ -30,5 +29,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/01-discovery-stdio-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php ``` From fcf605d5d74dac18291150214f48aece00d066eb Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 7 Oct 2025 23:25:06 +0100 Subject: [PATCH 28/66] [Server] Simplify server extensibility with pluggable method handlers (#100) * refactor: use a custom method handler registration system * refactor: remove redundant Completer and CompleterInterface * docs: update server-builder documentation to reflect custom method handler changes --- docs/server-builder.md | 115 +--- examples/custom-method-handlers/server.php | 144 ++++ src/Capability/Completion/Completer.php | 75 -- .../Completion/CompleterInterface.php | 25 - src/Capability/Prompt/PromptGetter.php | 69 -- .../Prompt/PromptGetterInterface.php | 29 - src/Capability/Resource/ResourceReader.php | 68 -- .../Resource/ResourceReaderInterface.php | 29 - src/Capability/Tool/ToolCaller.php | 81 --- src/Capability/Tool/ToolCallerInterface.php | 29 - src/Schema/Result/CallToolResult.php | 18 +- src/Server/Builder.php | 126 ++-- src/Server/Handler/JsonRpcHandler.php | 53 +- .../Handler/Request/CallToolHandler.php | 55 +- .../Request/CompletionCompleteHandler.php | 50 +- .../Handler/Request/GetPromptHandler.php | 40 +- .../Handler/Request/ReadResourceHandler.php | 39 +- .../Completion/EnumCompletionProviderTest.php | 85 --- .../Completion/ListCompletionProviderTest.php | 80 --- .../Capability/Prompt/PromptGetterTest.php | 638 ------------------ .../Resource/ResourceReaderTest.php | 522 -------------- tests/Unit/Capability/Tool/ToolCallerTest.php | 628 ----------------- tests/Unit/JsonRpc/HandlerTest.php | 16 +- .../Handler/Request/CallToolHandlerTest.php | 221 +++--- .../Handler/Request/GetPromptHandlerTest.php | 283 +++++--- .../Request/ReadResourceHandlerTest.php | 243 ++++--- 26 files changed, 896 insertions(+), 2865 deletions(-) create mode 100644 examples/custom-method-handlers/server.php delete mode 100644 src/Capability/Completion/Completer.php delete mode 100644 src/Capability/Completion/CompleterInterface.php delete mode 100644 src/Capability/Prompt/PromptGetter.php delete mode 100644 src/Capability/Prompt/PromptGetterInterface.php delete mode 100644 src/Capability/Resource/ResourceReader.php delete mode 100644 src/Capability/Resource/ResourceReaderInterface.php delete mode 100644 src/Capability/Tool/ToolCaller.php delete mode 100644 src/Capability/Tool/ToolCallerInterface.php delete mode 100644 tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php delete mode 100644 tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php delete mode 100644 tests/Unit/Capability/Prompt/PromptGetterTest.php delete mode 100644 tests/Unit/Capability/Resource/ResourceReaderTest.php delete mode 100644 tests/Unit/Capability/Tool/ToolCallerTest.php diff --git a/docs/server-builder.md b/docs/server-builder.md index 0d51ed79..f673000c 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -12,7 +12,7 @@ various aspects of the server behavior. - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) - [Service Dependencies](#service-dependencies) -- [Custom Capability Handlers](#custom-capability-handlers) +- [Custom Method Handlers](#custom-method-handlers) - [Complete Example](#complete-example) - [Method Reference](#method-reference) @@ -344,102 +344,50 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` -## Custom Capability Handlers +## Custom Method Handlers -**Advanced customization for specific use cases.** Override the default capability handlers when you need completely custom -behavior for how tools are executed, resources are read, or prompts are generated. Most users should stick with the default implementations. +**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over +individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass +those dependencies in yourself. -The default handlers work by: -1. Looking up registered tools/resources/prompts by name/URI -2. Resolving the handler from the container -3. Executing the handler with the provided arguments -4. Formatting the result and handling errors - -### Custom Tool Caller - -Replace how tool execution requests are processed. Your custom `ToolCallerInterface` receives a `CallToolRequest` (with -tool name and arguments) and must return a `CallToolResult`. +Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as +many times as needed; each call prepends the handlers so they execute before the defaults: ```php -use Mcp\Capability\Tool\ToolCallerInterface; -use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Result\CallToolResult; - -class CustomToolCaller implements ToolCallerInterface -{ - public function call(CallToolRequest $request): CallToolResult - { - // Custom tool routing, execution, authentication, caching, etc. - // You handle finding the tool, executing it, and formatting results - $toolName = $request->name; - $arguments = $request->arguments ?? []; - - // Your custom logic here - return new CallToolResult([/* content */]); - } -} - $server = Server::builder() - ->setToolCaller(new CustomToolCaller()); + ->addMethodHandler(new AuditHandler()) + ->addMethodHandlers([ + new CustomListToolsHandler(), + new CustomCallToolHandler(), + ]) + ->build(); ``` -### Custom Resource Reader - -Replace how resource reading requests are processed. Your custom `ResourceReaderInterface` receives a `ReadResourceRequest` -(with URI) and must return a `ReadResourceResult`. +Custom handlers implement `MethodHandlerInterface`: ```php -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Schema\Request\ReadResourceRequest; -use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Session\SessionInterface; -class CustomResourceReader implements ResourceReaderInterface +interface MethodHandlerInterface { - public function read(ReadResourceRequest $request): ReadResourceResult - { - // Custom resource resolution, caching, access control, etc. - $uri = $request->uri; - - // Your custom logic here - return new ReadResourceResult([/* content */]); - } -} + public function supports(HasMethodInterface $message): bool; -$server = Server::builder() - ->setResourceReader(new CustomResourceReader()); + public function handle(HasMethodInterface $message, SessionInterface $session); +} ``` -### Custom Prompt Getter - -Replace how prompt generation requests are processed. Your custom `PromptGetterInterface` receives a `GetPromptRequest` -(with prompt name and arguments) and must return a `GetPromptResult`. - -```php -use Mcp\Capability\Prompt\PromptGetterInterface; -use Mcp\Schema\Request\GetPromptRequest; -use Mcp\Schema\Result\GetPromptResult; - -class CustomPromptGetter implements PromptGetterInterface -{ - public function get(GetPromptRequest $request): GetPromptResult - { - // Custom prompt generation, template engines, dynamic content, etc. - $promptName = $request->name; - $arguments = $request->arguments ?? []; - - // Your custom logic here - return new GetPromptResult([/* messages */]); - } -} +- `supports()` decides if the handler should look at the incoming message. +- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`. -$server = Server::builder() - ->setPromptGetter(new CustomPromptGetter()); -``` +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tool/list` and `tool/call` methods independently of the registry. -> **Warning**: Custom capability handlers bypass the entire default registration system (discovered attributes, manual -> registration, container resolution, etc.). You become responsible for all aspect of execution, including error handling, -> logging, and result formatting. Only use this for very specific advanced use cases like custom authentication, complex -> routing, or integration with external systems. +> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. +> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing. ## Complete Example @@ -505,9 +453,8 @@ $server = Server::builder() | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `setToolCaller()` | caller | Set custom tool caller | -| `setResourceReader()` | reader | Set custom resource reader | -| `setPromptGetter()` | getter | Set custom prompt getter | +| `addMethodHandler()` | handler | Prepend a single custom method handler | +| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php new file mode 100644 index 00000000..b1db5f8f --- /dev/null +++ b/examples/custom-method-handlers/server.php @@ -0,0 +1,144 @@ +#!/usr/bin/env php +info('Starting MCP Custom Method Handlers (Stdio) Server...'); + +$toolDefinitions = [ + 'say_hello' => new Tool( + name: 'say_hello', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'Name to greet'], + ], + 'required' => ['name'], + ], + description: 'Greets a user by name.', + annotations: null, + ), + 'sum' => new Tool( + name: 'sum', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'number'], + 'b' => ['type' => 'number'], + ], + 'required' => ['a', 'b'], + ], + description: 'Returns a+b.', + annotations: null, + ), +]; + +$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface { + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof ListToolsRequest; + } + + public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + { + assert($message instanceof ListToolsRequest); + + return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); + } +}; + +$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface { + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(HasMethodInterface $message): bool + { + return $message instanceof CallToolRequest; + } + + public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + { + assert($message instanceof CallToolRequest); + + $name = $message->name; + $args = $message->arguments ?? []; + + if (!isset($this->toolDefinitions[$name])) { + return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); + } + + try { + switch ($name) { + case 'say_hello': + $greetName = (string) ($args['name'] ?? 'world'); + $result = [new TextContent(sprintf('Hello, %s!', $greetName))]; + break; + case 'sum': + $a = (float) ($args['a'] ?? 0); + $b = (float) ($args['b'] ?? 0); + $result = [new TextContent((string) ($a + $b))]; + break; + default: + $result = [new TextContent('Unknown tool')]; + } + + return new Response($message->getId(), new CallToolResult($result)); + } catch (Throwable $e) { + return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); + } + } +}; + +$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false); + +$server = Server::builder() + ->setServerInfo('Custom Handlers Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setCapabilities($capabilities) + ->addMethodHandlers([$listToolsHandler, $callToolHandler]) + ->build(); + +$transport = new StdioTransport(logger: logger()); + +$server->connect($transport); + +$transport->listen(); + +logger()->info('Server listener stopped gracefully.'); diff --git a/src/Capability/Completion/Completer.php b/src/Capability/Completion/Completer.php deleted file mode 100644 index eaffaee1..00000000 --- a/src/Capability/Completion/Completer.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -final class Completer implements CompleterInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ?ContainerInterface $container = null, - ) { - } - - public function complete(CompletionCompleteRequest $request): CompletionCompleteResult - { - $argumentName = $request->argument['name'] ?? ''; - $currentValue = $request->argument['value'] ?? ''; - - $reference = match (true) { - 'ref/prompt' === $request->ref->type => $this->referenceProvider->getPrompt($request->ref->name), - 'ref/resource' === $request->ref->type => $this->referenceProvider->getResourceTemplate($request->ref->uri), - default => null, - }; - - if (null === $reference) { - return new CompletionCompleteResult([]); - } - - $providerClassOrInstance = $reference->completionProviders[$argumentName] ?? null; - if (null === $providerClassOrInstance) { - return new CompletionCompleteResult([]); - } - - if (\is_string($providerClassOrInstance)) { - if (!class_exists($providerClassOrInstance)) { - throw new RuntimeException(\sprintf('Completion provider class "%s" does not exist.', $providerClassOrInstance)); - } - - $provider = $this->container?->has($providerClassOrInstance) - ? $this->container->get($providerClassOrInstance) - : new $providerClassOrInstance(); - } else { - $provider = $providerClassOrInstance; - } - - if (!$provider instanceof ProviderInterface) { - throw new RuntimeException('Completion provider must implement ProviderInterface.'); - } - - $completions = $provider->getCompletions($currentValue); - - $total = \count($completions); - $hasMore = $total > 100; - $pagedCompletions = \array_slice($completions, 0, 100); - - return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); - } -} diff --git a/src/Capability/Completion/CompleterInterface.php b/src/Capability/Completion/CompleterInterface.php deleted file mode 100644 index 9f9f871c..00000000 --- a/src/Capability/Completion/CompleterInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -interface CompleterInterface -{ - public function complete(CompletionCompleteRequest $request): CompletionCompleteResult; -} diff --git a/src/Capability/Prompt/PromptGetter.php b/src/Capability/Prompt/PromptGetter.php deleted file mode 100644 index ec5ac31a..00000000 --- a/src/Capability/Prompt/PromptGetter.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ -final class PromptGetter implements PromptGetterInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function get(GetPromptRequest $request): GetPromptResult - { - $promptName = $request->name; - $arguments = $request->arguments ?? []; - - $this->logger->debug('Getting prompt', ['name' => $promptName, 'arguments' => $arguments]); - - $reference = $this->referenceProvider->getPrompt($promptName); - - if (null === $reference) { - $this->logger->warning('Prompt not found', ['name' => $promptName]); - throw new PromptNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($reference, $arguments); - $formattedResult = $reference->formatResult($result); - - $this->logger->debug('Prompt retrieved successfully', [ - 'name' => $promptName, - 'result_type' => \gettype($result), - ]); - - return new GetPromptResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Prompt retrieval failed', [ - 'name' => $promptName, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new PromptGetException($request, $e); - } - } -} diff --git a/src/Capability/Prompt/PromptGetterInterface.php b/src/Capability/Prompt/PromptGetterInterface.php deleted file mode 100644 index 35d7b9fb..00000000 --- a/src/Capability/Prompt/PromptGetterInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface PromptGetterInterface -{ - /** - * @throws PromptGetException if the prompt execution fails - * @throws PromptNotFoundException if the prompt is not found - */ - public function get(GetPromptRequest $request): GetPromptResult; -} diff --git a/src/Capability/Resource/ResourceReader.php b/src/Capability/Resource/ResourceReader.php deleted file mode 100644 index 2496cfaf..00000000 --- a/src/Capability/Resource/ResourceReader.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -final class ResourceReader implements ResourceReaderInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function read(ReadResourceRequest $request): ReadResourceResult - { - $uri = $request->uri; - - $this->logger->debug('Reading resource', ['uri' => $uri]); - - $reference = $this->referenceProvider->getResource($uri); - - if (null === $reference) { - $this->logger->warning('Resource not found', ['uri' => $uri]); - throw new ResourceNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); - $formattedResult = $reference->formatResult($result, $uri); - - $this->logger->debug('Resource read successfully', [ - 'uri' => $uri, - 'result_type' => \gettype($result), - ]); - - return new ReadResourceResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Resource read failed', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new ResourceReadException($request, $e); - } - } -} diff --git a/src/Capability/Resource/ResourceReaderInterface.php b/src/Capability/Resource/ResourceReaderInterface.php deleted file mode 100644 index cba468cd..00000000 --- a/src/Capability/Resource/ResourceReaderInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface ResourceReaderInterface -{ - /** - * @throws ResourceReadException if the resource execution fails - * @throws ResourceNotFoundException if the resource is not found - */ - public function read(ReadResourceRequest $request): ReadResourceResult; -} diff --git a/src/Capability/Tool/ToolCaller.php b/src/Capability/Tool/ToolCaller.php deleted file mode 100644 index 24bc3995..00000000 --- a/src/Capability/Tool/ToolCaller.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -final class ToolCaller implements ToolCallerInterface -{ - public function __construct( - private readonly ReferenceProviderInterface $referenceProvider, - private readonly ReferenceHandlerInterface $referenceHandler, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @throws ToolCallException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found - */ - public function call(CallToolRequest $request): CallToolResult - { - $toolName = $request->name; - $arguments = $request->arguments ?? []; - - $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); - - $toolReference = $this->referenceProvider->getTool($toolName); - - if (null === $toolReference) { - $this->logger->warning('Tool not found', ['name' => $toolName]); - throw new ToolNotFoundException($request); - } - - try { - $result = $this->referenceHandler->handle($toolReference, $arguments); - /** @var TextContent[]|ImageContent[]|EmbeddedResource[]|AudioContent[] $formattedResult */ - $formattedResult = $toolReference->formatResult($result); - - $this->logger->debug('Tool executed successfully', [ - 'name' => $toolName, - 'result_type' => \gettype($result), - ]); - - return new CallToolResult($formattedResult); - } catch (\Throwable $e) { - $this->logger->error('Tool execution failed', [ - 'name' => $toolName, - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw new ToolCallException($request, $e); - } - } -} diff --git a/src/Capability/Tool/ToolCallerInterface.php b/src/Capability/Tool/ToolCallerInterface.php deleted file mode 100644 index 1ef7ffea..00000000 --- a/src/Capability/Tool/ToolCallerInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface ToolCallerInterface -{ - /** - * @throws ToolCallException if the tool execution fails - * @throws ToolNotFoundException if the tool is not found - */ - public function call(CallToolRequest $request): CallToolResult; -} diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 4345f81b..8a01bbb2 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\EmbeddedResource; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; -use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; /** @@ -32,11 +31,6 @@ * server does not support tool calls, or any other exceptional conditions, * should be reported as an MCP error response. * - * @phpstan-import-type TextContentData from TextContent - * @phpstan-import-type ImageContentData from ImageContent - * @phpstan-import-type AudioContentData from AudioContent - * @phpstan-import-type EmbeddedResourceData from EmbeddedResource - * * @author Kyrian Obikwelu */ class CallToolResult implements ResultInterface @@ -44,8 +38,8 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param array $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). */ public function __construct( public readonly array $content, @@ -61,7 +55,7 @@ public function __construct( /** * Create a new CallToolResult with success status. * - * @param array $content The content of the tool result + * @param Content[] $content The content of the tool result */ public static function success(array $content): self { @@ -71,7 +65,7 @@ public static function success(array $content): self /** * Create a new CallToolResult with error status. * - * @param array $content The content of the tool result + * @param Content[] $content The content of the tool result */ public static function error(array $content): self { @@ -80,7 +74,7 @@ public static function error(array $content): self /** * @param array{ - * content: array, + * content: array, * isError?: bool, * } $data */ @@ -107,7 +101,7 @@ public static function fromArray(array $data): self /** * @return array{ - * content: array, + * content: array, * isError: bool, * } */ diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 82c50f2a..8ac2dc4a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,27 +20,24 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\HandlerResolver; use Mcp\Capability\Discovery\SchemaGenerator; -use Mcp\Capability\Prompt\PromptGetter; -use Mcp\Capability\Prompt\PromptGetterInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; -use Mcp\Capability\Resource\ResourceReader; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolCaller; -use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ConfigurationException; +use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; +use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -64,12 +61,6 @@ final class Builder private ?CacheInterface $discoveryCache = null; - private ?ToolCallerInterface $toolCaller = null; - - private ?ResourceReaderInterface $resourceReader = null; - - private ?PromptGetterInterface $promptGetter = null; - private ?EventDispatcherInterface $eventDispatcher = null; private ?ContainerInterface $container = null; @@ -84,6 +75,13 @@ final class Builder private ?string $instructions = null; + private ?ServerCapabilities $explicitCapabilities = null; + + /** + * @var array + */ + private array $customMethodHandlers = []; + /** * @var array{ * handler: Handler, @@ -175,39 +173,52 @@ public function setInstructions(?string $instructions): self } /** - * Provides a PSR-3 logger instance. Defaults to NullLogger. + * Explicitly set server capabilities. If set, this overrides automatic detection. */ - public function setLogger(LoggerInterface $logger): self + public function setCapabilities(ServerCapabilities $capabilities): self { - $this->logger = $logger; + $this->explicitCapabilities = $capabilities; return $this; } - public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self + /** + * Register a single custom method handler. + */ + public function addMethodHandler(MethodHandlerInterface $handler): self { - $this->eventDispatcher = $eventDispatcher; + $this->customMethodHandlers[] = $handler; return $this; } - public function setToolCaller(ToolCallerInterface $toolCaller): self + /** + * Register multiple custom method handlers. + * + * @param iterable $handlers + */ + public function addMethodHandlers(iterable $handlers): self { - $this->toolCaller = $toolCaller; + foreach ($handlers as $handler) { + $this->customMethodHandlers[] = $handler; + } return $this; } - public function setResourceReader(ResourceReaderInterface $resourceReader): self + /** + * Provides a PSR-3 logger instance. Defaults to NullLogger. + */ + public function setLogger(LoggerInterface $logger): self { - $this->resourceReader = $resourceReader; + $this->logger = $logger; return $this; } - public function setPromptGetter(PromptGetterInterface $promptGetter): self + public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self { - $this->promptGetter = $promptGetter; + $this->eventDispatcher = $eventDispatcher; return $this; } @@ -333,49 +344,60 @@ public function addPrompt(\Closure|array|string $handler, ?string $name = null, public function build(): Server { $logger = $this->logger ?? new NullLogger(); - $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - $referenceHandler = new ReferenceHandler($container); - $toolCaller = $this->toolCaller ??= new ToolCaller($registry, $referenceHandler, $logger); - $resourceReader = $this->resourceReader ??= new ResourceReader($registry, $referenceHandler, $logger); - $promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger); - $this->registerCapabilities($registry, $logger); if (null !== $this->discoveryBasePath) { - $discovery = new Discoverer($registry, $logger); - - if (null !== $this->discoveryCache) { - $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); - } - - $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); + $this->performDiscovery($registry, $logger); } $sessionTtl = $this->sessionTtl ?? 3600; $sessionFactory = $this->sessionFactory ?? new SessionFactory(); $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + $messageFactory = MessageFactory::make(); - return new Server( - jsonRpcHandler: JsonRpcHandler::make( - registry: $registry, - referenceProvider: $registry, - configuration: new Configuration( - $this->serverInfo, - $registry->getCapabilities(), - $this->paginationLimit, $this->instructions, - ), - toolCaller: $toolCaller, - resourceReader: $resourceReader, - promptGetter: $promptGetter, - sessionStore: $sessionStore, - sessionFactory: $sessionFactory, - logger: $logger, - ), + $capabilities = $this->explicitCapabilities ?? $registry->getCapabilities(); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); + $referenceHandler = new ReferenceHandler($container); + + $methodHandlers = array_merge($this->customMethodHandlers, [ + new Handler\Request\PingHandler(), + new Handler\Request\InitializeHandler($configuration), + new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), + new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), + new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), + new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), + new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + + new Handler\Notification\InitializedHandler(), + ]); + + $jsonRpcHandler = new JsonRpcHandler( + methodHandlers: $methodHandlers, + messageFactory: $messageFactory, + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, logger: $logger, ); + + return new Server($jsonRpcHandler, $logger); + } + + private function performDiscovery( + Registry\ReferenceRegistryInterface $registry, + LoggerInterface $logger, + ): void { + $discovery = new Discoverer($registry, $logger); + + if (null !== $this->discoveryCache) { + $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); + } + + $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); } /** diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php index ab1eb106..5f785bc5 100644 --- a/src/Server/Handler/JsonRpcHandler.php +++ b/src/Server/Handler/JsonRpcHandler.php @@ -11,12 +11,6 @@ namespace Mcp\Server\Handler; -use Mcp\Capability\Completion\Completer; -use Mcp\Capability\Prompt\PromptGetterInterface; -use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Capability\Registry\ReferenceRegistryInterface; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Capability\Tool\ToolCallerInterface; use Mcp\Exception\ExceptionInterface; use Mcp\Exception\HandlerNotFoundException; use Mcp\Exception\InvalidInputMessageException; @@ -27,8 +21,6 @@ use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; -use Mcp\Server\Configuration; -use Mcp\Server\Handler; use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionStoreInterface; @@ -44,56 +36,15 @@ class JsonRpcHandler { /** - * @var array - */ - private readonly array $methodHandlers; - - /** - * @param iterable $methodHandlers + * @param array $methodHandlers */ public function __construct( + private readonly array $methodHandlers, private readonly MessageFactory $messageFactory, private readonly SessionFactoryInterface $sessionFactory, private readonly SessionStoreInterface $sessionStore, - iterable $methodHandlers, private readonly LoggerInterface $logger = new NullLogger(), ) { - $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array( - $methodHandlers, - ) : $methodHandlers; - } - - public static function make( - ReferenceRegistryInterface $registry, - ReferenceProviderInterface $referenceProvider, - Configuration $configuration, - ToolCallerInterface $toolCaller, - ResourceReaderInterface $resourceReader, - PromptGetterInterface $promptGetter, - SessionStoreInterface $sessionStore, - SessionFactoryInterface $sessionFactory, - LoggerInterface $logger = new NullLogger(), - int $paginationLimit = 50, - ): self { - return new self( - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - methodHandlers: [ - new Notification\InitializedHandler(), - new Handler\Request\InitializeHandler($configuration), - new Handler\Request\PingHandler(), - new Handler\Request\ListPromptsHandler($referenceProvider, $paginationLimit), - new Handler\Request\GetPromptHandler($promptGetter), - new Handler\Request\ListResourcesHandler($referenceProvider, $paginationLimit), - new Handler\Request\ReadResourceHandler($resourceReader), - new Handler\Request\ListResourceTemplatesHandler($referenceProvider, $paginationLimit), - new Handler\Request\CallToolHandler($toolCaller, $logger), - new Handler\Request\ListToolsHandler($referenceProvider, $paginationLimit), - new Handler\Request\CompletionCompleteHandler(new Completer($referenceProvider)), - ], - logger: $logger, - ); } /** diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 9e83037e..d9b36066 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -11,12 +11,16 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\ExceptionInterface; +use Mcp\Exception\ToolCallException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Result\CallToolResult; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; @@ -29,7 +33,8 @@ final class CallToolHandler implements MethodHandlerInterface { public function __construct( - private readonly ToolCallerInterface $toolCaller, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -43,20 +48,44 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter { \assert($message instanceof CallToolRequest); + $toolName = $message->name; + $arguments = $message->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + try { - $content = $this->toolCaller->call($message); - } catch (ExceptionInterface $exception) { - $this->logger->error( - \sprintf('Error while executing tool "%s": "%s".', $message->name, $exception->getMessage()), - [ - 'tool' => $message->name, - 'arguments' => $message->arguments, - ], - ); + $reference = $this->referenceProvider->getTool($toolName); + if (null === $reference) { + throw new ToolNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, $arguments); + $formatted = $reference->formatResult($result); + + $this->logger->debug('Tool executed successfully', [ + 'name' => $toolName, + 'result_type' => \gettype($result), + ]); + + return new Response($message->getId(), new CallToolResult($formatted)); + } catch (ToolNotFoundException $e) { + $this->logger->error('Tool not found', ['name' => $toolName]); + + return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + } catch (ToolCallException|ExceptionInterface $e) { + $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ + 'tool' => $toolName, + 'arguments' => $arguments, + ]); return Error::forInternalError('Error while executing tool', $message->getId()); - } + } catch (\Throwable $e) { + $this->logger->error('Unhandled error during tool execution', [ + 'name' => $toolName, + 'exception' => $e->getMessage(), + ]); - return new Response($message->getId(), $content); + return Error::forInternalError('Error while executing tool', $message->getId()); + } } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index ef4b3020..be4d67d1 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -11,14 +11,16 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Completion\CompleterInterface; -use Mcp\Exception\ExceptionInterface; +use Mcp\Capability\Completion\ProviderInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Container\ContainerInterface; /** * Handles completion/complete requests. @@ -28,7 +30,8 @@ final class CompletionCompleteHandler implements MethodHandlerInterface { public function __construct( - private readonly CompleterInterface $completer, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ?ContainerInterface $container = null, ) { } @@ -41,12 +44,45 @@ public function handle(CompletionCompleteRequest|HasMethodInterface $message, Se { \assert($message instanceof CompletionCompleteRequest); + $name = $message->argument['name'] ?? ''; + $value = $message->argument['value'] ?? ''; + + $reference = match ($message->ref->type) { + 'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name), + 'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri), + default => null, + }; + + if (null === $reference) { + return new Response($message->getId(), new CompletionCompleteResult([])); + } + + $providers = $reference->completionProviders; + $provider = $providers[$name] ?? null; + if (null === $provider) { + return new Response($message->getId(), new CompletionCompleteResult([])); + } + + if (\is_string($provider)) { + if (!class_exists($provider)) { + return Error::forInternalError('Invalid completion provider', $message->getId()); + } + $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); + } + + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $message->getId()); + } + try { - $result = $this->completer->complete($message); - } catch (ExceptionInterface) { + $completions = $provider->getCompletions($value); + $total = \count($completions); + $hasMore = $total > 100; + $paged = \array_slice($completions, 0, 100); + + return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); + } catch (\Throwable) { return Error::forInternalError('Error while handling completion request', $message->getId()); } - - return new Response($message->getId(), $result); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 07a5bebf..758ab9de 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -11,14 +11,20 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\ExceptionInterface; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Tobias Nyholm @@ -26,7 +32,9 @@ final class GetPromptHandler implements MethodHandlerInterface { public function __construct( - private readonly PromptGetterInterface $promptGetter, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -39,12 +47,32 @@ public function handle(GetPromptRequest|HasMethodInterface $message, SessionInte { \assert($message instanceof GetPromptRequest); + $promptName = $message->name; + $arguments = $message->arguments ?? []; + try { - $messages = $this->promptGetter->get($message); - } catch (ExceptionInterface) { + $reference = $this->referenceProvider->getPrompt($promptName); + if (null === $reference) { + throw new PromptNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, $arguments); + + $formatted = $reference->formatResult($result); + + return new Response($message->getId(), new GetPromptResult($formatted)); + } catch (PromptNotFoundException $e) { + $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); + + return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + } catch (PromptGetException|ExceptionInterface $e) { + $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); + return Error::forInternalError('Error while handling prompt', $message->getId()); - } + } catch (\Throwable $e) { + $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return new Response($message->getId(), $messages); + return Error::forInternalError('Error while handling prompt', $message->getId()); + } } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index fd208917..6691021a 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -11,15 +11,19 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Capability\Resource\ResourceReaderInterface; -use Mcp\Exception\ExceptionInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\HasMethodInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * @author Tobias Nyholm @@ -27,7 +31,9 @@ final class ReadResourceHandler implements MethodHandlerInterface { public function __construct( - private readonly ResourceReaderInterface $resourceReader, + private readonly ReferenceProviderInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -40,14 +46,33 @@ public function handle(ReadResourceRequest|HasMethodInterface $message, SessionI { \assert($message instanceof ReadResourceRequest); + $uri = $message->uri; + + $this->logger->debug('Reading resource', ['uri' => $uri]); + try { - $contents = $this->resourceReader->read($message); + $reference = $this->referenceProvider->getResource($uri); + if (null === $reference) { + throw new ResourceNotFoundException($message); + } + + $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); + + if ($reference instanceof ResourceTemplateReference) { + $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); + } else { + $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); + } + + return new Response($message->getId(), new ReadResourceResult($formatted)); } catch (ResourceNotFoundException $e) { + $this->logger->error('Resource not found', ['uri' => $uri]); + return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); - } catch (ExceptionInterface) { + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + return Error::forInternalError('Error while reading resource', $message->getId()); } - - return new Response($message->getId(), $contents); } } diff --git a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php deleted file mode 100644 index 6321fdc6..00000000 --- a/tests/Unit/Capability/Prompt/Completion/EnumCompletionProviderTest.php +++ /dev/null @@ -1,85 +0,0 @@ -getCompletions(''); - $this->assertSame(['draft', 'published', 'archived'], $result); - } - - public function testCreatesProviderFromIntBackedEnumUsingNames() - { - $provider = new EnumCompletionProvider(PriorityEnum::class); - $result = $provider->getCompletions(''); - - $this->assertSame(['LOW', 'MEDIUM', 'HIGH'], $result); - } - - public function testCreatesProviderFromUnitEnumUsingNames() - { - $provider = new EnumCompletionProvider(UnitEnum::class); - $result = $provider->getCompletions(''); - - $this->assertSame(['Yes', 'No'], $result); - } - - public function testFiltersStringEnumValuesByPrefix() - { - $provider = new EnumCompletionProvider(StatusEnum::class); - $result = $provider->getCompletions('ar'); - - $this->assertEquals(['archived'], $result); - } - - public function testFiltersUnitEnumValuesByPrefix() - { - $provider = new EnumCompletionProvider(UnitEnum::class); - $result = $provider->getCompletions('Y'); - - $this->assertSame(['Yes'], $result); - } - - public function testReturnsEmptyArrayWhenNoValuesMatchPrefix() - { - $provider = new EnumCompletionProvider(StatusEnum::class); - $result = $provider->getCompletions('xyz'); - - $this->assertSame([], $result); - } - - public function testThrowsExceptionForNonEnumClass() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Class "stdClass" is not an enum.'); - - new EnumCompletionProvider(\stdClass::class); - } - - public function testThrowsExceptionForNonExistentClass() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Class "NonExistentClass" is not an enum.'); - - new EnumCompletionProvider('NonExistentClass'); /* @phpstan-ignore argument.type */ - } -} diff --git a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php deleted file mode 100644 index dd3439d8..00000000 --- a/tests/Unit/Capability/Prompt/Completion/ListCompletionProviderTest.php +++ /dev/null @@ -1,80 +0,0 @@ -getCompletions(''); - - $this->assertSame($values, $result); - } - - public function testFiltersValuesBasedOnCurrentValuePrefix() - { - $values = ['apple', 'apricot', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('ap'); - - $this->assertSame(['apple', 'apricot'], $result); - } - - public function testReturnsEmptyArrayWhenNoValuesMatch() - { - $values = ['apple', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('xyz'); - - $this->assertSame([], $result); - } - - public function testWorksWithSingleCharacterPrefix() - { - $values = ['apple', 'banana', 'cherry']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('a'); - - $this->assertSame(['apple'], $result); - } - - public function testIsCaseSensitiveByDefault() - { - $values = ['Apple', 'apple', 'APPLE']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions('A'); - - $this->assertEquals(['Apple', 'APPLE'], $result); - } - - public function testHandlesEmptyValuesArray() - { - $provider = new ListCompletionProvider([]); - $result = $provider->getCompletions('test'); - - $this->assertSame([], $result); - } - - public function testPreservesArrayOrder() - { - $values = ['zebra', 'apple', 'banana']; - $provider = new ListCompletionProvider($values); - $result = $provider->getCompletions(''); - - $this->assertSame(['zebra', 'apple', 'banana'], $result); - } -} diff --git a/tests/Unit/Capability/Prompt/PromptGetterTest.php b/tests/Unit/Capability/Prompt/PromptGetterTest.php deleted file mode 100644 index 874dbe3c..00000000 --- a/tests/Unit/Capability/Prompt/PromptGetterTest.php +++ /dev/null @@ -1,638 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - - $this->promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - ); - } - - public function testGetExecutesPromptSuccessfully(): void - { - $request = new GetPromptRequest('test_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('test_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'test result'); - $handlerResult = [ - 'role' => 'user', - 'content' => 'Generated prompt content', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('test_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertInstanceOf(PromptMessage::class, $result->messages[0]); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertInstanceOf(TextContent::class, $result->messages[0]->content); - $this->assertEquals('Generated prompt content', $result->messages[0]->content->text); - } - - public function testGetWithEmptyArguments(): void - { - $request = new GetPromptRequest('empty_args_prompt', []); - $prompt = $this->createValidPrompt('empty_args_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Empty args content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_args_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn([ - 'role' => 'user', - 'content' => 'Empty args content', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetWithComplexArguments(): void - { - $arguments = [ - 'string_param' => 'value', - 'int_param' => 42, - 'bool_param' => true, - 'array_param' => ['nested' => 'data'], - 'null_param' => null, - ]; - $request = new GetPromptRequest('complex_prompt', $arguments); - $prompt = $this->createValidPrompt('complex_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Complex content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('complex_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, $arguments) - ->willReturn([ - 'role' => 'assistant', - 'content' => 'Complex response', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetThrowsInvalidArgumentExceptionWhenPromptNotFound(): void - { - $request = new GetPromptRequest('nonexistent_prompt', ['param' => 'value']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('nonexistent_prompt') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->expectException(PromptNotFoundException::class); - $this->expectExceptionMessage('Prompt not found for name: "nonexistent_prompt".'); - - $this->promptGetter->get($request); - } - - public function testGetThrowsRegistryExceptionWhenHandlerFails(): void - { - $request = new GetPromptRequest('failing_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('failing_prompt'); - $promptReference = new PromptReference($prompt, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = RegistryException::internalError('Handler failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('failing_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willThrowException($handlerException); - - $this->expectException(PromptGetException::class); - - $this->promptGetter->get($request); - } - - public function testGetHandlesJsonExceptionDuringFormatting(): void - { - $request = new GetPromptRequest('json_error_prompt', []); - $prompt = $this->createValidPrompt('json_error_prompt'); - - // Create a mock PromptReference that will throw JsonException during formatResult - $promptReference = $this->createMock(PromptReference::class); - $promptReference->expects($this->once()) - ->method('formatResult') - ->willThrowException(new \JsonException('JSON encoding failed')); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('json_error_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn('some result'); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('JSON encoding failed'); - - $this->promptGetter->get($request); - } - - public function testGetHandlesArrayOfMessages(): void - { - $request = new GetPromptRequest('multi_message_prompt', ['context' => 'test']); - $prompt = $this->createValidPrompt('multi_message_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Multiple messages'); - $handlerResult = [ - [ - 'role' => 'user', - 'content' => 'First message', - ], - [ - 'role' => 'assistant', - 'content' => 'Second message', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('multi_message_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['context' => 'test']) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('First message', $result->messages[0]->content->text); - $this->assertEquals(Role::Assistant, $result->messages[1]->role); - $this->assertEquals('Second message', $result->messages[1]->content->text); - } - - public function testGetHandlesPromptMessageObjects(): void - { - $request = new GetPromptRequest('prompt_message_prompt', []); - $prompt = $this->createValidPrompt('prompt_message_prompt'); - $promptMessage = new PromptMessage( - Role::User, - new TextContent('Direct prompt message') - ); - $promptReference = new PromptReference($prompt, fn () => $promptMessage); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('prompt_message_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($promptMessage); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertSame($promptMessage, $result->messages[0]); - } - - public function testGetHandlesUserAssistantStructure(): void - { - $request = new GetPromptRequest('user_assistant_prompt', []); - $prompt = $this->createValidPrompt('user_assistant_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Conversation content'); - $handlerResult = [ - 'user' => 'What is the weather?', - 'assistant' => 'I can help you check the weather.', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('user_assistant_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('What is the weather?', $result->messages[0]->content->text); - $this->assertEquals(Role::Assistant, $result->messages[1]->role); - $this->assertEquals('I can help you check the weather.', $result->messages[1]->content->text); - } - - public function testGetHandlesEmptyArrayResult(): void - { - $request = new GetPromptRequest('empty_array_prompt', []); - $prompt = $this->createValidPrompt('empty_array_prompt'); - $promptReference = new PromptReference($prompt, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('empty_array_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn([]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(0, $result->messages); - } - - public function testGetWithTypedContentStructure(): void - { - $request = new GetPromptRequest('typed_content_prompt', []); - $prompt = $this->createValidPrompt('typed_content_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Typed content'); - $handlerResult = [ - 'role' => 'user', - 'content' => [ - 'type' => 'text', - 'text' => 'Typed text content', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('typed_content_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertEquals(Role::User, $result->messages[0]->role); - $this->assertEquals('Typed text content', $result->messages[0]->content->text); - } - - public function testGetWithPromptReferenceHavingCompletionProviders(): void - { - $request = new GetPromptRequest('completion_prompt', ['param' => 'value']); - $prompt = $this->createValidPrompt('completion_prompt'); - $completionProviders = ['param' => EnumCompletionProvider::class]; - $promptReference = new PromptReference( - $prompt, - fn () => 'Completion content', - false, - $completionProviders - ); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('completion_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, ['param' => 'value']) - ->willReturn([ - 'role' => 'user', - 'content' => 'Completion content', - ]); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - } - - public function testGetHandlesMixedMessageArray(): void - { - $request = new GetPromptRequest('mixed_prompt', []); - $prompt = $this->createValidPrompt('mixed_prompt'); - $promptMessage = new PromptMessage(Role::Assistant, new TextContent('Direct message')); - $promptReference = new PromptReference($prompt, fn () => 'Mixed content'); - $handlerResult = [ - $promptMessage, - [ - 'role' => 'user', - 'content' => 'Regular message', - ], - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('mixed_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(2, $result->messages); - $this->assertSame($promptMessage, $result->messages[0]); - $this->assertEquals(Role::User, $result->messages[1]->role); - $this->assertEquals('Regular message', $result->messages[1]->content->text); - } - - public function testGetReflectsFormattedMessagesCorrectly(): void - { - $request = new GetPromptRequest('format_test_prompt', []); - $prompt = $this->createValidPrompt('format_test_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Format test'); - - // Test that the formatted result from PromptReference.formatResult is properly returned - $handlerResult = [ - 'role' => 'user', - 'content' => 'Test formatting', - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('format_test_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($handlerResult); - - $result = $this->promptGetter->get($request); - - $this->assertInstanceOf(GetPromptResult::class, $result); - $this->assertCount(1, $result->messages); - $this->assertEquals('Test formatting', $result->messages[0]->content->text); - $this->assertEquals(Role::User, $result->messages[0]->role); - } - - /** - * Test that invalid handler results throw RuntimeException from PromptReference.formatResult(). - */ - public function testGetThrowsRuntimeExceptionForInvalidHandlerResult(): void - { - $request = new GetPromptRequest('invalid_prompt', []); - $prompt = $this->createValidPrompt('invalid_prompt'); - $promptReference = new PromptReference($prompt, fn () => 'Invalid content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('invalid_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn('This is not a valid prompt format'); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that null result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForNullHandlerResult(): void - { - $request = new GetPromptRequest('null_prompt', []); - $prompt = $this->createValidPrompt('null_prompt'); - $promptReference = new PromptReference($prompt, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('null_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(null); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that scalar result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForScalarHandlerResult(): void - { - $request = new GetPromptRequest('scalar_prompt', []); - $prompt = $this->createValidPrompt('scalar_prompt'); - $promptReference = new PromptReference($prompt, fn () => 42); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('scalar_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(42); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that boolean result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForBooleanHandlerResult(): void - { - $request = new GetPromptRequest('boolean_prompt', []); - $prompt = $this->createValidPrompt('boolean_prompt'); - $promptReference = new PromptReference($prompt, fn () => true); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('boolean_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn(true); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - /** - * Test that object result from handler throws RuntimeException. - */ - public function testGetThrowsRuntimeExceptionForObjectHandlerResult(): void - { - $request = new GetPromptRequest('object_prompt', []); - $prompt = $this->createValidPrompt('object_prompt'); - $objectResult = new \stdClass(); - $objectResult->property = 'value'; - $promptReference = new PromptReference($prompt, fn () => $objectResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getPrompt') - ->with('object_prompt') - ->willReturn($promptReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($promptReference, []) - ->willReturn($objectResult); - - $this->expectException(PromptGetException::class); - $this->expectExceptionMessage('Prompt generator method must return an array of messages.'); - - $this->promptGetter->get($request); - } - - public function testConstructorWithDefaultLogger(): void - { - $promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - ); - - $this->assertInstanceOf(PromptGetter::class, $promptGetter); - } - - public function testConstructorWithCustomLogger(): void - { - $logger = $this->createMock(LoggerInterface::class); - $promptGetter = new PromptGetter( - $this->referenceProvider, - $this->referenceHandler, - $logger, - ); - - $this->assertInstanceOf(PromptGetter::class, $promptGetter); - } - - private function createValidPrompt(string $name): Prompt - { - return new Prompt( - name: $name, - description: "Test prompt: {$name}", - arguments: null, - ); - } -} diff --git a/tests/Unit/Capability/Resource/ResourceReaderTest.php b/tests/Unit/Capability/Resource/ResourceReaderTest.php deleted file mode 100644 index 73c967ff..00000000 --- a/tests/Unit/Capability/Resource/ResourceReaderTest.php +++ /dev/null @@ -1,522 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - - $this->resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - ); - } - - public function testReadResourceSuccessfullyWithStringResult(): void - { - $request = new ReadResourceRequest('file://test.txt'); - $resource = $this->createValidResource('file://test.txt', 'test', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => 'test content'); - $handlerResult = 'test content'; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('file://test.txt') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'file://test.txt']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('test content', $result->contents[0]->text); - $this->assertEquals('file://test.txt', $result->contents[0]->uri); - $this->assertEquals('text/plain', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithArrayResult(): void - { - $request = new ReadResourceRequest('api://data'); - $resource = $this->createValidResource('api://data', 'data', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => ['key' => 'value', 'count' => 42]); - $handlerResult = ['key' => 'value', 'count' => 42]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('api://data') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'api://data']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertJsonStringEqualsJsonString( - json_encode($handlerResult, \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - $this->assertEquals('api://data', $result->contents[0]->uri); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithBlobResult(): void - { - $request = new ReadResourceRequest('file://image.png'); - $resource = $this->createValidResource('file://image.png', 'image', 'image/png'); - - $handlerResult = [ - 'blob' => base64_encode('binary data'), - 'mimeType' => 'image/png', - ]; - - $resourceReference = new ResourceReference($resource, fn () => $handlerResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('file://image.png') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'file://image.png']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(BlobResourceContents::class, $result->contents[0]); - $this->assertEquals(base64_encode('binary data'), $result->contents[0]->blob); - $this->assertEquals('file://image.png', $result->contents[0]->uri); - $this->assertEquals('image/png', $result->contents[0]->mimeType); - } - - public function testReadResourceSuccessfullyWithResourceContentResult(): void - { - $request = new ReadResourceRequest('custom://resource'); - $resource = $this->createValidResource('custom://resource', 'resource', 'text/plain'); - $textContent = new TextResourceContents('custom://resource', 'text/plain', 'direct content'); - $resourceReference = new ResourceReference($resource, fn () => $textContent); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('custom://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'custom://resource']) - ->willReturn($textContent); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertSame($textContent, $result->contents[0]); - } - - public function testReadResourceSuccessfullyWithMultipleContentResults(): void - { - $request = new ReadResourceRequest('multi://content'); - $resource = $this->createValidResource('multi://content', 'content', 'application/json'); - $content1 = new TextResourceContents('multi://content', 'text/plain', 'first content'); - $content2 = new TextResourceContents('multi://content', 'text/plain', 'second content'); - $handlerResult = [$content1, $content2]; - $resourceReference = new ResourceReference($resource, fn () => $handlerResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('multi://content') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'multi://content']) - ->willReturn($handlerResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(2, $result->contents); - $this->assertSame($content1, $result->contents[0]); - $this->assertSame($content2, $result->contents[1]); - } - - public function testReadResourceTemplate(): void - { - $request = new ReadResourceRequest('users://123'); - $resourceTemplate = $this->createValidResourceTemplate('users://{id}', 'user_template'); - $templateReference = new ResourceTemplateReference( - $resourceTemplate, - fn () => ['id' => 123, 'name' => 'Test User'], - ); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('users://123') - ->willReturn($templateReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($templateReference, ['uri' => 'users://123']) - ->willReturn(['id' => 123, 'name' => 'Test User']); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertJsonStringEqualsJsonString( - json_encode(['id' => 123, 'name' => 'Test User'], \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - } - - public function testReadResourceThrowsExceptionWhenResourceNotFound(): void - { - $request = new ReadResourceRequest('nonexistent://resource'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('nonexistent://resource') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->expectException(ResourceNotFoundException::class); - $this->expectExceptionMessage('Resource not found for uri: "nonexistent://resource".'); - - $this->resourceReader->read($request); - } - - public function testReadResourceThrowsRegistryExceptionWhenHandlerFails(): void - { - $request = new ReadResourceRequest('failing://resource'); - $resource = $this->createValidResource('failing://resource', 'failing', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = new RegistryException('Handler execution failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('failing://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'failing://resource']) - ->willThrowException($handlerException); - - $this->expectException(ResourceReadException::class); - $this->expectExceptionMessage('Handler execution failed'); - - $this->resourceReader->read($request); - } - - public function testReadResourcePassesCorrectArgumentsToHandler(): void - { - $request = new ReadResourceRequest('test://resource'); - $resource = $this->createValidResource('test://resource', 'test', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => 'test'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('test://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with( - $this->identicalTo($resourceReference), - $this->equalTo(['uri' => 'test://resource']), - ) - ->willReturn('test'); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - } - - public function testReadResourceWithEmptyStringResult(): void - { - $request = new ReadResourceRequest('empty://resource'); - $resource = $this->createValidResource('empty://resource', 'empty', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => ''); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('empty://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'empty://resource']) - ->willReturn(''); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('', $result->contents[0]->text); - } - - public function testReadResourceWithEmptyArrayResult(): void - { - $request = new ReadResourceRequest('empty://array'); - $resource = $this->createValidResource('empty://array', 'array', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('empty://array') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'empty://array']) - ->willReturn([]); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('[]', $result->contents[0]->text); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - } - - public function testReadResourceWithNullResult(): void - { - $request = new ReadResourceRequest('null://resource'); - $resource = $this->createValidResource('null://resource', 'null', 'text/plain'); - $resourceReference = new ResourceReference($resource, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('null://resource') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'null://resource']) - ->willReturn(null); - - // The formatResult method in ResourceReference should handle null values - $this->expectException(\RuntimeException::class); - - $this->resourceReader->read($request); - } - - public function testReadResourceWithDifferentMimeTypes(): void - { - $request = new ReadResourceRequest('xml://data'); - $resource = $this->createValidResource('xml://data', 'data', 'application/xml'); - $resourceReference = new ResourceReference($resource, fn () => 'value'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('xml://data') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'xml://data']) - ->willReturn('value'); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - // The MIME type should be guessed from content since formatResult handles the conversion - $this->assertEquals('value', $result->contents[0]->text); - } - - public function testReadResourceWithJsonMimeTypeAndArrayResult(): void - { - $request = new ReadResourceRequest('api://json'); - $resource = $this->createValidResource('api://json', 'json', 'application/json'); - $resourceReference = new ResourceReference($resource, fn () => ['formatted' => true, 'data' => [1, 2, 3]]); - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('api://json') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'api://json']) - ->willReturn(['formatted' => true, 'data' => [1, 2, 3]]); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertCount(1, $result->contents); - $this->assertInstanceOf(TextResourceContents::class, $result->contents[0]); - $this->assertEquals('application/json', $result->contents[0]->mimeType); - $this->assertJsonStringEqualsJsonString( - json_encode(['formatted' => true, 'data' => [1, 2, 3]], \JSON_PRETTY_PRINT), - $result->contents[0]->text, - ); - } - - public function testReadResourceCallsFormatResultOnReference(): void - { - $request = new ReadResourceRequest('format://test'); - $resource = $this->createValidResource('format://test', 'format', 'text/plain'); - - // Create a mock ResourceReference to verify formatResult is called - $resourceReference = $this - ->getMockBuilder(ResourceReference::class) - ->setConstructorArgs([$resource, fn () => 'test', false]) - ->onlyMethods(['formatResult']) - ->getMock(); - - $handlerResult = 'test result'; - $formattedResult = [new TextResourceContents('format://test', 'text/plain', 'formatted content')]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getResource') - ->with('format://test') - ->willReturn($resourceReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($resourceReference, ['uri' => 'format://test']) - ->willReturn($handlerResult); - - $resourceReference - ->expects($this->once()) - ->method('formatResult') - ->with($handlerResult, 'format://test') - ->willReturn($formattedResult); - - $result = $this->resourceReader->read($request); - - $this->assertInstanceOf(ReadResourceResult::class, $result); - $this->assertSame($formattedResult, $result->contents); - } - - public function testConstructorWithDefaultLogger(): void - { - $resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - ); - - $this->assertInstanceOf(ResourceReader::class, $resourceReader); - } - - public function testConstructorWithCustomLogger(): void - { - $logger = $this->createMock(LoggerInterface::class); - $resourceReader = new ResourceReader( - $this->referenceProvider, - $this->referenceHandler, - $logger, - ); - - $this->assertInstanceOf(ResourceReader::class, $resourceReader); - } - - private function createValidResource(string $uri, string $name, ?string $mimeType = null): Resource - { - return new Resource( - uri: $uri, - name: $name, - description: "Test resource: {$name}", - mimeType: $mimeType, - size: null, - annotations: null, - ); - } - - private function createValidResourceTemplate( - string $uriTemplate, - string $name, - ?string $mimeType = null, - ): ResourceTemplate { - return new ResourceTemplate( - uriTemplate: $uriTemplate, - name: $name, - description: "Test resource template: {$name}", - mimeType: $mimeType, - annotations: null, - ); - } -} diff --git a/tests/Unit/Capability/Tool/ToolCallerTest.php b/tests/Unit/Capability/Tool/ToolCallerTest.php deleted file mode 100644 index 889c2ec4..00000000 --- a/tests/Unit/Capability/Tool/ToolCallerTest.php +++ /dev/null @@ -1,628 +0,0 @@ -referenceProvider = $this->createMock(ReferenceProviderInterface::class); - $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->toolCaller = new ToolCaller( - $this->referenceProvider, - $this->referenceHandler, - $this->logger, - ); - } - - public function testCallExecutesToolSuccessfully(): void - { - $request = new CallToolRequest('test_tool', ['param' => 'value']); - $tool = $this->createValidTool('test_tool'); - $toolReference = new ToolReference($tool, fn () => 'test result'); - $handlerResult = 'test result'; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('test_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value']) - ->willReturn($handlerResult); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug') - ->with( - $this->logicalOr( - $this->equalTo('Executing tool'), - $this->equalTo('Tool executed successfully') - ), - $this->logicalOr( - $this->equalTo(['name' => 'test_tool', 'arguments' => ['param' => 'value']]), - $this->equalTo(['name' => 'test_tool', 'result_type' => 'string']) - ) - ); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('test result', $result->content[0]->text); - $this->assertFalse($result->isError); - } - - public function testCallWithEmptyArguments(): void - { - $request = new CallToolRequest('test_tool', []); - $tool = $this->createValidTool('test_tool'); - $toolReference = new ToolReference($tool, fn () => 'result'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('test_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn('result'); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallWithComplexArguments(): void - { - $arguments = [ - 'string_param' => 'value', - 'int_param' => 42, - 'bool_param' => true, - 'array_param' => ['nested' => 'data'], - 'null_param' => null, - ]; - $request = new CallToolRequest('complex_tool', $arguments); - $tool = $this->createValidTool('complex_tool'); - $toolReference = new ToolReference($tool, fn () => ['processed' => true]); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('complex_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, $arguments) - ->willReturn(['processed' => true]); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - } - - public function testCallThrowsToolNotFoundExceptionWhenToolNotFound(): void - { - $request = new CallToolRequest('nonexistent_tool', ['param' => 'value']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('nonexistent_tool') - ->willReturn(null); - - $this->referenceHandler - ->expects($this->never()) - ->method('handle'); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Executing tool', ['name' => 'nonexistent_tool', 'arguments' => ['param' => 'value']]); - - $this->logger - ->expects($this->once()) - ->method('warning') - ->with('Tool not found', ['name' => 'nonexistent_tool']); - - $this->expectException(ToolNotFoundException::class); - $this->expectExceptionMessage('Tool not found for call: "nonexistent_tool".'); - - $this->toolCaller->call($request); - } - - public function testCallThrowsToolExecutionExceptionWhenHandlerThrowsException(): void - { - $request = new CallToolRequest('failing_tool', ['param' => 'value']); - $tool = $this->createValidTool('failing_tool'); - $toolReference = new ToolReference($tool, fn () => throw new \RuntimeException('Handler failed')); - $handlerException = new \RuntimeException('Handler failed'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('failing_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, ['param' => 'value']) - ->willThrowException($handlerException); - - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Executing tool', ['name' => 'failing_tool', 'arguments' => ['param' => 'value']]); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Tool execution failed', - $this->callback(function ($context) { - return 'failing_tool' === $context['name'] - && 'Handler failed' === $context['exception'] - && isset($context['trace']); - }) - ); - - $this->expectException(ToolCallException::class); - $this->expectExceptionMessage('Tool call "failing_tool" failed with error: "Handler failed".'); - - $thrownException = null; - try { - $this->toolCaller->call($request); - } catch (ToolCallException $e) { - $thrownException = $e; - throw $e; - } finally { - if ($thrownException) { - $this->assertSame($request, $thrownException->request); - $this->assertSame($handlerException, $thrownException->getPrevious()); - } - } - } - - public function testCallHandlesNullResult(): void - { - $request = new CallToolRequest('null_tool', []); - $tool = $this->createValidTool('null_tool'); - $toolReference = new ToolReference($tool, fn () => null); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('null_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(null); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('(null)', $result->content[0]->text); - } - - public function testCallHandlesBooleanResults(): void - { - $request = new CallToolRequest('bool_tool', []); - $tool = $this->createValidTool('bool_tool'); - $toolReference = new ToolReference($tool, fn () => true); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('bool_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(true); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('true', $result->content[0]->text); - } - - public function testCallHandlesArrayResults(): void - { - $request = new CallToolRequest('array_tool', []); - $tool = $this->createValidTool('array_tool'); - $toolReference = new ToolReference($tool, fn () => ['key' => 'value', 'number' => 42]); - $arrayResult = ['key' => 'value', 'number' => 42]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($arrayResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertJsonStringEqualsJsonString( - json_encode($arrayResult, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE), - $result->content[0]->text - ); - } - - public function testCallHandlesContentObjectResults(): void - { - $request = new CallToolRequest('content_tool', []); - $tool = $this->createValidTool('content_tool'); - $toolReference = new ToolReference($tool, fn () => new TextContent('Direct content')); - $contentResult = new TextContent('Direct content'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('content_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($contentResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertSame($contentResult, $result->content[0]); - } - - public function testCallHandlesArrayOfContentResults(): void - { - $request = new CallToolRequest('content_array_tool', []); - $tool = $this->createValidTool('content_array_tool'); - $toolReference = new ToolReference($tool, fn () => [ - new TextContent('First content'), - new TextContent('Second content'), - ]); - $contentArray = [ - new TextContent('First content'), - new TextContent('Second content'), - ]; - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('content_array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($contentArray); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(2, $result->content); - $this->assertSame($contentArray[0], $result->content[0]); - $this->assertSame($contentArray[1], $result->content[1]); - } - - public function testCallWithDifferentExceptionTypes(): void - { - $request = new CallToolRequest('error_tool', []); - $tool = $this->createValidTool('error_tool'); - $toolReference = new ToolReference($tool, fn () => throw new \InvalidArgumentException('Invalid input')); - $handlerException = new \InvalidArgumentException('Invalid input'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('error_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willThrowException($handlerException); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Tool execution failed', - $this->callback(function ($context) { - return 'error_tool' === $context['name'] - && 'Invalid input' === $context['exception'] - && isset($context['trace']); - }) - ); - - $this->expectException(ToolCallException::class); - $this->expectExceptionMessage('Tool call "error_tool" failed with error: "Invalid input".'); - - $this->toolCaller->call($request); - } - - public function testCallLogsResultTypeCorrectlyForString(): void - { - $request = new CallToolRequest('string_tool', []); - $tool = $this->createValidTool('string_tool'); - $toolReference = new ToolReference($tool, fn () => 'string result'); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('string_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn('string result'); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallLogsResultTypeCorrectlyForInteger(): void - { - $request = new CallToolRequest('int_tool', []); - $tool = $this->createValidTool('int_tool'); - $toolReference = new ToolReference($tool, fn () => 42); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('int_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(42); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testCallLogsResultTypeCorrectlyForArray(): void - { - $request = new CallToolRequest('array_tool', []); - $tool = $this->createValidTool('array_tool'); - $toolReference = new ToolReference($tool, fn () => ['test']); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(['test']); - - $this->logger - ->expects($this->exactly(2)) - ->method('debug'); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - } - - public function testConstructorWithDefaultLogger(): void - { - $executor = new ToolCaller($this->referenceProvider, $this->referenceHandler); - - // Verify it's constructed without throwing exceptions - $this->assertInstanceOf(ToolCaller::class, $executor); - } - - public function testCallHandlesEmptyArrayResult(): void - { - $request = new CallToolRequest('empty_array_tool', []); - $tool = $this->createValidTool('empty_array_tool'); - $toolReference = new ToolReference($tool, fn () => []); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('empty_array_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn([]); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('[]', $result->content[0]->text); - } - - public function testCallHandlesMixedContentAndNonContentArray(): void - { - $request = new CallToolRequest('mixed_tool', []); - $tool = $this->createValidTool('mixed_tool'); - $mixedResult = [ - new TextContent('First content'), - 'plain string', - 42, - new TextContent('Second content'), - ]; - $toolReference = new ToolReference($tool, fn () => $mixedResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('mixed_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($mixedResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - // The ToolReference.formatResult should handle this mixed array - $this->assertGreaterThan(1, \count($result->content)); - } - - public function testCallHandlesStdClassResult(): void - { - $request = new CallToolRequest('object_tool', []); - $tool = $this->createValidTool('object_tool'); - $objectResult = new \stdClass(); - $objectResult->property = 'value'; - $toolReference = new ToolReference($tool, fn () => $objectResult); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('object_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn($objectResult); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertStringContainsString('"property": "value"', $result->content[0]->text); - } - - public function testCallHandlesBooleanFalseResult(): void - { - $request = new CallToolRequest('false_tool', []); - $tool = $this->createValidTool('false_tool'); - $toolReference = new ToolReference($tool, fn () => false); - - $this->referenceProvider - ->expects($this->once()) - ->method('getTool') - ->with('false_tool') - ->willReturn($toolReference); - - $this->referenceHandler - ->expects($this->once()) - ->method('handle') - ->with($toolReference, []) - ->willReturn(false); - - $result = $this->toolCaller->call($request); - - $this->assertInstanceOf(CallToolResult::class, $result); - $this->assertCount(1, $result->content); - $this->assertInstanceOf(TextContent::class, $result->content[0]); - $this->assertEquals('false', $result->content[0]->text); - } - - private function createValidTool(string $name): Tool - { - return new Tool( - name: $name, - inputSchema: [ - 'type' => 'object', - 'properties' => [ - 'param' => ['type' => 'string'], - ], - 'required' => null, - ], - description: "Test tool: {$name}", - annotations: null, - ); - } -} diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php index 63c8e0f6..be9820ed 100644 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ b/tests/Unit/JsonRpc/HandlerTest.php @@ -57,10 +57,10 @@ public function testHandleMultipleNotifications() $sessionStore->method('exists')->willReturn(true); $jsonRpc = new JsonRpcHandler( - MessageFactory::make(), - $sessionFactory, - $sessionStore, - [$handlerA, $handlerB, $handlerC] + methodHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, ); $sessionId = Uuid::v4(); $result = $jsonRpc->process( @@ -103,10 +103,10 @@ public function testHandleMultipleRequests() $sessionStore->method('exists')->willReturn(true); $jsonRpc = new JsonRpcHandler( - MessageFactory::make(), - $sessionFactory, - $sessionStore, - [$handlerA, $handlerB, $handlerC] + methodHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, ); $sessionId = Uuid::v4(); $result = $jsonRpc->process( diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index da092c7e..4423cbd6 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Tool\ToolCallerInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ToolReference; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; @@ -28,18 +30,21 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ToolCallerInterface|MockObject $toolCaller; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private LoggerInterface|MockObject $logger; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->toolCaller = $this->createMock(ToolCallerInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->session = $this->createMock(SessionInterface::class); $this->handler = new CallToolHandler( - $this->toolCaller, + $this->referenceProvider, + $this->referenceHandler, $this->logger, ); } @@ -54,13 +59,26 @@ public function testSupportsCallToolRequest(): void public function testHandleSuccessfulToolCall(): void { $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); - $this->toolCaller + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('greet_user') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['name' => 'John']) + ->willReturn('Hello, John!'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Hello, John!') + ->willReturn([new TextContent('Hello, John!')]); $this->logger ->expects($this->never()) @@ -70,24 +88,37 @@ public function testHandleSuccessfulToolCall(): void $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Simple result')]); - $this->toolCaller + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('simple_tool') + ->willReturn($toolReference); + + $this->referenceHandler ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($toolReference, []) + ->willReturn('Simple result'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Simple result') + ->willReturn([new TextContent('Simple result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolCallWithComplexArguments(): void @@ -100,48 +131,52 @@ public function testHandleToolCallWithComplexArguments(): void 'null_param' => null, ]; $request = $this->createCallToolRequest('complex_tool', $arguments); + $toolReference = $this->createMock(ToolReference::class); $expectedResult = new CallToolResult([new TextContent('Complex result')]); - $this->toolCaller + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('complex_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn('Complex result'); + + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('Complex result') + ->willReturn([new TextContent('Complex result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleToolNotFoundExceptionReturnsError(): void { $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); - $exception = new ToolNotFoundException($request); - $this->toolCaller + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willThrowException($exception); + ->method('getTool') + ->with('nonexistent_tool') + ->willThrowException(new ToolNotFoundException($request)); $this->logger ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "nonexistent_tool": "Tool not found for call: "nonexistent_tool".".', - [ - 'tool' => 'nonexistent_tool', - 'arguments' => ['param' => 'value'], - ], - ); + ->method('error'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while executing tool', $response->message); + $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } public function testHandleToolExecutionExceptionReturnsError(): void @@ -149,29 +184,28 @@ public function testHandleToolExecutionExceptionReturnsError(): void $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value']) ->willThrowException($exception); $this->logger ->expects($this->once()) - ->method('error') - ->with( - 'Error while executing tool "failing_tool": "Tool call "failing_tool" failed with error: "Tool execution failed".".', - [ - 'tool' => 'failing_tool', - 'arguments' => ['param' => 'value'], - ], - ); + ->method('error'); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while executing tool', $response->message); } public function testHandleWithNullResult(): void @@ -179,39 +213,34 @@ public function testHandleWithNullResult(): void $request = $this->createCallToolRequest('null_tool', []); $expectedResult = new CallToolResult([]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('null_tool') + ->willReturn($toolReference); - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - } - - public function testHandleWithErrorResult(): void - { - $request = $this->createCallToolRequest('error_tool', []); - $expectedResult = CallToolResult::error([new TextContent('Tool error occurred')]); + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn(null); - $this->toolCaller + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with(null) + ->willReturn([]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertTrue($response->result->isError); + $this->assertEquals($expectedResult, $response->result); } public function testConstructorWithDefaultLogger(): void { - $handler = new CallToolHandler($this->toolCaller); + $handler = new CallToolHandler($this->referenceProvider, $this->referenceHandler); $this->assertInstanceOf(CallToolHandler::class, $handler); } @@ -221,9 +250,17 @@ public function testHandleLogsErrorWithCorrectParameters(): void $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler ->expects($this->once()) - ->method('call') + ->method('handle') + ->with($toolReference, ['key1' => 'value1', 'key2' => 42]) ->willThrowException($exception); $this->logger @@ -245,16 +282,29 @@ public function testHandleWithSpecialCharactersInToolName(): void $request = $this->createCallToolRequest('tool-with_special.chars', []); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('getTool') + ->with('tool-with_special.chars') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, []) + ->willReturn('Special tool result'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Special tool result') + ->willReturn([new TextContent('Special tool result')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleWithSpecialCharactersInArguments(): void @@ -267,16 +317,29 @@ public function testHandleWithSpecialCharactersInArguments(): void $request = $this->createCallToolRequest('unicode_tool', $arguments); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $this->toolCaller + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('unicode_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, $arguments) + ->willReturn('Unicode handled'); + + $toolReference ->expects($this->once()) - ->method('call') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('Unicode handled') + ->willReturn([new TextContent('Unicode handled')]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } /** diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 9fba1b29..3f5171b1 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Prompt\PromptGetterInterface; +use Mcp\Capability\Registry\PromptReference; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\Content\PromptMessage; @@ -29,15 +31,17 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; - private PromptGetterInterface|MockObject $promptGetter; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->promptGetter = $this->createMock(PromptGetterInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); - $this->handler = new GetPromptHandler($this->promptGetter); + $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler); } public function testSupportsGetPromptRequest(): void @@ -53,22 +57,33 @@ public function testHandleSuccessfulPromptGet(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Hello, how can I help you?')), ]; - $expectedResult = new GetPromptResult( - description: 'A greeting prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('greeting_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithArguments(): void @@ -85,21 +100,31 @@ public function testHandlePromptGetWithArguments(): void new TextContent('Good morning, John. How may I assist you in your business meeting?'), ), ]; - $expectedResult = new GetPromptResult( - description: 'A personalized greeting prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('personalized_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithNullArguments(): void @@ -108,21 +133,31 @@ public function testHandlePromptGetWithNullArguments(): void $expectedMessages = [ new PromptMessage(Role::Assistant, new TextContent('I am ready to help.')), ]; - $expectedResult = new GetPromptResult( - description: 'A simple prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('simple_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithEmptyArguments(): void @@ -131,21 +166,31 @@ public function testHandlePromptGetWithEmptyArguments(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Default message')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt with empty arguments', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_args_prompt') + ->willReturn($promptReference); - $this->promptGetter + $this->referenceHandler ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithMultipleMessages(): void @@ -156,22 +201,31 @@ public function testHandlePromptGetWithMultipleMessages(): void new PromptMessage(Role::Assistant, new TextContent('Hi there! How can I help you today?')), new PromptMessage(Role::User, new TextContent('I need assistance with my project')), ]; - $expectedResult = new GetPromptResult( - description: 'A conversation prompt', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('conversation_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(3, $response->result->messages); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptNotFoundExceptionReturnsError(): void @@ -179,18 +233,18 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $request = $this->createGetPromptRequest('nonexistent_prompt'); $exception = new PromptNotFoundException($request); - $this->promptGetter + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) + ->method('getPrompt') + ->with('nonexistent_prompt') ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while handling prompt', $response->message); + $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + $this->assertEquals('Prompt not found for name: "nonexistent_prompt".', $response->message); } public function testHandlePromptGetExceptionReturnsError(): void @@ -198,10 +252,10 @@ public function testHandlePromptGetExceptionReturnsError(): void $request = $this->createGetPromptRequest('failing_prompt'); $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); - $this->promptGetter + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) + ->method('getPrompt') + ->with('failing_prompt') ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -233,21 +287,31 @@ public function testHandlePromptGetWithComplexArguments(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Complex prompt generated with all parameters')), ]; - $expectedResult = new GetPromptResult( - description: 'A complex prompt with nested arguments', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('complex_prompt') + ->willReturn($promptReference); - $this->promptGetter + $this->referenceHandler ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithSpecialCharacters(): void @@ -261,42 +325,61 @@ public function testHandlePromptGetWithSpecialCharacters(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Unicode message processed')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt handling unicode characters', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); - $this->promptGetter + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('getPrompt') + ->with('unicode_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetReturnsEmptyMessages(): void { $request = $this->createGetPromptRequest('empty_prompt'); - $expectedResult = new GetPromptResult( - description: 'An empty prompt', - messages: [], - ); + $expectedResult = new GetPromptResult([]); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, []) + ->willReturn([]); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with([]) + ->willReturn([]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(0, $response->result->messages); + $this->assertEquals($expectedResult, $response->result); } public function testHandlePromptGetWithLargeNumberOfArguments(): void @@ -310,21 +393,31 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $expectedMessages = [ new PromptMessage(Role::User, new TextContent('Processed 100 arguments')), ]; - $expectedResult = new GetPromptResult( - description: 'A prompt with many arguments', - messages: $expectedMessages, - ); + $expectedResult = new GetPromptResult($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('many_args_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, $arguments) + ->willReturn($expectedMessages); - $this->promptGetter + $promptReference ->expects($this->once()) - ->method('get') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with($expectedMessages) + ->willReturn($expectedMessages); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } /** diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index fd67becd..440005d4 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Handler\Request; -use Mcp\Capability\Resource\ResourceReaderInterface; +use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Capability\Registry\ResourceReference; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\Content\BlobResourceContents; @@ -19,6 +21,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Resource; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Handler\Request\ReadResourceHandler; use Mcp\Server\Session\SessionInterface; @@ -28,15 +31,17 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; - private ResourceReaderInterface|MockObject $resourceReader; + private ReferenceProviderInterface|MockObject $referenceProvider; + private ReferenceHandlerInterface|MockObject $referenceHandler; private SessionInterface|MockObject $session; protected function setUp(): void { - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); $this->session = $this->createMock(SessionInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } public function testSupportsReadResourceRequest(): void @@ -57,17 +62,33 @@ public function testHandleSuccessfulResourceRead(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('test'); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('test', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithBlobContent(): void @@ -81,16 +102,32 @@ public function testHandleResourceReadWithBlobContent(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('fake-image-data'); + + $resourceReference ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('fake-image-data', $uri, 'image/png') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithMultipleContents(): void @@ -109,17 +146,32 @@ public function testHandleResourceReadWithMultipleContents(): void ); $expectedResult = new ReadResourceResult([$textContent, $blobContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('binary-data'); + + $resourceReference ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('formatResult') + ->with('binary-data', $uri, 'application/octet-stream') + ->willReturn([$textContent, $blobContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(2, $response->result->contents); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void @@ -128,10 +180,10 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $request = $this->createReadResourceRequest($uri); $exception = new ResourceNotFoundException($request); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -151,10 +203,10 @@ public function testHandleResourceReadExceptionReturnsGenericError(): void new \RuntimeException('Failed to read resource: corrupted data'), ); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -185,46 +237,40 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn('test'); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('test', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); + $this->assertEquals($expectedResult, $response->result); // Reset the mock for next iteration - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } } - public function testHandleResourceReadWithSpecialCharactersInUri(): void - { - $uri = 'file://path/with spaces/äöü-file-ñ.txt'; - $request = $this->createReadResourceRequest($uri); - $expectedContent = new TextResourceContents( - uri: $uri, - mimeType: 'text/plain', - text: 'Content with unicode characters: äöü ñ 世界 🚀', - ); - $expectedResult = new ReadResourceResult([$expectedContent]); - - $this->resourceReader - ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - } - public function testHandleResourceReadWithEmptyContent(): void { $uri = 'file://empty/file.txt'; @@ -236,18 +282,32 @@ public function testHandleResourceReadWithEmptyContent(): void ); $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn(''); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('', $uri, 'text/plain') + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertInstanceOf(TextResourceContents::class, $response->result->contents[0]); - $this->assertEquals('', $response->result->contents[0]->text); + $this->assertEquals($expectedResult, $response->result); } public function testHandleResourceReadWithDifferentMimeTypes(): void @@ -284,21 +344,37 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void } $expectedResult = new ReadResourceResult([$expectedContent]); - $this->resourceReader + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->getMock(); + + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri]) + ->willReturn($expectedContent); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with($expectedContent, $uri, $mimeType) + ->willReturn([$expectedContent]); $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertEquals($mimeType, $response->result->contents[0]->mimeType); + $this->assertEquals($expectedResult, $response->result); // Reset the mock for next iteration - $this->resourceReader = $this->createMock(ResourceReaderInterface::class); - $this->handler = new ReadResourceHandler($this->resourceReader); + $this->referenceProvider = $this->createMock(ReferenceProviderInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->referenceProvider, $this->referenceHandler); } } @@ -308,10 +384,10 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $request = $this->createReadResourceRequest($uri); $exception = new ResourceNotFoundException($request); - $this->resourceReader + $this->referenceProvider ->expects($this->once()) - ->method('read') - ->with($request) + ->method('getResource') + ->with($uri) ->willThrowException($exception); $response = $this->handler->handle($request, $this->session); @@ -321,25 +397,6 @@ public function testHandleResourceNotFoundWithCustomMessage(): void $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } - public function testHandleResourceReadWithEmptyResult(): void - { - $uri = 'file://empty/resource'; - $request = $this->createReadResourceRequest($uri); - $expectedResult = new ReadResourceResult([]); - - $this->resourceReader - ->expects($this->once()) - ->method('read') - ->with($request) - ->willReturn($expectedResult); - - $response = $this->handler->handle($request, $this->session); - - $this->assertInstanceOf(Response::class, $response); - $this->assertSame($expectedResult, $response->result); - $this->assertCount(0, $response->result->contents); - } - private function createReadResourceRequest(string $uri): ReadResourceRequest { return ReadResourceRequest::fromArray([ From 415f8dd360be19b4226e4f910b83b1124dfca6d8 Mon Sep 17 00:00:00 2001 From: ineersa Date: Tue, 7 Oct 2025 21:56:10 -0400 Subject: [PATCH 29/66] Fixed Discoverer file processing for PHAR (#94) --- src/Capability/Discovery/Discoverer.php | 51 +++++++++++-------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 83dafa09..1520a8e1 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -143,16 +143,9 @@ public function discover(string $basePath, array $directories, array $excludeDir */ private function processFile(SplFileInfo $file, array &$discoveredCount, array &$tools, array &$resources, array &$prompts, array &$resourceTemplates): void { - $filePath = $file->getRealPath(); - if (false === $filePath) { - $this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]); - - return; - } - - $className = $this->getClassFromFile($filePath); + $className = $this->getClassFromFile($file); if (!$className) { - $this->logger->warning('No valid class found in file', ['file' => $filePath]); + $this->logger->warning('No valid class found in file', ['file' => $file->getPathname()]); return; } @@ -199,10 +192,10 @@ private function processFile(SplFileInfo $file, array &$discoveredCount, array & } } } catch (\ReflectionException $e) { - $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]); + $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $file->getPathname(), 'class' => $className, 'exception' => $e->getMessage()]); } catch (\Throwable $e) { $this->logger->error('Unexpected error processing file for MCP discovery', [ - 'file' => $filePath, + 'file' => $file->getPathname(), 'class' => $className, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), @@ -329,34 +322,34 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar * Attempt to determine the FQCN from a PHP file path. * Uses tokenization to extract namespace and class name. * - * @param string $filePath absolute path to the PHP file - * * @return class-string|null the FQCN or null if not found/determinable */ - private function getClassFromFile(string $filePath): ?string + private function getClassFromFile(SplFileInfo $file): ?string { - if (!file_exists($filePath) || !is_readable($filePath)) { - $this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]); + $this->logger->debug('Processing file', ['path' => $file->getPathname()]); + + try { + $content = $file->getContents(); + } catch (\Throwable $e) { + $this->logger->warning("Failed to read file content during class discovery: {$file->getPathname()}", [ + 'exception' => $e->getMessage(), + ]); return null; } - try { - $content = file_get_contents($filePath); - if (false === $content) { - $this->logger->warning('Failed to read file content.', ['file' => $filePath]); - - return null; - } - if (\strlen($content) > 500 * 1024) { - $this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]); + if (\strlen($content) > 500 * 1024) { + $this->logger->warning('Skipping large file during class discovery.', ['file' => $file->getPathname()]); - return null; - } + return null; + } + try { $tokens = token_get_all($content); } catch (\Throwable $e) { - $this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]); + $this->logger->warning("Failed to tokenize file during class discovery: {$file->getPathname()}", [ + 'exception' => $e->getMessage(), + ]); return null; } @@ -427,7 +420,7 @@ private function getClassFromFile(string $filePath): ?string if (!empty($potentialClasses)) { if (!class_exists($potentialClasses[0], false)) { - $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]); + $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]); } return $potentialClasses[0]; From e2243bc2d94e18dac2e3e29a639ab27211dec7dd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Fri, 10 Oct 2025 19:38:42 +0200 Subject: [PATCH 30/66] [Server] Remove dead resource template handler code (#102) --- .../Registry/ResourceTemplateReference.php | 64 ++++++------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index c6f7ec46..951d0aff 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\ResourceTemplate; -use Psr\Container\ContainerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -31,11 +30,6 @@ class ResourceTemplateReference extends ElementReference */ private array $variableNames; - /** - * @var array - */ - private array $uriVariables; - private string $uriTemplateRegex; /** @@ -53,22 +47,6 @@ public function __construct( $this->compileTemplate(); } - /** - * @deprecated - * Gets the resource template - * - * @return array array of ResourceContents objects - */ - public function read(ContainerInterface $container, string $uri): array - { - $arguments = array_merge($this->uriVariables, ['uri' => $uri]); - - $referenceHandler = new ReferenceHandler($container); - $result = $referenceHandler->handle($this, $arguments); - - return $this->formatResult($result, $uri, $this->resourceTemplate->mimeType); - } - /** * @return array */ @@ -87,34 +65,12 @@ public function matches(string $uri): bool } } - $this->uriVariables = $variables; - return true; } return false; } - private function compileTemplate(): void - { - $this->variableNames = []; - $regexParts = []; - - $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); - - foreach ($segments as $segment) { - if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { - $varName = $matches[1]; - $this->variableNames[] = $varName; - $regexParts[] = '(?P<'.$varName.'>[^/]+)'; - } else { - $regexParts[] = preg_quote($segment, '#'); - } - } - - $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; - } - /** * Formats the raw result of a resource read operation into MCP ResourceContent items. * @@ -256,6 +212,26 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = throw new RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".\gettype($readResult)); } + private function compileTemplate(): void + { + $this->variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $this->variableNames[] = $varName; + $regexParts[] = '(?P<'.$varName.'>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; + } + /** Guesses MIME type from string content (very basic) */ private function guessMimeTypeFromString(string $content): string { From 2dbd4111942c54b4a286359e36e4b03dffa9f931 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 10 Oct 2025 18:52:06 +0100 Subject: [PATCH 31/66] [Server] Simplify server entrypoint with run-based transport lifecycle (#103) * feat: replace connect/listen flow with run lifecycle * feat(transport): add phpstan generics for typed transport returns * refactor: standardize variable naming for MCP server instances in transport handling * test: remove unnecessary phpstan-ignore annotation in DocBlockTestFixture --- README.md | 10 ++--- docs/transports.md | 41 ++++++------------- examples/custom-method-handlers/server.php | 6 +-- .../http-combined-registration/server.php | 4 +- examples/http-complex-tool-schema/server.php | 4 +- .../http-discovery-userprofile/server.php | 4 +- examples/http-schema-showcase/server.php | 4 +- examples/stdio-cached-discovery/server.php | 6 +-- examples/stdio-custom-dependencies/server.php | 6 +-- .../stdio-discovery-calculator/server.php | 6 +-- examples/stdio-env-variables/server.php | 6 +-- .../stdio-explicit-registration/server.php | 6 +-- src/Server.php | 15 ++++++- src/Server/Transport/InMemoryTransport.php | 7 ++++ src/Server/Transport/StdioTransport.php | 13 +++++- .../Transport/StreamableHttpTransport.php | 4 +- src/Server/Transport/TransportInterface.php | 5 ++- .../Discovery/DocBlockTestFixture.php | 2 +- tests/Unit/ServerTest.php | 7 ++-- 19 files changed, 82 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index f441b3ca..8d4aaa81 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ $server = Server::builder() ->build(); $transport = new StdioTransport(); -$server->connect($transport); -$transport->listen(); + +$server->run($transport); ``` ### 3. Configure Your MCP Client @@ -175,15 +175,13 @@ $server = Server::builder() **STDIO Transport** (Command-line integration): ```php $transport = new StdioTransport(); -$server->connect($transport); -$transport->listen(); +$server->run($transport); ``` **HTTP Transport** (Web-based communication): ```php $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$server->connect($transport); -$response = $transport->listen(); +$response = $server->run($transport); // Handle $response in your web application ``` diff --git a/docs/transports.md b/docs/transports.md index dc0f50a2..75902c93 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -22,9 +22,7 @@ $server = Server::builder() $transport = new SomeTransport(); -$server->connect($transport); - -$transport->listen(); // For STDIO, or handle response for HTTP +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP ``` ## STDIO Transport @@ -70,9 +68,9 @@ $server = Server::builder() $transport = new StdioTransport(); -$server->connect($transport); +$status = $server->run($transport); -$transport->listen(); +exit($status); // 0 on clean shutdown, non-zero if STDIN errored ``` ### Client Configuration @@ -138,24 +136,20 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7Server\ServerRequestCreator; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -// Create PSR-7 request from globals $psr17Factory = new Psr17Factory(); $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); $request = $creator->fromGlobals(); -// Build server $server = Server::builder() ->setServerInfo('HTTP Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions ->build(); -// Process request and get response $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); -$response = $transport->listen(); -// Emit response +$response = $server->run($transport); + (new SapiEmitter())->emit($response); ``` @@ -187,7 +181,7 @@ use Mcp\Server\Transport\StreamableHttpTransport; class McpController { #[Route('/mcp', name: 'mcp_endpoint'] - public function handle(Request $request, Server $mcpServer): Response + public function handle(Request $request, Server $server): Response { // Create PSR-7 factories $psr17Factory = new Psr17Factory(); @@ -199,8 +193,7 @@ class McpController // Process with MCP $transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory); - $mcpServer->connect($transport); - $psrResponse = $transport->listen(); + $psrResponse = $server->run($transport); // Convert PSR-7 response back to Symfony return $httpFoundationFactory->createResponse($psrResponse); @@ -230,17 +223,16 @@ use Nyholm\Psr7\Factory\Psr17Factory; class McpController { - public function handle(ServerRequestInterface $request, Server $mcpServer): ResponseInterface + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface { $psr17Factory = new Psr17Factory(); - // Create and connect the MCP HTTP transport + // Create the MCP HTTP transport $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - $mcpServer->connect($transport); // Process MCP request and return PSR-7 response // Laravel automatically handles PSR-7 responses - return $transport->listen(); + return $server->run($transport); } } @@ -255,7 +247,6 @@ Slim Framework works natively with PSR-7. Create a route handler using Slim's built-in factories and container: ```php -use Psr\Container\ContainerInterface; use Slim\Factory\AppFactory; use Slim\Psr7\Factory\ResponseFactory; use Slim\Psr7\Factory\StreamFactory; @@ -263,25 +254,19 @@ use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; $app = AppFactory::create(); -$container = $app->getContainer(); -$container->set('mcpServer', function (ContainerInterface $container) { - return Server::builder() +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() ->setServerInfo('My MCP Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) ->build(); -}); - -$app->any('/mcp', function ($request, $response) { - $mcpServer = $this->get('mcpServer'); $responseFactory = new ResponseFactory(); $streamFactory = new StreamFactory(); $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); - $mcpServer->connect($transport); - return $transport->listen(); + return $server->run($transport); }); ``` diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index b1db5f8f..ef47f32d 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -137,8 +137,8 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/examples/http-combined-registration/server.php b/examples/http-combined-registration/server.php index 6eefcfd3..660cf3c1 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/http-combined-registration/server.php @@ -42,8 +42,6 @@ $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run($transport); (new SapiEmitter())->emit($response); diff --git a/examples/http-complex-tool-schema/server.php b/examples/http-complex-tool-schema/server.php index fbbe45a8..a3795a6c 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/http-complex-tool-schema/server.php @@ -35,8 +35,6 @@ $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run($transport); (new SapiEmitter())->emit($response); diff --git a/examples/http-discovery-userprofile/server.php b/examples/http-discovery-userprofile/server.php index b1cba000..6f859c0a 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/http-discovery-userprofile/server.php @@ -77,8 +77,6 @@ function (): array { $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run($transport); (new SapiEmitter())->emit($response); diff --git a/examples/http-schema-showcase/server.php b/examples/http-schema-showcase/server.php index 8b35b6a2..e8d6d176 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/http-schema-showcase/server.php @@ -35,8 +35,6 @@ $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run($transport); (new SapiEmitter())->emit($response); diff --git a/examples/stdio-cached-discovery/server.php b/examples/stdio-cached-discovery/server.php index dcd849ba..2f10de0e 100644 --- a/examples/stdio-cached-discovery/server.php +++ b/examples/stdio-cached-discovery/server.php @@ -31,8 +31,8 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/examples/stdio-custom-dependencies/server.php b/examples/stdio-custom-dependencies/server.php index 42d5b053..743fd78b 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/stdio-custom-dependencies/server.php @@ -39,8 +39,8 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index ad5c1cf7..fe223240 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -28,8 +28,8 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/examples/stdio-env-variables/server.php b/examples/stdio-env-variables/server.php index 08848bba..62c03501 100644 --- a/examples/stdio-env-variables/server.php +++ b/examples/stdio-env-variables/server.php @@ -57,8 +57,8 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/examples/stdio-explicit-registration/server.php b/examples/stdio-explicit-registration/server.php index f225f989..1efcba8b 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/stdio-explicit-registration/server.php @@ -31,8 +31,8 @@ $transport = new StdioTransport(logger: logger()); -$server->connect($transport); +$result = $server->run($transport); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +exit($result); diff --git a/src/Server.php b/src/Server.php index ec3d6542..54ba8103 100644 --- a/src/Server.php +++ b/src/Server.php @@ -35,7 +35,14 @@ public static function builder(): Builder return new Builder(); } - public function connect(TransportInterface $transport): void + /** + * @template TResult + * + * @param TransportInterface $transport + * + * @return TResult + */ + public function run(TransportInterface $transport): mixed { $transport->initialize(); @@ -56,5 +63,11 @@ public function connect(TransportInterface $transport): void $transport->onSessionEnd(function (Uuid $sessionId) { $this->jsonRpcHandler->destroySession($sessionId); }); + + try { + return $transport->listen(); + } finally { + $transport->close(); + } } } diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index 2da8d215..a1bd2946 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -14,6 +14,8 @@ use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Tobias Nyholm */ class InMemoryTransport implements TransportInterface @@ -50,6 +52,9 @@ public function send(string $data, array $context): void } } + /** + * @return null + */ public function listen(): mixed { foreach ($this->messages as $message) { @@ -60,6 +65,7 @@ public function listen(): mixed if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { \call_user_func($this->sessionDestroyListener, $this->sessionId); + $this->sessionId = null; } return null; @@ -74,6 +80,7 @@ public function close(): void { if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { \call_user_func($this->sessionDestroyListener, $this->sessionId); + $this->sessionId = null; } } } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 6b4a337a..be69dd04 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -16,6 +16,8 @@ use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Kyrian Obikwelu */ class StdioTransport implements TransportInterface @@ -59,13 +61,18 @@ public function send(string $data, array $context): void fwrite($this->output, $data.\PHP_EOL); } - public function listen(): mixed + public function listen(): int { $this->logger->info('StdioTransport is listening for messages on STDIN...'); + $status = 0; while (!feof($this->input)) { $line = fgets($this->input); if (false === $line) { + if (!feof($this->input)) { + $status = 1; + } + break; } @@ -82,9 +89,10 @@ public function listen(): mixed if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { \call_user_func($this->sessionEndListener, $this->sessionId); + $this->sessionId = null; } - return null; + return $status; } public function onSessionEnd(callable $listener): void @@ -96,6 +104,7 @@ public function close(): void { if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { \call_user_func($this->sessionEndListener, $this->sessionId); + $this->sessionId = null; } if (\is_resource($this->input)) { diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 7f5035fe..070657de 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -21,6 +21,8 @@ use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Kyrian Obikwelu */ class StreamableHttpTransport implements TransportInterface @@ -78,7 +80,7 @@ public function send(string $data, array $context): void ]); } - public function listen(): mixed + public function listen(): ResponseInterface { return match ($this->request->getMethod()) { 'OPTIONS' => $this->handleOptionsRequest(), diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index a8040a0f..c910154f 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -14,6 +14,8 @@ use Symfony\Component\Uid\Uuid; /** + * @template TResult + * * @author Christopher Hertel * @author Kyrian Obikwelu */ @@ -38,7 +40,7 @@ public function onMessage(callable $listener): void; * - For a single-request transport like HTTP, this will process the request * and return a result (e.g., a PSR-7 Response) to be sent to the client. * - * @return mixed the result of the transport's execution, if any + * @return TResult the result of the transport's execution, if any */ public function listen(): mixed; @@ -63,6 +65,7 @@ public function onSessionEnd(callable $listener): void; * * This method should be called when the transport is no longer needed. * It should clean up any resources and close any connections. + * `Server::run()` calls this automatically after `listen()` exits. */ public function close(): void; } diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php index 0f015ce4..a218ad63 100644 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -75,7 +75,7 @@ public function methodWithReturn(): string * @deprecated use newMethod() instead * @see DocBlockTestFixture::newMethod() */ - public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */ + public function methodWithMultipleTags(float $value): bool { return true; } diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 1abba9f6..1c58f58b 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -32,16 +32,15 @@ public function testJsonExceptions() $transport = $this->getMockBuilder(InMemoryTransport::class) ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send']) + ->onlyMethods(['send', 'close']) ->getMock(); $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( null, null ); + $transport->expects($this->once())->method('close'); $server = new Server($handler); - $server->connect($transport); - - $transport->listen(); + $server->run($transport); } } From 709a815cde7408f7632ef97cac323a3342b05ab1 Mon Sep 17 00:00:00 2001 From: ineersa Date: Fri, 10 Oct 2025 20:37:58 -0400 Subject: [PATCH 32/66] Add possibility to set custom ServerCapabilities. (#95) --- .../stdio-explicit-registration/server.php | 12 +++++++ src/Capability/Registry.php | 9 ++++- src/Server/Builder.php | 12 +++++++ .../Unit/Capability/Registry/RegistryTest.php | 36 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/examples/stdio-explicit-registration/server.php b/examples/stdio-explicit-registration/server.php index 1efcba8b..e44d1ffb 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/stdio-explicit-registration/server.php @@ -14,6 +14,7 @@ chdir(__DIR__); use Mcp\Example\StdioExplicitRegistration\SimpleHandlers; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; @@ -27,6 +28,17 @@ ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') + ->setServerCapabilities(new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false, + logging: false, + completions: false, + )) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 6e54cfc2..94db079f 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -63,6 +63,8 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt */ private array $resourceTemplates = []; + private ServerCapabilities $serverCapabilities; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), @@ -75,7 +77,7 @@ public function getCapabilities(): ServerCapabilities $this->logger->info('No capabilities registered on server.'); } - return new ServerCapabilities( + return $this->serverCapabilities ?? new ServerCapabilities( tools: [] !== $this->tools, toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: [] !== $this->resources || [] !== $this->resourceTemplates, @@ -453,4 +455,9 @@ private function paginateResults(array $items, int $limit, ?string $cursor = nul return array_values(\array_slice($items, $offset, $limit)); } + + public function setServerCapabilities(ServerCapabilities $serverCapabilities): void + { + $this->serverCapabilities = $serverCapabilities; + } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 8ac2dc4a..39f2cb83 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -138,6 +138,8 @@ final class Builder */ private array $discoveryExcludeDirs = []; + private ?ServerCapabilities $serverCapabilities = null; + /** * Sets the server's identity. Required. */ @@ -264,6 +266,13 @@ public function setDiscovery( return $this; } + public function setServerCapabilities(ServerCapabilities $serverCapabilities): self + { + $this->serverCapabilities = $serverCapabilities; + + return $this; + } + /** * Manually registers a tool handler. * @@ -348,6 +357,9 @@ public function build(): Server $registry = new Registry($this->eventDispatcher, $logger); $this->registerCapabilities($registry, $logger); + if ($this->serverCapabilities) { + $registry->setServerCapabilities($this->serverCapabilities); + } if (null !== $this->discoveryBasePath) { $this->performDiscovery($registry, $logger); diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index e18f9d1f..e1f47689 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -80,6 +80,42 @@ public function testGetCapabilitiesWhenPopulated(): void $this->assertFalse($capabilities->logging); } + public function testSetCustomCapabilities(): void + { + $serverCapabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: false, + promptsListChanged: false, + logging: true, + completions: true, + ); + $tool = $this->createValidTool('test_tool'); + $resource = $this->createValidResource('test://resource'); + $prompt = $this->createValidPrompt('test_prompt'); + $template = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerTool($tool, fn () => 'result'); + $this->registry->registerResource($resource, fn () => 'content'); + $this->registry->registerPrompt($prompt, fn () => []); + $this->registry->registerResourceTemplate($template, fn () => 'template'); + + $this->registry->setServerCapabilities($serverCapabilities); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + $this->assertTrue($capabilities->completions); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->toolsListChanged); + } + public function testRegisterToolWithManualFlag(): void { $tool = $this->createValidTool('test_tool'); From 4486776e7d2582ea2557c68fb7a2fefaa1b8ac70 Mon Sep 17 00:00:00 2001 From: Adebayo120 <54323098+Adebayo120@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:51:21 +0100 Subject: [PATCH 33/66] [Server] Remove redundant Builder.setServerCapabilities method (#107) --- .../stdio-explicit-registration/server.php | 2 +- src/Server/Builder.php | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/examples/stdio-explicit-registration/server.php b/examples/stdio-explicit-registration/server.php index e44d1ffb..2189bf16 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/stdio-explicit-registration/server.php @@ -28,7 +28,7 @@ ->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') ->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') ->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') - ->setServerCapabilities(new ServerCapabilities( + ->setCapabilities(new ServerCapabilities( tools: true, toolsListChanged: false, resources: true, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 39f2cb83..35f5d3fa 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -75,8 +75,6 @@ final class Builder private ?string $instructions = null; - private ?ServerCapabilities $explicitCapabilities = null; - /** * @var array */ @@ -177,9 +175,9 @@ public function setInstructions(?string $instructions): self /** * Explicitly set server capabilities. If set, this overrides automatic detection. */ - public function setCapabilities(ServerCapabilities $capabilities): self + public function setCapabilities(ServerCapabilities $serverCapabilities): self { - $this->explicitCapabilities = $capabilities; + $this->serverCapabilities = $serverCapabilities; return $this; } @@ -266,13 +264,6 @@ public function setDiscovery( return $this; } - public function setServerCapabilities(ServerCapabilities $serverCapabilities): self - { - $this->serverCapabilities = $serverCapabilities; - - return $this; - } - /** * Manually registers a tool handler. * @@ -370,7 +361,7 @@ public function build(): Server $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); $messageFactory = MessageFactory::make(); - $capabilities = $this->explicitCapabilities ?? $registry->getCapabilities(); + $capabilities = $registry->getCapabilities(); $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); $referenceHandler = new ReferenceHandler($container); @@ -597,7 +588,8 @@ private function getHandlerDescription(\Closure|array|string $handler): string } if (\is_array($handler)) { - return \sprintf('%s::%s', + return \sprintf( + '%s::%s', \is_object($handler[0]) ? $handler[0]::class : $handler[0], $handler[1], ); From 9ff6516e5b4f4882edf2deb912437baa31b6ee27 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 12 Oct 2025 14:52:58 +0200 Subject: [PATCH 34/66] Add happy cases for functional tests of example 1 (#91) --- tests/Inspector/InspectorSnapshotTestCase.php | 48 ++++++++++++++----- .../Inspector/StdioCalculatorExampleTest.php | 11 +++++ ...oCalculatorExampleTest-resources_read.json | 9 ++++ ...StdioCalculatorExampleTest-tools_call.json | 9 ++++ 4 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_read.json create mode 100644 tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_call.json diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 71a065a6..8fe3d96a 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -19,20 +19,42 @@ abstract class InspectorSnapshotTestCase extends TestCase { private const INSPECTOR_VERSION = '0.16.8'; + /** + * @param array $toolArgs + */ #[DataProvider('provideMethods')] - public function testResourcesListOutputMatchesSnapshot(string $method): void - { - $process = (new Process([ - 'npx', - \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION), - '--cli', - 'php', - $this->getServerScript(), - '--method', - $method, - ]))->mustRun(); - - $output = $process->getOutput(); + public function testMethodOutputMatchesSnapshot( + string $method, + ?string $toolName = null, + array $toolArgs = [], + ?string $uri = null, + ): void { + $inspector = \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION); + $args = [ + 'npx', $inspector, '--cli', 'php', $this->getServerScript(), '--method', $method, + ]; + + // Options for tools/call + if (null !== $toolName) { + $args[] = '--tool-name'; + $args[] = $toolName; + + foreach ($toolArgs as $key => $value) { + $args[] = '--tool-arg'; + $args[] = \sprintf('%s=%s', $key, $value); + } + } + + // Options for resources/read + if (null !== $uri) { + $args[] = '--uri'; + $args[] = $uri; + } + + $output = (new Process($args)) + ->mustRun() + ->getOutput(); + $snapshotFile = $this->getSnapshotFilePath($method); if (!file_exists($snapshotFile)) { diff --git a/tests/Inspector/StdioCalculatorExampleTest.php b/tests/Inspector/StdioCalculatorExampleTest.php index b1b1bc5e..b023b5b5 100644 --- a/tests/Inspector/StdioCalculatorExampleTest.php +++ b/tests/Inspector/StdioCalculatorExampleTest.php @@ -17,6 +17,17 @@ public static function provideMethods(): array { return [ ...parent::provideListMethods(), + 'Calculate Sum' => [ + 'method' => 'tools/call', + 'toolName' => 'calculate', + 'toolArgs' => ['a' => 12.5, 'b' => 7.3, 'operation' => 'add'], + ], + 'Read Config' => [ + 'method' => 'resources/read', + 'toolName' => null, // can be removed with newer PHPUnit versions + 'toolArgs' => [], // can be removed with newer PHPUnit versions + 'uri' => 'config://calculator/settings', + ], ]; } diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_read.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_read.json new file mode 100644 index 00000000..c15d9a8e --- /dev/null +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_read.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "config://calculator/settings", + "mimeType": "application/json", + "text": "{\n \"precision\": 2,\n \"allow_negative\": true\n}" + } + ] +} diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_call.json b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_call.json new file mode 100644 index 00000000..a73c8b94 --- /dev/null +++ b/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_call.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "19.8" + } + ], + "isError": false +} From 54a5d1495c5f6a204121e4e9c13266c0b6b8d972 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 12 Oct 2025 13:53:41 +0100 Subject: [PATCH 35/66] [Server] Refactor protocol pipeline to separate request and notification handling (#106) --- docs/server-builder.md | 103 ++- examples/custom-method-handlers/server.php | 38 +- src/JsonRpc/MessageFactory.php | 149 +++-- src/Schema/JsonRpc/Error.php | 14 +- src/Schema/JsonRpc/Response.php | 3 + src/Server.php | 25 +- src/Server/Builder.php | 58 +- src/Server/Handler/JsonRpcHandler.php | 258 -------- src/Server/Handler/MethodHandlerInterface.php | 32 - .../Notification/InitializedHandler.php | 17 +- .../NotificationHandlerInterface.php | 25 + .../Handler/Request/CallToolHandler.php | 27 +- .../Request/CompletionCompleteHandler.php | 35 +- .../Handler/Request/GetPromptHandler.php | 27 +- .../Handler/Request/InitializeHandler.php | 17 +- .../Handler/Request/ListPromptsHandler.php | 17 +- .../Request/ListResourceTemplatesHandler.php | 17 +- .../Handler/Request/ListResourcesHandler.php | 17 +- .../Handler/Request/ListToolsHandler.php | 17 +- src/Server/Handler/Request/PingHandler.php | 15 +- .../Handler/Request/ReadResourceHandler.php | 23 +- .../Request/RequestHandlerInterface.php | 27 + src/Server/Protocol.php | 324 +++++++++ src/Server/Transport/TransportInterface.php | 21 +- tests/Unit/JsonRpc/HandlerTest.php | 118 ---- tests/Unit/JsonRpc/MessageFactoryTest.php | 360 +++++++++- tests/Unit/Server/ProtocolTest.php | 615 ++++++++++++++++++ tests/Unit/ServerTest.php | 163 ++++- 28 files changed, 1843 insertions(+), 719 deletions(-) delete mode 100644 src/Server/Handler/JsonRpcHandler.php delete mode 100644 src/Server/Handler/MethodHandlerInterface.php create mode 100644 src/Server/Handler/Notification/NotificationHandlerInterface.php create mode 100644 src/Server/Handler/Request/RequestHandlerInterface.php create mode 100644 src/Server/Protocol.php delete mode 100644 tests/Unit/JsonRpc/HandlerTest.php create mode 100644 tests/Unit/Server/ProtocolTest.php diff --git a/docs/server-builder.md b/docs/server-builder.md index f673000c..03fcaece 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -12,7 +12,7 @@ various aspects of the server behavior. - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) - [Service Dependencies](#service-dependencies) -- [Custom Method Handlers](#custom-method-handlers) +- [Custom Message Handlers](#custom-message-handlers) - [Complete Example](#complete-example) - [Method Reference](#method-reference) @@ -344,50 +344,101 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` -## Custom Method Handlers +## Custom Message Handlers -**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over -individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass those dependencies in yourself. -Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as -many times as needed; each call prepends the handlers so they execute before the defaults: +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +### Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; each call prepends the handlers so they execute before the defaults: ```php $server = Server::builder() - ->addMethodHandler(new AuditHandler()) - ->addMethodHandlers([ - new CustomListToolsHandler(), + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ new CustomCallToolHandler(), + new CustomGetPromptHandler(), ]) ->build(); ``` -Custom handlers implement `MethodHandlerInterface`: +Request handlers implement `RequestHandlerInterface`: ```php -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; -interface MethodHandlerInterface +interface RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool; + public function supports(Request $request): bool; - public function handle(HasMethodInterface $message, SessionInterface $session); + public function handle(Request $request, SessionInterface $session): Response|Error; } ``` -- `supports()` decides if the handler should look at the incoming message. -- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`. +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement -custom `tool/list` and `tool/call` methods independently of the registry. +### Notification Handlers -> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss -> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. -> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing. +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +$server = Server::builder() + ->addNotificationHandler(new LoggingNotificationHandler()) + ->addNotificationHandlers([ + new InitializedNotificationHandler(), + new ProgressNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +### Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +### Example + +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. ## Complete Example @@ -453,8 +504,10 @@ $server = Server::builder() | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `addMethodHandler()` | handler | Prepend a single custom method handler | -| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index ef47f32d..9ff0a822 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -15,7 +15,7 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ListToolsRequest; @@ -24,7 +24,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Transport\StdioTransport; @@ -58,7 +58,7 @@ ), ]; -$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface { +$listToolsHandler = new class($toolDefinitions) implements RequestHandlerInterface { /** * @param array $toolDefinitions */ @@ -66,20 +66,20 @@ public function __construct(private array $toolDefinitions) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListToolsRequest; + return $request instanceof ListToolsRequest; } - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - assert($message instanceof ListToolsRequest); + assert($request instanceof ListToolsRequest); - return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); + return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); } }; -$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface { +$callToolHandler = new class($toolDefinitions) implements RequestHandlerInterface { /** * @param array $toolDefinitions */ @@ -87,20 +87,20 @@ public function __construct(private array $toolDefinitions) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CallToolRequest; + return $request instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - assert($message instanceof CallToolRequest); + assert($request instanceof CallToolRequest); - $name = $message->name; - $args = $message->arguments ?? []; + $name = $request->name; + $args = $request->arguments ?? []; if (!isset($this->toolDefinitions[$name])) { - return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); } try { @@ -118,9 +118,9 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter $result = [new TextContent('Unknown tool')]; } - return new Response($message->getId(), new CallToolResult($result)); + return new Response($request->getId(), new CallToolResult($result)); } catch (Throwable $e) { - return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); + return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); } } }; @@ -132,7 +132,7 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter ->setLogger(logger()) ->setContainer(container()) ->setCapabilities($capabilities) - ->addMethodHandlers([$listToolsHandler, $callToolHandler]) + ->addRequestHandlers([$listToolsHandler, $callToolHandler]) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index b6e34cca..2ca446c2 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -13,62 +13,75 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidInputMessageException; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\Notification; -use Mcp\Schema\Request; +use Mcp\Schema; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\MessageInterface; +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; /** + * Factory for creating JSON-RPC message objects from raw input. + * + * Handles all types of JSON-RPC messages: + * - Requests (have method + id) + * - Notifications (have method, no id) + * - Responses (have result + id) + * - Errors (have error + id) + * * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class MessageFactory { /** - * Registry of all known messages. + * Registry of all known message classes that have methods. * - * @var array> + * @var array> */ private const REGISTERED_MESSAGES = [ - Notification\CancelledNotification::class, - Notification\InitializedNotification::class, - Notification\LoggingMessageNotification::class, - Notification\ProgressNotification::class, - Notification\PromptListChangedNotification::class, - Notification\ResourceListChangedNotification::class, - Notification\ResourceUpdatedNotification::class, - Notification\RootsListChangedNotification::class, - Notification\ToolListChangedNotification::class, - Request\CallToolRequest::class, - Request\CompletionCompleteRequest::class, - Request\CreateSamplingMessageRequest::class, - Request\GetPromptRequest::class, - Request\InitializeRequest::class, - Request\ListPromptsRequest::class, - Request\ListResourcesRequest::class, - Request\ListResourceTemplatesRequest::class, - Request\ListRootsRequest::class, - Request\ListToolsRequest::class, - Request\PingRequest::class, - Request\ReadResourceRequest::class, - Request\ResourceSubscribeRequest::class, - Request\ResourceUnsubscribeRequest::class, - Request\SetLogLevelRequest::class, + Schema\Notification\CancelledNotification::class, + Schema\Notification\InitializedNotification::class, + Schema\Notification\LoggingMessageNotification::class, + Schema\Notification\ProgressNotification::class, + Schema\Notification\PromptListChangedNotification::class, + Schema\Notification\ResourceListChangedNotification::class, + Schema\Notification\ResourceUpdatedNotification::class, + Schema\Notification\RootsListChangedNotification::class, + Schema\Notification\ToolListChangedNotification::class, + + Schema\Request\CallToolRequest::class, + Schema\Request\CompletionCompleteRequest::class, + Schema\Request\CreateSamplingMessageRequest::class, + Schema\Request\GetPromptRequest::class, + Schema\Request\InitializeRequest::class, + Schema\Request\ListPromptsRequest::class, + Schema\Request\ListResourcesRequest::class, + Schema\Request\ListResourceTemplatesRequest::class, + Schema\Request\ListRootsRequest::class, + Schema\Request\ListToolsRequest::class, + Schema\Request\PingRequest::class, + Schema\Request\ReadResourceRequest::class, + Schema\Request\ResourceSubscribeRequest::class, + Schema\Request\ResourceUnsubscribeRequest::class, + Schema\Request\SetLogLevelRequest::class, ]; /** - * @param array> $registeredMessages + * @param array> $registeredMessages */ public function __construct( private readonly array $registeredMessages, ) { - foreach ($this->registeredMessages as $message) { - if (!is_subclass_of($message, HasMethodInterface::class)) { - throw new InvalidArgumentException(\sprintf('Message classes must implement %s.', HasMethodInterface::class)); + foreach ($this->registeredMessages as $messageClass) { + if (!is_subclass_of($messageClass, Request::class) && !is_subclass_of($messageClass, Notification::class)) { + throw new InvalidArgumentException(\sprintf('Message classes must extend %s or %s.', Request::class, Notification::class)); } } } /** - * Creates a new Factory instance with the all the protocol's default notifications and requests. + * Creates a new Factory instance with all the protocol's default messages. */ public static function make(): self { @@ -76,11 +89,16 @@ public static function make(): self } /** - * @return iterable + * Creates message objects from JSON input. + * + * Supports both single messages and batch requests. Returns an array containing + * MessageInterface objects or InvalidInputMessageException instances for invalid messages. + * + * @return array * * @throws \JsonException When the input string is not valid JSON */ - public function create(string $input): iterable + public function create(string $input): array { $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); @@ -88,32 +106,63 @@ public function create(string $input): iterable $data = [$data]; } + $messages = []; foreach ($data as $message) { - if (!isset($message['method']) || !\is_string($message['method'])) { - yield new InvalidInputMessageException('Invalid JSON-RPC request, missing valid "method".'); - continue; - } - try { - yield $this->getType($message['method'])::fromArray($message); + $messages[] = $this->createMessage($message); } catch (InvalidInputMessageException $e) { - yield $e; - continue; + $messages[] = $e; } } + + return $messages; } /** - * @return class-string + * Creates a single message object from parsed JSON data. + * + * @param array $data + * + * @throws InvalidInputMessageException + */ + private function createMessage(array $data): MessageInterface + { + try { + if (isset($data['error'])) { + return Error::fromArray($data); + } + + if (isset($data['result'])) { + return Response::fromArray($data); + } + + if (!isset($data['method'])) { + throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.'); + } + + $messageClass = $this->findMessageClassByMethod($data['method']); + + return $messageClass::fromArray($data); + } catch (InvalidArgumentException $e) { + throw new InvalidInputMessageException($e->getMessage(), 0, $e); + } + } + + /** + * Finds the registered message class for a given method name. + * + * @return class-string + * + * @throws InvalidInputMessageException */ - private function getType(string $method): string + private function findMessageClassByMethod(string $method): string { - foreach (self::REGISTERED_MESSAGES as $type) { - if ($type::getMethod() === $method) { - return $type; + foreach ($this->registeredMessages as $messageClass) { + if ($messageClass::getMethod() === $method) { + return $messageClass; } } - throw new InvalidInputMessageException(\sprintf('Invalid JSON-RPC request, unknown method "%s".', $method)); + throw new InvalidInputMessageException(\sprintf('Unknown method "%s".', $method)); } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 64cb8455..ae802580 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -57,17 +57,23 @@ public static function fromArray(array $data): self if (!isset($data['jsonrpc']) || MessageInterface::JSONRPC_VERSION !== $data['jsonrpc']) { throw new InvalidArgumentException('Invalid or missing "jsonrpc" in Error data.'); } - if (!isset($data['id']) || !\is_string($data['id'])) { + if (!isset($data['id'])) { throw new InvalidArgumentException('Invalid or missing "id" in Error data.'); } - if (!isset($data['code']) || !\is_int($data['code'])) { + if (!\is_string($data['id']) && !\is_int($data['id'])) { + throw new InvalidArgumentException('Invalid "id" type in Error data.'); + } + if (!isset($data['error']) || !\is_array($data['error'])) { + throw new InvalidArgumentException('Invalid or missing "error" field in Error data.'); + } + if (!isset($data['error']['code']) || !\is_int($data['error']['code'])) { throw new InvalidArgumentException('Invalid or missing "code" in Error data.'); } - if (!isset($data['message']) || !\is_string($data['message'])) { + if (!isset($data['error']['message']) || !\is_string($data['error']['message'])) { throw new InvalidArgumentException('Invalid or missing "message" in Error data.'); } - return new self($data['id'], $data['code'], $data['message'], $data['data'] ?? null); + return new self($data['id'], $data['error']['code'], $data['error']['message'], $data['error']['data'] ?? null); } public static function forParseError(string $message, string|int $id = ''): self diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 6e5ae2c6..f1521265 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -56,6 +56,9 @@ public static function fromArray(array $data): self if (!isset($data['result'])) { throw new InvalidArgumentException('Response must contain "result" field.'); } + if (!\is_array($data['result'])) { + throw new InvalidArgumentException('Response "result" must be an array.'); + } return new self($data['id'], $data['result']); } diff --git a/src/Server.php b/src/Server.php index 54ba8103..1eb24e8c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,11 +12,10 @@ namespace Mcp; use Mcp\Server\Builder; -use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel @@ -25,7 +24,7 @@ final class Server { public function __construct( - private readonly JsonRpcHandler $jsonRpcHandler, + private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -44,25 +43,11 @@ public static function builder(): Builder */ public function run(TransportInterface $transport): mixed { - $transport->initialize(); - - $this->logger->info('Transport initialized.', [ - 'transport' => $transport::class, - ]); + $this->logger->info('Running server...'); - $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { - foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { - if (null === $response) { - continue; - } - - $transport->send($response, $context); - } - }); + $transport->initialize(); - $transport->onSessionEnd(function (Uuid $sessionId) { - $this->jsonRpcHandler->destroySession($sessionId); - }); + $this->protocol->connect($transport); try { return $transport->listen(); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 35f5d3fa..95ab9ea1 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -36,8 +36,8 @@ use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -76,9 +76,14 @@ final class Builder private ?string $instructions = null; /** - * @var array + * @var array */ - private array $customMethodHandlers = []; + private array $requestHandlers = []; + + /** + * @var array + */ + private array $notificationHandlers = []; /** * @var array{ @@ -185,9 +190,9 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self /** * Register a single custom method handler. */ - public function addMethodHandler(MethodHandlerInterface $handler): self + public function addRequestHandler(RequestHandlerInterface $handler): self { - $this->customMethodHandlers[] = $handler; + $this->requestHandlers[] = $handler; return $this; } @@ -195,12 +200,36 @@ public function addMethodHandler(MethodHandlerInterface $handler): self /** * Register multiple custom method handlers. * - * @param iterable $handlers + * @param iterable $handlers */ - public function addMethodHandlers(iterable $handlers): self + public function addRequestHandlers(iterable $handlers): self { foreach ($handlers as $handler) { - $this->customMethodHandlers[] = $handler; + $this->requestHandlers[] = $handler; + } + + return $this; + } + + /** + * Register a single custom notification handler. + */ + public function addNotificationHandler(NotificationHandlerInterface $handler): self + { + $this->notificationHandlers[] = $handler; + + return $this; + } + + /** + * Register multiple custom notification handlers. + * + * @param iterable $handlers + */ + public function addNotificationHandlers(iterable $handlers): self + { + foreach ($handlers as $handler) { + $this->notificationHandlers[] = $handler; } return $this; @@ -365,7 +394,7 @@ public function build(): Server $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); $referenceHandler = new ReferenceHandler($container); - $methodHandlers = array_merge($this->customMethodHandlers, [ + $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\PingHandler(), new Handler\Request\InitializeHandler($configuration), new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), @@ -375,19 +404,22 @@ public function build(): Server new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + ]); + $notificationHandlers = array_merge($this->notificationHandlers, [ new Handler\Notification\InitializedHandler(), ]); - $jsonRpcHandler = new JsonRpcHandler( - methodHandlers: $methodHandlers, + $protocol = new Protocol( + requestHandlers: $requestHandlers, + notificationHandlers: $notificationHandlers, messageFactory: $messageFactory, sessionFactory: $sessionFactory, sessionStore: $sessionStore, logger: $logger, ); - return new Server($jsonRpcHandler, $logger); + return new Server($protocol, $logger); } private function performDiscovery( diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php deleted file mode 100644 index 5f785bc5..00000000 --- a/src/Server/Handler/JsonRpcHandler.php +++ /dev/null @@ -1,258 +0,0 @@ - - */ -class JsonRpcHandler -{ - /** - * @param array $methodHandlers - */ - public function __construct( - private readonly array $methodHandlers, - private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @return iterable}> - */ - public function process(string $input, ?Uuid $sessionId): iterable - { - $this->logger->info('Received message to process.', ['message' => $input]); - - $this->runGarbageCollection(); - - try { - $messages = iterator_to_array($this->messageFactory->create($input)); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - $error = Error::forParseError($e->getMessage()); - yield [$this->encodeResponse($error), []]; - - return; - } - - $hasInitializeRequest = false; - foreach ($messages as $message) { - if ($message instanceof InitializeRequest) { - $hasInitializeRequest = true; - break; - } - } - - $session = null; - - if ($hasInitializeRequest) { - // Spec: An initialize request must not be part of a batch. - if (\count($messages) > 1) { - $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - // Spec: An initialize request must not have a session ID. - if ($sessionId) { - $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - $session = $this->sessionFactory->create($this->sessionStore); - } else { - if (!$sessionId) { - $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); - yield [$this->encodeResponse($error), ['status_code' => 400]]; - - return; - } - - if (!$this->sessionStore->exists($sessionId)) { - $error = Error::forInvalidRequest('Session not found or has expired.'); - yield [$this->encodeResponse($error), ['status_code' => 404]]; - - return; - } - - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); - } - - foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->logger->warning('Failed to create message.', ['exception' => $message]); - $error = Error::forInvalidRequest($message->getMessage()); - yield [$this->encodeResponse($error), []]; - continue; - } - - $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ - 'method' => $message->getMethod(), - ]); - - $messageId = $message instanceof Request ? $message->getId() : 0; - - try { - $response = $this->handle($message, $session); - yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; - } catch (\DomainException) { - yield [null, []]; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning( - \sprintf('Failed to create response: %s', $e->getMessage()), - ['exception' => $e], - ); - - $error = Error::forMethodNotFound($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInvalidParams($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInternalError($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } - } - - $session->save(); - } - - /** - * Encodes a response to JSON, handling encoding errors gracefully. - */ - private function encodeResponse(Response|Error|null $response): ?string - { - if (null === $response) { - $this->logger->info('The handler created an empty response.'); - - return null; - } - - $this->logger->info('Encoding response.', ['response' => $response]); - - try { - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } - - return json_encode($response, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), - 'exception' => $e, - ]); - - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); - - return json_encode($fallbackError, \JSON_THROW_ON_ERROR); - } - } - - /** - * If the handler does support the message, but does not create a response, other handlers will be tried. - * - * @throws NotFoundExceptionInterface When no handler is found for the request method - * @throws ExceptionInterface When a request handler throws an exception - */ - private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null - { - $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ - 'message' => $message, - ]); - - $handled = false; - foreach ($this->methodHandlers as $handler) { - if (!$handler->supports($message)) { - continue; - } - - $return = $handler->handle($message, $session); - $handled = true; - - $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ - 'method' => $message::getMethod(), - 'response' => $return, - ]); - - if (null !== $return) { - return $return; - } - } - - if ($handled) { - return null; - } - - throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - private function runGarbageCollection(): void - { - if (random_int(0, 100) > 1) { - return; - } - - $deletedSessions = $this->sessionStore->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } - } - - /** - * Destroy a specific session. - */ - public function destroySession(Uuid $sessionId): void - { - $this->sessionStore->destroy($sessionId); - $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); - } -} diff --git a/src/Server/Handler/MethodHandlerInterface.php b/src/Server/Handler/MethodHandlerInterface.php deleted file mode 100644 index 0f61631b..00000000 --- a/src/Server/Handler/MethodHandlerInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface MethodHandlerInterface -{ - public function supports(HasMethodInterface $message): bool; - - /** - * @throws ExceptionInterface When the handler encounters an error processing the request - */ - public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null; -} diff --git a/src/Server/Handler/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php index 01881a13..08dec76a 100644 --- a/src/Server/Handler/Notification/InitializedHandler.php +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -11,27 +11,24 @@ namespace Mcp\Server\Handler\Notification; -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\InitializedNotification; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class InitializedHandler implements MethodHandlerInterface +final class InitializedHandler implements NotificationHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Notification $notification): bool { - return $message instanceof InitializedNotification; + return $notification instanceof InitializedNotification; } - public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null + public function handle(Notification $message, SessionInterface $session): void { - $session->set('initialized', true); + \assert($message instanceof InitializedNotification); - return null; + $session->set('initialized', true); } } diff --git a/src/Server/Handler/Notification/NotificationHandlerInterface.php b/src/Server/Handler/Notification/NotificationHandlerInterface.php new file mode 100644 index 00000000..8746dc73 --- /dev/null +++ b/src/Server/Handler/Notification/NotificationHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index d9b36066..dea2725b 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -17,11 +17,10 @@ use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,7 +29,7 @@ * @author Christopher Hertel * @author Tobias Nyholm */ -final class CallToolHandler implements MethodHandlerInterface +final class CallToolHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -39,24 +38,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CallToolRequest; + return $request instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CallToolRequest); + \assert($request instanceof CallToolRequest); - $toolName = $message->name; - $arguments = $message->arguments ?? []; + $toolName = $request->name; + $arguments = $request->arguments ?? []; $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); try { $reference = $this->referenceProvider->getTool($toolName); if (null === $reference) { - throw new ToolNotFoundException($message); + throw new ToolNotFoundException($request); } $result = $this->referenceHandler->handle($reference, $arguments); @@ -67,25 +66,25 @@ public function handle(CallToolRequest|HasMethodInterface $message, SessionInter 'result_type' => \gettype($result), ]); - return new Response($message->getId(), new CallToolResult($formatted)); + return new Response($request->getId(), new CallToolResult($formatted)); } catch (ToolNotFoundException $e) { $this->logger->error('Tool not found', ['name' => $toolName]); - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (ToolCallException|ExceptionInterface $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + return Error::forInternalError('Error while executing tool', $request->getId()); } catch (\Throwable $e) { $this->logger->error('Unhandled error during tool execution', [ 'name' => $toolName, 'exception' => $e->getMessage(), ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + return Error::forInternalError('Error while executing tool', $request->getId()); } } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index be4d67d1..6787e48b 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -14,11 +14,10 @@ use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CompletionCompleteRequest; use Mcp\Schema\Result\CompletionCompleteResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -27,7 +26,7 @@ * * @author Kyrian Obikwelu */ -final class CompletionCompleteHandler implements MethodHandlerInterface +final class CompletionCompleteHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -35,43 +34,43 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CompletionCompleteRequest; + return $request instanceof CompletionCompleteRequest; } - public function handle(CompletionCompleteRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CompletionCompleteRequest); + \assert($request instanceof CompletionCompleteRequest); - $name = $message->argument['name'] ?? ''; - $value = $message->argument['value'] ?? ''; + $name = $request->argument['name'] ?? ''; + $value = $request->argument['value'] ?? ''; - $reference = match ($message->ref->type) { - 'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name), - 'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri), + $reference = match ($request->ref->type) { + 'ref/prompt' => $this->referenceProvider->getPrompt($request->ref->name), + 'ref/resource' => $this->referenceProvider->getResourceTemplate($request->ref->uri), default => null, }; if (null === $reference) { - return new Response($message->getId(), new CompletionCompleteResult([])); + return new Response($request->getId(), new CompletionCompleteResult([])); } $providers = $reference->completionProviders; $provider = $providers[$name] ?? null; if (null === $provider) { - return new Response($message->getId(), new CompletionCompleteResult([])); + return new Response($request->getId(), new CompletionCompleteResult([])); } if (\is_string($provider)) { if (!class_exists($provider)) { - return Error::forInternalError('Invalid completion provider', $message->getId()); + return Error::forInternalError('Invalid completion provider', $request->getId()); } $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); } if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $message->getId()); + return Error::forInternalError('Invalid completion provider type', $request->getId()); } try { @@ -80,9 +79,9 @@ public function handle(CompletionCompleteRequest|HasMethodInterface $message, Se $hasMore = $total > 100; $paged = \array_slice($completions, 0, 100); - return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); + return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); } catch (\Throwable) { - return Error::forInternalError('Error while handling completion request', $message->getId()); + return Error::forInternalError('Error while handling completion request', $request->getId()); } } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 758ab9de..cf321981 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -17,11 +17,10 @@ use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -29,7 +28,7 @@ /** * @author Tobias Nyholm */ -final class GetPromptHandler implements MethodHandlerInterface +final class GetPromptHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -38,41 +37,41 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof GetPromptRequest; + return $request instanceof GetPromptRequest; } - public function handle(GetPromptRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof GetPromptRequest); + \assert($request instanceof GetPromptRequest); - $promptName = $message->name; - $arguments = $message->arguments ?? []; + $promptName = $request->name; + $arguments = $request->arguments ?? []; try { $reference = $this->referenceProvider->getPrompt($promptName); if (null === $reference) { - throw new PromptNotFoundException($message); + throw new PromptNotFoundException($request); } $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result); - return new Response($message->getId(), new GetPromptResult($formatted)); + return new Response($request->getId(), new GetPromptResult($formatted)); } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (PromptGetException|ExceptionInterface $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $message->getId()); + return Error::forInternalError('Error while handling prompt', $request->getId()); } catch (\Throwable $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $message->getId()); + return Error::forInternalError('Error while handling prompt', $request->getId()); } } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index e9d7a751..28bf109f 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -12,38 +12,37 @@ namespace Mcp\Server\Handler\Request; use Mcp\Schema\Implementation; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class InitializeHandler implements MethodHandlerInterface +final class InitializeHandler implements RequestHandlerInterface { public function __construct( public readonly ?Configuration $configuration = null, ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof InitializeRequest; + return $request instanceof InitializeRequest; } - public function handle(InitializeRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof InitializeRequest); + \assert($request instanceof InitializeRequest); - $session->set('client_info', $message->clientInfo->jsonSerialize()); + $session->set('client_info', $request->clientInfo->jsonSerialize()); return new Response( - $message->getId(), + $request->getId(), new InitializeResult( $this->configuration->capabilities ?? new ServerCapabilities(), $this->configuration->serverInfo ?? new Implementation(), diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php index 4a5b0556..2db8a7ab 100644 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm */ -final class ListPromptsHandler implements MethodHandlerInterface +final class ListPromptsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListPromptsRequest; + return $request instanceof ListPromptsRequest; } /** * @throws InvalidCursorException */ - public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListPromptsRequest); + \assert($request instanceof ListPromptsRequest); - $page = $this->registry->getPrompts($this->pageSize, $message->cursor); + $page = $this->registry->getPrompts($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListPromptsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php index eadd3427..76b48bb0 100644 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourceTemplatesRequest; use Mcp\Schema\Result\ListResourceTemplatesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class ListResourceTemplatesHandler implements MethodHandlerInterface +final class ListResourceTemplatesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourceTemplatesRequest; + return $request instanceof ListResourceTemplatesRequest; } /** * @throws InvalidCursorException */ - public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourceTemplatesRequest); + \assert($request instanceof ListResourceTemplatesRequest); - $page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor); + $page = $this->registry->getResourceTemplates($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourceTemplatesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php index 383a5f43..7e4a4ce7 100644 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -13,17 +13,16 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Tobias Nyholm */ -final class ListResourcesHandler implements MethodHandlerInterface +final class ListResourcesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +30,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourcesRequest; + return $request instanceof ListResourcesRequest; } /** * @throws InvalidCursorException */ - public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourcesRequest); + \assert($request instanceof ListResourcesRequest); - $page = $this->registry->getResources($this->pageSize, $message->cursor); + $page = $this->registry->getResources($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourcesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php index d34b9a5b..81854a62 100644 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -13,18 +13,17 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel * @author Tobias Nyholm */ -final class ListToolsHandler implements MethodHandlerInterface +final class ListToolsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -32,22 +31,22 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListToolsRequest; + return $request instanceof ListToolsRequest; } /** * @throws InvalidCursorException When the cursor is invalid */ - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListToolsRequest); + \assert($request instanceof ListToolsRequest); - $page = $this->registry->getTools($this->pageSize, $message->cursor); + $page = $this->registry->getTools($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListToolsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php index bc2cae32..378926c1 100644 --- a/src/Server/Handler/Request/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -11,27 +11,26 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class PingHandler implements MethodHandlerInterface +final class PingHandler implements RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof PingRequest; + return $request instanceof PingRequest; } - public function handle(PingRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof PingRequest); + \assert($request instanceof PingRequest); - return new Response($message->getId(), new EmptyResult()); + return new Response($request->getId(), new EmptyResult()); } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 6691021a..17d2781c 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -16,11 +16,10 @@ use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -28,7 +27,7 @@ /** * @author Tobias Nyholm */ -final class ReadResourceHandler implements MethodHandlerInterface +final class ReadResourceHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -37,23 +36,23 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ReadResourceRequest; + return $request instanceof ReadResourceRequest; } - public function handle(ReadResourceRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof ReadResourceRequest); + \assert($request instanceof ReadResourceRequest); - $uri = $message->uri; + $uri = $request->uri; $this->logger->debug('Reading resource', ['uri' => $uri]); try { $reference = $this->referenceProvider->getResource($uri); if (null === $reference) { - throw new ResourceNotFoundException($message); + throw new ResourceNotFoundException($request); } $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); @@ -64,15 +63,15 @@ public function handle(ReadResourceRequest|HasMethodInterface $message, SessionI $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); } - return new Response($message->getId(), new ReadResourceResult($formatted)); + return new Response($request->getId(), new ReadResourceResult($formatted)); } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri]); - return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); + return new Error($request->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); - return Error::forInternalError('Error while reading resource', $message->getId()); + return Error::forInternalError('Error while reading resource', $request->getId()); } } } diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php new file mode 100644 index 00000000..d89b2c1f --- /dev/null +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -0,0 +1,27 @@ + + */ +interface RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php new file mode 100644 index 00000000..11ec6a87 --- /dev/null +++ b/src/Server/Protocol.php @@ -0,0 +1,324 @@ + + * @author Kyrian Obikwelu + */ +class Protocol +{ + /** @var TransportInterface|null */ + private ?TransportInterface $transport = null; + + /** + * @param array $requestHandlers + * @param array $notificationHandlers + */ + public function __construct( + private readonly array $requestHandlers, + private readonly array $notificationHandlers, + private readonly MessageFactory $messageFactory, + private readonly SessionFactoryInterface $sessionFactory, + private readonly SessionStoreInterface $sessionStore, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Connect this protocol to a transport. + * + * The protocol takes ownership of the transport and sets up all callbacks. + * + * @param TransportInterface $transport + */ + public function connect(TransportInterface $transport): void + { + if ($this->transport) { + throw new \RuntimeException('Protocol already connected to a transport'); + } + + $this->transport = $transport; + + $this->transport->onMessage([$this, 'processInput']); + + $this->transport->onSessionEnd([$this, 'destroySession']); + + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); + } + + /** + * Handle an incoming message from the transport. + * + * This is called by the transport whenever ANY message arrives. + */ + public function processInput(string $input, ?Uuid $sessionId): void + { + $this->logger->info('Received message to process.', ['message' => $input]); + + $this->gcSessions(); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to decode json message.', ['exception' => $e]); + $error = Error::forParseError($e->getMessage()); + $this->sendResponse($error, ['session_id' => $sessionId]); + + return; + } + + $session = $this->resolveSession($sessionId, $messages); + if (null === $session) { + return; + } + + foreach ($messages as $message) { + if ($message instanceof InvalidInputMessageException) { + $this->handleInvalidMessage($message, $session); + } elseif ($message instanceof Request) { + $this->handleRequest($message, $session); + } elseif ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message, $session); + } elseif ($message instanceof Notification) { + $this->handleNotification($message, $session); + } + } + + $session->save(); + } + + private function handleInvalidMessage(InvalidInputMessageException $exception, SessionInterface $session): void + { + $this->logger->warning('Failed to create message.', ['exception' => $exception]); + + $error = Error::forInvalidRequest($exception->getMessage()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + + private function handleRequest(Request $request, SessionInterface $session): void + { + $this->logger->info('Handling request.', ['request' => $request]); + + $handlerFound = false; + + foreach ($this->requestHandlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + $handlerFound = true; + + try { + $response = $handler->handle($request, $session); + $this->sendResponse($response, ['session_id' => $session->getId()]); + } catch (\InvalidArgumentException $e) { + $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInvalidParams($e->getMessage(), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInternalError($e->getMessage(), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + + break; + } + + if (!$handlerFound) { + $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); + $this->sendResponse($error, ['session_id' => $session->getId()]); + } + } + + private function handleResponse(Response|Error $response, SessionInterface $session): void + { + $this->logger->info('Handling response.', ['response' => $response]); + // TODO: Implement response handling + } + + private function handleNotification(Notification $notification, SessionInterface $session): void + { + $this->logger->info('Handling notification.', ['notification' => $notification]); + + foreach ($this->notificationHandlers as $handler) { + if (!$handler->supports($notification)) { + continue; + } + + try { + $handler->handle($notification, $session); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Error while handling notification: %s', $e->getMessage()), ['exception' => $e]); + } + } + } + + /** + * @param array $context + */ + public function sendRequest(Request $request, array $context = []): void + { + $this->logger->info('Sending request.', ['request' => $request, 'context' => $context]); + // TODO: Implement request sending + } + + /** + * @param array $context + */ + public function sendResponse(Response|Error $response, array $context = []): void + { + $this->logger->info('Sending response.', ['response' => $response, 'context' => $context]); + + $encoded = null; + + try { + if ($response instanceof Response && [] === $response->result) { + $encoded = json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + } + + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message_id' => $response->getId(), + 'exception' => $e, + ]); + + $fallbackError = new Error( + id: $response->getId(), + code: Error::INTERNAL_ERROR, + message: 'Response could not be encoded to JSON' + ); + + $encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR); + } + $context['type'] = 'response'; + + $this->transport->send($encoded, $context); + } + + /** + * @param array $context + */ + public function sendNotification(Notification $notification, array $context = []): void + { + $this->logger->info('Sending notification.', ['notification' => $notification, 'context' => $context]); + $context['type'] = 'notification'; + // TODO: Implement notification sending + } + + /** + * @param array $messages + */ + private function hasInitializeRequest(array $messages): bool + { + foreach ($messages as $message) { + if ($message instanceof InitializeRequest) { + return true; + } + } + + return false; + } + + /** + * Resolves and validates the session based on the request context. + * + * @param Uuid|null $sessionId The session ID from the transport + * @param array $messages The parsed messages + */ + private function resolveSession(?Uuid $sessionId, array $messages): ?SessionInterface + { + if ($this->hasInitializeRequest($messages)) { + // Spec: An initialize request must not be part of a batch. + if (\count($messages) > 1) { + $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); + $this->sendResponse($error, ['session_id' => $sessionId]); + + return null; + } + + // Spec: An initialize request must not have a session ID. + if ($sessionId) { + $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); + $this->sendResponse($error); + + return null; + } + + return $this->sessionFactory->create($this->sessionStore); + } + + if (!$sessionId) { + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + $this->sendResponse($error, ['status_code' => 400]); + + return null; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + $this->sendResponse($error, ['status_code' => 404]); + + return null; + } + + return $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + private function gcSessions(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->sessionStore->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => \count($deletedSessions), + 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } + + /** + * Destroy a specific session. + */ + public function destroySession(Uuid $sessionId): void + { + $this->sessionStore->destroy($sessionId); + $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); + } +} diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index c910154f..a082d070 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -27,9 +27,11 @@ interface TransportInterface public function initialize(): void; /** - * Registers a callback that will be invoked whenever the transport receives an incoming message. + * Register callback for ALL incoming messages. * - * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs + * The transport calls this whenever ANY message arrives, regardless of source. + * + * @param callable(string $message, ?Uuid $sessionId): void $listener */ public function onMessage(callable $listener): void; @@ -45,15 +47,21 @@ public function onMessage(callable $listener): void; public function listen(): mixed; /** - * Sends a raw JSON-RPC message string back to the client. + * Send a message to the client. + * + * The transport decides HOW to send based on context * - * @param string $data The JSON-RPC message string to send - * @param array $context The context of the message + * @param array $context Context about this message: + * - 'session_id': Uuid|null + * - 'type': 'response'|'request'|'notification' + * - 'in_reply_to': int|string|null (ID of request this responds to) + * - 'expects_response': bool (if this is a request needing response) */ public function send(string $data, array $context): void; /** - * Registers a callback that will be invoked when a session needs to be destroyed. + * Register callback for session termination. + * * This can happen when a client disconnects or explicitly ends their session. * * @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session @@ -65,7 +73,6 @@ public function onSessionEnd(callable $listener): void; * * This method should be called when the transport is no longer needed. * It should clean up any resources and close any connections. - * `Server::run()` calls this automatically after `listen()` exits. */ public function close(): void; } diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php deleted file mode 100644 index be9820ed..00000000 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ /dev/null @@ -1,118 +0,0 @@ -getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle'); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->once())->method('handle'); - - $sessionFactory = $this->createMock(SessionFactoryInterface::class); - $sessionStore = $this->createMock(SessionStoreInterface::class); - $session = $this->createMock(SessionInterface::class); - - $sessionFactory->method('create')->willReturn($session); - $sessionFactory->method('createWithId')->willReturn($session); - $sessionStore->method('exists')->willReturn(true); - - $jsonRpc = new JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - iterator_to_array($result); - } - - #[TestDox('Make sure a single request can NOT be handled by multiple handlers.')] - public function testHandleMultipleRequests() - { - $handlerA = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->never())->method('handle'); - - $sessionFactory = $this->createMock(SessionFactoryInterface::class); - $sessionStore = $this->createMock(SessionStoreInterface::class); - $session = $this->createMock(SessionInterface::class); - - $sessionFactory->method('create')->willReturn($session); - $sessionFactory->method('createWithId')->willReturn($session); - $sessionStore->method('exists')->willReturn(true); - - $jsonRpc = new JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - iterator_to_array($result); - } -} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 12d2b233..7f591e57 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -13,9 +13,12 @@ use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\CancelledNotification; use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Request\PingRequest; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -28,68 +31,373 @@ protected function setUp(): void CancelledNotification::class, InitializedNotification::class, GetPromptRequest::class, + PingRequest::class, ]); } - public function testCreateRequest() + public function testCreateRequestWithIntegerId(): void { $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var GetPromptRequest $result */ + $result = $results[0]; $this->assertInstanceOf(GetPromptRequest::class, $result); $this->assertSame('prompts/get', $result::getMethod()); $this->assertSame('create_story', $result->name); $this->assertSame(123, $result->getId()); } - public function testCreateNotification() + public function testCreateRequestWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "method": "ping", "id": "abc-123"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var PingRequest $result */ + $result = $results[0]; + $this->assertInstanceOf(PingRequest::class, $result); + $this->assertSame('ping', $result::getMethod()); + $this->assertSame('abc-123', $result->getId()); + } + + public function testCreateNotification(): void { $json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var CancelledNotification $result */ + $result = $results[0]; $this->assertInstanceOf(CancelledNotification::class, $result); $this->assertSame('notifications/cancelled', $result::getMethod()); $this->assertSame(12345, $result->requestId); } - public function testInvalidJson() + public function testCreateNotificationWithoutParams(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var InitializedNotification $result */ + $result = $results[0]; + $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertSame('notifications/initialized', $result::getMethod()); + } + + public function testCreateResponseWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 456, "result": {"content": [{"type": "text", "text": "Hello"}]}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(456, $result->getId()); + $this->assertIsArray($result->result); + $this->assertArrayHasKey('content', $result->result); + } + + public function testCreateResponseWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "response-1", "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('response-1', $result->getId()); + $this->assertEquals(['status' => 'ok'], $result->result); + } + + public function testCreateErrorWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 789, "error": {"code": -32601, "message": "Method not found"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(789, $result->getId()); + $this->assertSame(-32601, $result->code); + $this->assertSame('Method not found', $result->message); + $this->assertNull($result->data); + } + + public function testCreateErrorWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "err-1", "error": {"code": -32600, "message": "Invalid request"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame('err-1', $result->getId()); + $this->assertSame(-32600, $result->code); + $this->assertSame('Invalid request', $result->message); + } + + public function testCreateErrorWithData(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Server error", "data": {"details": "Something went wrong"}}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertEquals(['details' => 'Something went wrong'], $result->data); + } + + public function testBatchRequests(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test"}, "id": 2}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(3, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(GetPromptRequest::class, $results[1]); + $this->assertInstanceOf(InitializedNotification::class, $results[2]); + } + + public function testBatchWithMixedMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "id": 2, "result": {"status": "ok"}}, + {"jsonrpc": "2.0", "id": 3, "error": {"code": -32600, "message": "Invalid"}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(Response::class, $results[1]); + $this->assertInstanceOf(Error::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testInvalidJson(): void { $this->expectException(\JsonException::class); - $this->first($this->factory->create('invalid json')); + $this->factory->create('invalid json'); } - public function testMissingMethod() + public function testMissingJsonRpcVersion(): void { - $result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}')); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $json = '{"method": "ping", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); } - public function testBatchMissingMethod() + public function testInvalidJsonRpcVersion(): void { - $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}]'); + $json = '{"jsonrpc": "1.0", "method": "ping", "id": 1}'; - $results = iterator_to_array($results); - $result = array_shift($results); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $results = $this->factory->create($json); - $result = array_shift($results); - $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); + } + + public function testMissingAllIdentifyingFields(): void + { + $json = '{"jsonrpc": "2.0", "params": {}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('missing', $results[0]->getMessage()); + } + + public function testUnknownMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testUnknownNotificationMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/unknown"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testNotificationMethodUsedAsRequest(): void + { + // When a notification method is used with an id, it should still create the notification + // The fromArray validation will handle any issues + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + // The notification class will reject the id in fromArray validation + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testErrorMissingId(): void + { + $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorMissingCode(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorMissingMessage(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); + } + + public function testBatchWithErrors(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "params": {}, "id": 2}, + {"jsonrpc": "2.0", "method": "unknown/method", "id": 3}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testMakeFactoryWithDefaultMessages(): void + { + $factory = MessageFactory::make(); + $json = '{"jsonrpc": "2.0", "method": "ping", "id": 1}'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + } + + public function testResponseWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testResponseWithNonArrayResult(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "result": "not an array"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('result', $results[0]->getMessage()); } - /** - * @param iterable $items - */ - private function first(iterable $items): mixed + public function testErrorWithNonArrayErrorField(): void { - foreach ($items as $item) { - return $item; - } + $json = '{"jsonrpc": "2.0", "id": 1, "error": "not an object"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('error', $results[0]->getMessage()); + } + + public function testErrorWithInvalidCodeType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": "not-a-number", "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorWithInvalidMessageType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600, "message": 123}}'; + + $results = $this->factory->create($json); - return null; + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); } } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php new file mode 100644 index 00000000..859c4271 --- /dev/null +++ b/tests/Unit/Server/ProtocolTest.php @@ -0,0 +1,615 @@ + */ + private MockObject&TransportInterface $transport; + + protected function setUp(): void + { + $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); + $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->transport = $this->createMock(TransportInterface::class); + } + + #[TestDox('A single notification can be handled by multiple handlers')] + public function testNotificationHandledByMultipleHandlers(): void + { + $handlerA = $this->createMock(NotificationHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('handle'); + + $handlerB = $this->createMock(NotificationHandlerInterface::class); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('handle'); + + $handlerC = $this->createMock(NotificationHandlerInterface::class); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->once())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handlerA, $handlerB, $handlerC], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + } + + #[TestDox('A single request is handled only by the first matching handler')] + public function testRequestHandledByFirstMatchingHandler(): void + { + $handlerA = $this->createMock(RequestHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); + + $handlerB = $this->createMock(RequestHandlerInterface::class); + $handlerB->method('supports')->willReturn(false); + $handlerB->expects($this->never())->method('handle'); + + $handlerC = $this->createMock(RequestHandlerInterface::class); + $handlerC->method('supports')->willReturn(true); + $handlerC->expects($this->never())->method('handle'); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + $session->method('getId')->willReturn(Uuid::v4()); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handlerA, $handlerB, $handlerC], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Initialize request must not have a session ID')] + public function testInitializeRequestWithSessionIdReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'session ID MUST NOT be sent'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}', + $sessionId + ); + } + + #[TestDox('Initialize request must not be part of a batch')] + public function testInitializeRequestInBatchReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'MUST NOT be part of a batch'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '[{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', + null + ); + } + + #[TestDox('Non-initialize requests require a session ID')] + public function testNonInitializeRequestWithoutSessionIdReturnsError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'session id is REQUIRED'); + }), + $this->callback(function ($context) { + return isset($context['status_code']) && 400 === $context['status_code']; + }) + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + null + ); + } + + #[TestDox('Non-existent session ID returns error')] + public function testNonExistentSessionIdReturnsError(): void + { + $this->sessionStore->method('exists')->willReturn(false); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && str_contains($decoded['error']['message'], 'Session not found or has expired'); + }), + $this->callback(function ($context) { + return isset($context['status_code']) && 404 === $context['status_code']; + }) + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Invalid JSON returns parse error')] + public function testInvalidJsonReturnsParseError(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::PARSE_ERROR === $decoded['error']['code']; + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + 'invalid json', + null + ); + } + + #[TestDox('Invalid message structure returns error')] + public function testInvalidMessageStructureReturnsError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INVALID_REQUEST === $decoded['error']['code']; + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "params": {}}', + $sessionId + ); + } + + #[TestDox('Request without handler returns method not found error')] + public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::METHOD_NOT_FOUND === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'No handler found'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', + $sessionId + ); + } + + #[TestDox('Handler throwing InvalidArgumentException returns invalid params error')] + public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \InvalidArgumentException('Invalid parameter')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INVALID_PARAMS === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'Invalid parameter'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', + $sessionId + ); + } + + #[TestDox('Handler throwing unexpected exception returns internal error')] + public function testHandlerUnexpectedExceptionReturnsInternalError(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \RuntimeException('Unexpected error')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['error']) + && Error::INTERNAL_ERROR === $decoded['error']['code'] + && str_contains($decoded['error']['message'], 'Unexpected error'); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', + $sessionId + ); + } + + #[TestDox('Notification handler exceptions are caught and logged')] + public function testNotificationHandlerExceptionsAreCaught(): void + { + $handler = $this->createMock(NotificationHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willThrowException(new \RuntimeException('Handler error')); + + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [$handler], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->expectNotToPerformAssertions(); + } + + #[TestDox('Successful request returns response with session ID')] + public function testSuccessfulRequestReturnsResponseWithSessionId(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('supports')->willReturn(true); + $handler->method('handle')->willReturn(new Response(1, ['status' => 'ok'])); + + $sessionId = Uuid::v4(); + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn($sessionId); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $this->transport->expects($this->once()) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->callback(function ($context) use ($sessionId) { + return isset($context['session_id']) && $context['session_id']->equals($sessionId); + }) + ); + + $protocol = new Protocol( + requestHandlers: [$handler], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $protocol->processInput( + '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', + $sessionId + ); + } + + #[TestDox('Batch requests are processed and send multiple responses')] + public function testBatchRequestsAreProcessed(): void + { + $handlerA = $this->createMock(RequestHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->method('handle')->willReturnCallback(function ($request) { + return new Response($request->getId(), ['method' => $request::getMethod()]); + }); + + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + // Expect two calls to send() + $this->transport->expects($this->exactly(2)) + ->method('send') + ->with( + $this->callback(function ($data) { + $decoded = json_decode($data, true); + + return isset($decoded['result']); + }), + $this->anything() + ); + + $protocol = new Protocol( + requestHandlers: [$handlerA], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '[{"jsonrpc": "2.0", "method": "tools/list", "id": 1}, {"jsonrpc": "2.0", "method": "prompts/list", "id": 2}]', + $sessionId + ); + } + + #[TestDox('Session is saved after processing')] + public function testSessionIsSavedAfterProcessing(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(true); + + $session->expects($this->once())->method('save'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + } + + #[TestDox('Destroy session removes session from store')] + public function testDestroySessionRemovesSession(): void + { + $sessionId = Uuid::v4(); + + $this->sessionStore->expects($this->once()) + ->method('destroy') + ->with($sessionId); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->destroySession($sessionId); + } +} diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 1c58f58b..f7a8a370 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -12,35 +12,146 @@ namespace Mcp\Tests\Unit; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Transport\InMemoryTransport; +use Mcp\Server\Builder; +use Mcp\Server\Protocol; +use Mcp\Server\Transport\TransportInterface; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ServerTest extends TestCase +final class ServerTest extends TestCase { - public function testJsonExceptions() + /** @var MockObject&Protocol */ + private $protocol; + + /** @var MockObject&TransportInterface */ + private $transport; + + protected function setUp(): void + { + $this->protocol = $this->createMock(Protocol::class); + $this->transport = $this->createMock(TransportInterface::class); + } + + #[TestDox('builder() returns a Builder instance')] + public function testBuilderReturnsBuilderInstance(): void + { + $builder = Server::builder(); + + $this->assertInstanceOf(Builder::class, $builder); + } + + #[TestDox('run() orchestrates transport lifecycle and protocol connection')] + public function testRunOrchestatesTransportLifecycle(): void + { + $callOrder = []; + + $this->transport->expects($this->once()) + ->method('initialize') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'initialize'; + }); + + $this->protocol->expects($this->once()) + ->method('connect') + ->with($this->transport) + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'connect'; + }); + + $this->transport->expects($this->once()) + ->method('listen') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'listen'; + + return 0; + }); + + $this->transport->expects($this->once()) + ->method('close') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'close'; + }); + + $server = new Server($this->protocol); + $result = $server->run($this->transport); + + $this->assertEquals([ + 'initialize', + 'connect', + 'listen', + 'close', + ], $callOrder); + + $this->assertEquals(0, $result); + } + + #[TestDox('run() closes transport even if listen() throws exception')] + public function testRunClosesTransportEvenOnException(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + + $this->transport->expects($this->once()) + ->method('listen') + ->willThrowException(new \RuntimeException('Transport error')); + + // close() should still be called even though listen() threw + $this->transport->expects($this->once())->method('close'); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Transport error'); + + $server->run($this->transport); + } + + #[TestDox('run() propagates exception if initialize() throws')] + public function testRunPropagatesInitializeException(): void { - $handler = $this->getMockBuilder(JsonRpcHandler::class) - ->disableOriginalConstructor() - ->onlyMethods(['process']) - ->getMock(); - - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( - [['{"jsonrpc":"2.0","id":0,"error":{"code":-32700,"message":"Parse error"}}', []]], - [['success', []]] - ); - - $transport = $this->getMockBuilder(InMemoryTransport::class) - ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send', 'close']) - ->getMock(); - $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( - null, - null - ); - $transport->expects($this->once())->method('close'); - - $server = new Server($handler); - $server->run($transport); + $this->transport->expects($this->once()) + ->method('initialize') + ->willThrowException(new \RuntimeException('Initialize error')); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Initialize error'); + + $server->run($this->transport); + } + + #[TestDox('run() returns value from transport.listen()')] + public function testRunReturnsTransportListenValue(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + $this->transport->method('close'); + + $expectedReturn = 42; + $this->transport->expects($this->once()) + ->method('listen') + ->willReturn($expectedReturn); + + $server = new Server($this->protocol); + $result = $server->run($this->transport); + + $this->assertEquals($expectedReturn, $result); + } + + #[TestDox('run() connects protocol to transport')] + public function testRunConnectsProtocolToTransport(): void + { + $this->transport->method('initialize'); + $this->transport->method('listen')->willReturn(0); + $this->transport->method('close'); + + $this->protocol->expects($this->once()) + ->method('connect') + ->with($this->identicalTo($this->transport)); + + $server = new Server($this->protocol); + $server->run($this->transport); } } From 6100ffcf3ff4b402b8f727844ce40e366f87fe9b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 20 Oct 2025 22:41:24 +0200 Subject: [PATCH 36/66] Missing Mcp-Protocol-Version to access control (#110) --- src/Server/Transport/StreamableHttpTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 070657de..f4952952 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -44,7 +44,7 @@ class StreamableHttpTransport implements TransportInterface private array $corsHeaders = [ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', ]; public function __construct( From d347c844728bf8ed2352408c83a40012c642479a Mon Sep 17 00:00:00 2001 From: ineersa Date: Fri, 24 Oct 2025 15:18:42 -0400 Subject: [PATCH 37/66] StructuredContent call tool result, added ability to return error from tool (#93) --- src/Schema/Result/CallToolResult.php | 14 ++++- .../Handler/Request/CallToolHandler.php | 7 ++- .../Handler/Request/CallToolHandlerTest.php | 58 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 8a01bbb2..da3be4f6 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -38,12 +38,14 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param Content[] $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param mixed[] $structuredContent JSON content for `structuredContent` */ public function __construct( public readonly array $content, public readonly bool $isError = false, + public readonly ?array $structuredContent = null, ) { foreach ($this->content as $item) { if (!$item instanceof Content) { @@ -107,9 +109,15 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - return [ + $result = [ 'content' => $this->content, 'isError' => $this->isError, ]; + + if ($this->structuredContent) { + $result['structuredContent'] = $this->structuredContent; + } + + return $result; } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index dea2725b..c1f10b9d 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -59,14 +59,17 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result); + + if (!$result instanceof CallToolResult) { + $result = new CallToolResult($reference->formatResult($result)); + } $this->logger->debug('Tool executed successfully', [ 'name' => $toolName, 'result_type' => \gettype($result), ]); - return new Response($request->getId(), new CallToolResult($formatted)); + return new Response($request->getId(), $result); } catch (ToolNotFoundException $e) { $this->logger->error('Tool not found', ['name' => $toolName]); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 4423cbd6..11b799bf 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -342,6 +342,64 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->assertEquals($expectedResult, $response->result); } + public function testHandleReturnsStructuredContentResult(): void + { + $request = $this->createCallToolRequest('structured_tool', ['query' => 'php']); + $toolReference = $this->createMock(ToolReference::class); + $structuredResult = new CallToolResult([new TextContent('Rendered results')], false, ['result' => 'Rendered results']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('structured_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['query' => 'php']) + ->willReturn($structuredResult); + + $toolReference + ->expects($this->never()) + ->method('formatResult'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($structuredResult, $response->result); + $this->assertEquals(['result' => 'Rendered results'], $response->result->jsonSerialize()['structuredContent'] ?? []); + } + + public function testHandleReturnsCallToolResult(): void + { + $request = $this->createCallToolRequest('result_tool', ['query' => 'php']); + $toolReference = $this->createMock(ToolReference::class); + $callToolResult = new CallToolResult([new TextContent('Error result')], true); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('result_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['query' => 'php']) + ->willReturn($callToolResult); + + $toolReference + ->expects($this->never()) + ->method('formatResult'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($callToolResult, $response->result); + $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); + } + /** * @param array $arguments */ From e4a82f7474871b78f097271a659743cbe1f335a2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 24 Oct 2025 21:43:16 +0200 Subject: [PATCH 38/66] refactor: registry loader (#111) --- .../Registry/Loader/ArrayLoader.php | 299 ++++++++++++++++++ .../Registry/Loader/DiscoveryLoader.php | 49 +++ .../Registry/Loader/LoaderInterface.php | 22 ++ src/Server/Builder.php | 276 +--------------- 4 files changed, 384 insertions(+), 262 deletions(-) create mode 100644 src/Capability/Registry/Loader/ArrayLoader.php create mode 100644 src/Capability/Registry/Loader/DiscoveryLoader.php create mode 100644 src/Capability/Registry/Loader/LoaderInterface.php diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php new file mode 100644 index 00000000..7420bc75 --- /dev/null +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -0,0 +1,299 @@ + + * + * @phpstan-import-type Handler from ElementReference + */ +final class ArrayLoader implements LoaderInterface +{ + /** + * @param array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * annotations: ?ToolAnnotations, + * }[] $tools + * @param array{ + * handler: Handler, + * uri: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * size: int|null, + * annotations: ?Annotations, + * }[] $resources + * @param array{ + * handler: Handler, + * uriTemplate: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * annotations: ?Annotations, + * }[] $resourceTemplates + * @param array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * }[] $prompts + */ + public function __construct( + private array $tools = [], + private array $resources = [], + private array $resourceTemplates = [], + private array $prompts = [], + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function load(ReferenceRegistryInterface $registry): void + { + $docBlockParser = new DocBlockParser(logger: $this->logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + + // Register Tools + foreach ($this->tools as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + + $tool = new Tool($name, $inputSchema, $description, $data['annotations']); + $registry->registerTool($tool, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual tool', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Resources + foreach ($this->resources as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uri = $data['uri']; + $mimeType = $data['mimeType']; + $size = $data['size']; + $annotations = $data['annotations']; + + $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); + $registry->registerResource($resource, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual resource', + ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Templates + foreach ($this->resourceTemplates as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uriTemplate = $data['uriTemplate']; + $mimeType = $data['mimeType']; + $annotations = $data['annotations']; + + $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual template', + ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Prompts + foreach ($this->prompts as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $arguments = []; + $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( + $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), + ) : []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$'.$param->getName()] ?? null; + $arguments[] = new PromptArgument( + $param->getName(), + $paramTag ? trim((string) $paramTag->getDescription()) : null, + !$param->isOptional() && !$param->isDefaultValueAvailable(), + ); + } + + $prompt = new Prompt($name, $description, $arguments); + $completionProviders = $this->getCompletionProviders($reflection); + $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual prompt', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); + } + } + + $this->logger->debug('Manual element registration complete.'); + } + + /** + * @param Handler $handler + */ + private function getHandlerDescription(\Closure|array|string $handler): string + { + if ($handler instanceof \Closure) { + return 'Closure'; + } + + if (\is_array($handler)) { + return \sprintf( + '%s::%s', + \is_object($handler[0]) ? $handler[0]::class : $handler[0], + $handler[1], + ); + } + + return (string) $handler; + } + + /** + * @return array + */ + private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array + { + $completionProviders = []; + foreach ($reflection->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes( + CompletionProvider::class, + \ReflectionAttribute::IS_INSTANCEOF, + ); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + + if ($attributeInstance->provider) { + $completionProviders[$param->getName()] = $attributeInstance->provider; + } elseif ($attributeInstance->providerClass) { + $completionProviders[$param->getName()] = $attributeInstance->providerClass; + } elseif ($attributeInstance->values) { + $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); + } elseif ($attributeInstance->enum) { + $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); + } + } + } + + return $completionProviders; + } +} diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php new file mode 100644 index 00000000..2fadaadf --- /dev/null +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -0,0 +1,49 @@ + + */ +final class DiscoveryLoader implements LoaderInterface +{ + /** + * @param string[] $scanDirs + * @param array|string[] $excludeDirs + */ + public function __construct( + private string $basePath, + private array $scanDirs, + private array $excludeDirs, + private LoggerInterface $logger, + private ?CacheInterface $cache = null, + ) { + } + + public function load(ReferenceRegistryInterface $registry): void + { + // This now encapsulates the discovery process + $discoverer = new Discoverer($registry, $this->logger); + + $cachedDiscoverer = $this->cache + ? new CachedDiscoverer($discoverer, $this->cache, $this->logger) + : $discoverer; + + $cachedDiscoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + } +} diff --git a/src/Capability/Registry/Loader/LoaderInterface.php b/src/Capability/Registry/Loader/LoaderInterface.php new file mode 100644 index 00000000..5b7ba6a9 --- /dev/null +++ b/src/Capability/Registry/Loader/LoaderInterface.php @@ -0,0 +1,22 @@ + + */ +interface LoaderInterface +{ + public function load(ReferenceRegistryInterface $registry): void; +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 95ab9ea1..886e61a8 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -11,29 +11,16 @@ namespace Mcp\Server; -use Mcp\Capability\Attribute\CompletionProvider; -use Mcp\Capability\Completion\EnumCompletionProvider; -use Mcp\Capability\Completion\ListCompletionProvider; -use Mcp\Capability\Completion\ProviderInterface; -use Mcp\Capability\Discovery\CachedDiscoverer; -use Mcp\Capability\Discovery\Discoverer; -use Mcp\Capability\Discovery\DocBlockParser; -use Mcp\Capability\Discovery\HandlerResolver; -use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\DiscoveryLoader; use Mcp\Capability\Registry\ReferenceHandler; -use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Implementation; -use Mcp\Schema\Prompt; -use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; -use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; -use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; @@ -376,13 +363,20 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - $this->registerCapabilities($registry, $logger); - if ($this->serverCapabilities) { - $registry->setServerCapabilities($this->serverCapabilities); - } + $loaders = [ + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), + ]; if (null !== $this->discoveryBasePath) { - $this->performDiscovery($registry, $logger); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $logger, $this->discoveryCache); + } + + foreach ($loaders as $loader) { + $loader->load($registry); + } + + if ($this->serverCapabilities) { + $registry->setServerCapabilities($this->serverCapabilities); } $sessionTtl = $this->sessionTtl ?? 3600; @@ -421,246 +415,4 @@ public function build(): Server return new Server($protocol, $logger); } - - private function performDiscovery( - Registry\ReferenceRegistryInterface $registry, - LoggerInterface $logger, - ): void { - $discovery = new Discoverer($registry, $logger); - - if (null !== $this->discoveryCache) { - $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); - } - - $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); - } - - /** - * Helper to perform the actual registration based on stored data. - * Moved into the builder. - */ - private function registerCapabilities( - Registry\ReferenceRegistryInterface $registry, - LoggerInterface $logger = new NullLogger(), - ): void { - if (empty($this->tools) && empty($this->resources) && empty($this->resourceTemplates) && empty($this->prompts)) { - return; - } - - $docBlockParser = new DocBlockParser(logger: $logger); - $schemaGenerator = new SchemaGenerator($docBlockParser); - - // Register Tools - foreach ($this->tools as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); - - $tool = new Tool($name, $inputSchema, $description, $data['annotations']); - $registry->registerTool($tool, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual tool', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Resources - foreach ($this->resources as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $uri = $data['uri']; - $mimeType = $data['mimeType']; - $size = $data['size']; - $annotations = $data['annotations']; - - $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); - $registry->registerResource($resource, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual resource', - ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Templates - foreach ($this->resourceTemplates as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $uriTemplate = $data['uriTemplate']; - $mimeType = $data['mimeType']; - $annotations = $data['annotations']; - - $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual template', - ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Prompts - foreach ($this->prompts as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( - $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), - ) : []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - - // Basic DI check (heuristic) - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $paramTag = $paramTags['$'.$param->getName()] ?? null; - $arguments[] = new PromptArgument( - $param->getName(), - $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable(), - ); - } - - $prompt = new Prompt($name, $description, $arguments); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual prompt', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - $logger->debug('Manual element registration complete.'); - } - - /** - * @param Handler $handler - */ - private function getHandlerDescription(\Closure|array|string $handler): string - { - if ($handler instanceof \Closure) { - return 'Closure'; - } - - if (\is_array($handler)) { - return \sprintf( - '%s::%s', - \is_object($handler[0]) ? $handler[0]::class : $handler[0], - $handler[1], - ); - } - - return (string) $handler; - } - - /** - * @return array - */ - private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array - { - $completionProviders = []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $completionAttributes = $param->getAttributes( - CompletionProvider::class, - \ReflectionAttribute::IS_INSTANCEOF, - ); - if (!empty($completionAttributes)) { - $attributeInstance = $completionAttributes[0]->newInstance(); - - if ($attributeInstance->provider) { - $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->providerClass; - } elseif ($attributeInstance->values) { - $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); - } elseif ($attributeInstance->enum) { - $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); - } - } - } - - return $completionProviders; - } } From e5a4cb5906dba1add3b750cf38e8cf3ad3f2e740 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 26 Oct 2025 21:49:45 +0100 Subject: [PATCH 39/66] Actually use cache across example runs (#116) --- .gitignore | 1 + examples/http-combined-registration/server.php | 2 +- examples/http-complex-tool-schema/server.php | 2 +- examples/http-discovery-userprofile/server.php | 2 +- examples/http-schema-showcase/server.php | 2 +- examples/stdio-cached-discovery/server.php | 4 ++-- examples/stdio-custom-dependencies/server.php | 2 +- examples/stdio-discovery-calculator/server.php | 2 +- examples/stdio-env-variables/server.php | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index dda65497..8f118b26 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ composer.lock coverage vendor examples/**/dev.log +examples/**/cache examples/**/sessions diff --git a/examples/http-combined-registration/server.php b/examples/http-combined-registration/server.php index 660cf3c1..f5d98623 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/http-combined-registration/server.php @@ -31,7 +31,7 @@ ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->addTool([ManualHandlers::class, 'manualGreeter']) ->addResource( [ManualHandlers::class, 'getPriorityConfigManual'], diff --git a/examples/http-complex-tool-schema/server.php b/examples/http-complex-tool-schema/server.php index a3795a6c..261ad242 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/http-complex-tool-schema/server.php @@ -30,7 +30,7 @@ ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); diff --git a/examples/http-discovery-userprofile/server.php b/examples/http-discovery-userprofile/server.php index 6f859c0a..36c478e3 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/http-discovery-userprofile/server.php @@ -30,7 +30,7 @@ ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->addTool( function (float $a, float $b, string $operation = 'add'): array { $result = match ($operation) { diff --git a/examples/http-schema-showcase/server.php b/examples/http-schema-showcase/server.php index e8d6d176..cc8c4e4c 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/http-schema-showcase/server.php @@ -30,7 +30,7 @@ ->setContainer(container()) ->setLogger(logger()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); diff --git a/examples/stdio-cached-discovery/server.php b/examples/stdio-cached-discovery/server.php index 2f10de0e..38b5e9ce 100644 --- a/examples/stdio-cached-discovery/server.php +++ b/examples/stdio-cached-discovery/server.php @@ -17,7 +17,7 @@ use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; use Symfony\Component\Cache\Psr16Cache; logger()->info('Starting MCP Cached Discovery Calculator Server...'); @@ -26,7 +26,7 @@ ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) + ->setDiscovery(__DIR__, cache: new Psr16Cache(new PhpFilesAdapter(directory: __DIR__.'/cache'))) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/examples/stdio-custom-dependencies/server.php b/examples/stdio-custom-dependencies/server.php index 743fd78b..659f4499 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/stdio-custom-dependencies/server.php @@ -34,7 +34,7 @@ ->setServerInfo('Task Manager Server', '1.0.0') ->setLogger(logger()) ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/examples/stdio-discovery-calculator/server.php b/examples/stdio-discovery-calculator/server.php index fe223240..3f113489 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/stdio-discovery-calculator/server.php @@ -23,7 +23,7 @@ ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); $transport = new StdioTransport(logger: logger()); diff --git a/examples/stdio-env-variables/server.php b/examples/stdio-env-variables/server.php index 62c03501..13c36bcf 100644 --- a/examples/stdio-env-variables/server.php +++ b/examples/stdio-env-variables/server.php @@ -52,7 +52,7 @@ $server = Server::builder() ->setServerInfo('Env Var Server', '1.0.0') ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); $transport = new StdioTransport(logger: logger()); From 0963d243bc664ce59b943f8087cf75682e27d5f2 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 26 Oct 2025 22:06:10 +0100 Subject: [PATCH 40/66] feat: add PSR-17 factory auto-discovery to HTTP transport (#119) --- composer.json | 6 +- docs/transports.md | 88 ++++++++++--------- .../http-combined-registration/server.php | 9 +- examples/http-complex-tool-schema/server.php | 9 +- .../http-discovery-userprofile/server.php | 9 +- examples/http-schema-showcase/server.php | 9 +- .../Transport/StreamableHttpTransport.php | 11 ++- 7 files changed, 73 insertions(+), 68 deletions(-) diff --git a/composer.json b/composer.json index 00188f46..ea472ccb 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": "^8.1", "ext-fileinfo": "*", "opis/json-schema": "^2.4", + "php-http/discovery": "^1.20", "phpdocumentor/reflection-docblock": "^5.6", "psr/clock": "^1.0", "psr/container": "^2.0", @@ -64,6 +65,9 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false + } } } diff --git a/docs/transports.md b/docs/transports.md index 75902c93..83129ddf 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -95,24 +95,47 @@ and process requests and send responses. It provides a flexible architecture tha ```php use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; +// PSR-17 factories are automatically discovered $transport = new StreamableHttpTransport( - request: $serverRequest, // PSR-7 server request - responseFactory: $responseFactory, // PSR-17 response factory - streamFactory: $streamFactory, // PSR-17 stream factory - logger: $logger // Optional PSR-3 logger + request: $serverRequest, // PSR-7 server request + responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null) + streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null) + logger: $logger // Optional PSR-3 logger ); ``` ### Parameters - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request -- **`responseFactory`** (required): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses -- **`streamFactory`** (required): `StreamFactoryInterface` - PSR-17 factory for creating response body streams +- **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. +- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. +### PSR-17 Auto-Discovery + +The transport automatically discovers PSR-17 factory implementations from these popular packages: + +- `nyholm/psr7` +- `guzzlehttp/psr7` +- `slim/psr7` +- `laminas/laminas-diactoros` +- And other PSR-17 compatible implementations + +```bash +# Install any PSR-17 package - discovery works automatically +composer require nyholm/psr7 +``` + +If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly: + +```php +use Nyholm\Psr7\Factory\Psr17Factory; + +$psr17Factory = new Psr17Factory(); +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +``` + ### Architecture The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that @@ -126,19 +149,17 @@ This design allows integration with any PHP framework or application that suppor ### Basic Usage (Standalone) -Here's an opinionated example using Nyholm PSR-7 and Laminas emitter: +Here's a simplified example using PSR-17 discovery and Laminas emitter: ```php +use Http\Discovery\Psr17Factory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; use Mcp\Server\Session\FileSessionStore; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('HTTP Server', '1.0.0') @@ -146,7 +167,7 @@ $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$transport = new StreamableHttpTransport($request); $response = $server->run($transport); @@ -174,27 +195,23 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Nyholm\Psr7\Factory\Psr17Factory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; class McpController { - #[Route('/mcp', name: 'mcp_endpoint'] + #[Route('/mcp', name: 'mcp_endpoint')] public function handle(Request $request, Server $server): Response { - // Create PSR-7 factories - $psr17Factory = new Psr17Factory(); - $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); $httpFoundationFactory = new HttpFoundationFactory(); - - // Convert Symfony request to PSR-7 $psrRequest = $psrHttpFactory->createRequest($request); - - // Process with MCP - $transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory); + + // Process with MCP (factories auto-discovered) + $transport = new StreamableHttpTransport($psrRequest); $psrResponse = $server->run($transport); - + // Convert PSR-7 response back to Symfony return $httpFoundationFactory->createResponse($psrResponse); } @@ -219,17 +236,14 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; class McpController { public function handle(ServerRequestInterface $request, Server $server): ResponseInterface { - $psr17Factory = new Psr17Factory(); - // Create the MCP HTTP transport - $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - + $transport = new StreamableHttpTransport($request); + // Process MCP request and return PSR-7 response // Laravel automatically handles PSR-7 responses return $server->run($transport); @@ -248,8 +262,6 @@ Create a route handler using Slim's built-in factories and container: ```php use Slim\Factory\AppFactory; -use Slim\Psr7\Factory\ResponseFactory; -use Slim\Psr7\Factory\StreamFactory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; @@ -260,12 +272,9 @@ $app->any('/mcp', function ($request, $response) { ->setServerInfo('My MCP Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) ->build(); - - $responseFactory = new ResponseFactory(); - $streamFactory = new StreamFactory(); - - $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); - + + $transport = new StreamableHttpTransport($request); + return $server->run($transport); }); ``` @@ -330,6 +339,3 @@ npx @modelcontextprotocol/inspector http://localhost:8000 The choice between STDIO and HTTP transport depends on the client you want to integrate with. If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. - -One additiona difference to consider is that STDIO is process-based (one session per process) while HTTP is -request-based (multiple sessions via headers). diff --git a/examples/http-combined-registration/server.php b/examples/http-combined-registration/server.php index f5d98623..2c3184a6 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/http-combined-registration/server.php @@ -13,18 +13,15 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Example\HttpCombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') @@ -40,7 +37,7 @@ ) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$transport = new StreamableHttpTransport($request); $response = $server->run($transport); diff --git a/examples/http-complex-tool-schema/server.php b/examples/http-complex-tool-schema/server.php index 261ad242..b6d29378 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/http-complex-tool-schema/server.php @@ -13,17 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Event Scheduler Server', '1.0.0') @@ -33,7 +30,7 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$transport = new StreamableHttpTransport($request); $response = $server->run($transport); diff --git a/examples/http-discovery-userprofile/server.php b/examples/http-discovery-userprofile/server.php index 36c478e3..c958bfb9 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/http-discovery-userprofile/server.php @@ -13,17 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('HTTP User Profiles', '1.0.0') @@ -75,7 +72,7 @@ function (): array { ) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$transport = new StreamableHttpTransport($request); $response = $server->run($transport); diff --git a/examples/http-schema-showcase/server.php b/examples/http-schema-showcase/server.php index cc8c4e4c..6b38a3c2 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/http-schema-showcase/server.php @@ -13,17 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Schema Showcase', '1.0.0') @@ -33,7 +30,7 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); +$transport = new StreamableHttpTransport($request); $response = $server->run($transport); diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index f4952952..f4d06b2d 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Transport; +use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Schema\JsonRpc\Error; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -27,6 +28,9 @@ */ class StreamableHttpTransport implements TransportInterface { + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + /** @var callable(string, ?Uuid): void */ private $messageListener; @@ -49,12 +53,15 @@ class StreamableHttpTransport implements TransportInterface public function __construct( private readonly ServerRequestInterface $request, - private readonly ResponseFactoryInterface $responseFactory, - private readonly StreamFactoryInterface $streamFactory, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, private readonly LoggerInterface $logger = new NullLogger(), ) { $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } public function initialize(): void From f1b471a219ab63fe6786531fd996c6b4ef175dcb Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 26 Oct 2025 22:48:17 +0100 Subject: [PATCH 41/66] [Server] Add Bidirectional Client Communication Support (#109) * feat(server): add bidirectional client communication support * fix(server): use correct session ID string conversion method * fix: clean up docblocks for request handler methods in Builder * fix: merge artifacts --- composer.json | 1 + .../CallToolRequestHandler.php | 73 ++++ .../ListToolsRequestHandler.php | 46 +++ examples/custom-method-handlers/server.php | 81 +--- examples/http-client-communication/server.php | 124 ++++++ .../stdio-client-communication/server.php | 115 ++++++ src/Capability/Discovery/SchemaGenerator.php | 12 +- src/Capability/Registry/ReferenceHandler.php | 12 + src/Schema/JsonRpc/Request.php | 35 +- src/Schema/JsonRpc/Response.php | 15 +- .../Result/CreateSamplingMessageResult.php | 46 +++ src/Server.php | 4 +- src/Server/Builder.php | 6 +- src/Server/ClientGateway.php | 183 +++++++++ .../Handler/Request/CallToolHandler.php | 7 + .../Request/CompletionCompleteHandler.php | 5 + .../Handler/Request/GetPromptHandler.php | 11 +- .../Handler/Request/InitializeHandler.php | 5 + .../Handler/Request/ListPromptsHandler.php | 4 + .../Request/ListResourceTemplatesHandler.php | 4 + .../Handler/Request/ListResourcesHandler.php | 4 + .../Handler/Request/ListToolsHandler.php | 4 + src/Server/Handler/Request/PingHandler.php | 5 + .../Handler/Request/ReadResourceHandler.php | 12 +- .../Request/RequestHandlerInterface.php | 5 + src/Server/Protocol.php | 360 +++++++++++++++--- src/Server/Transport/BaseTransport.php | 136 +++++++ src/Server/Transport/CallbackStream.php | 156 ++++++++ src/Server/Transport/InMemoryTransport.php | 31 +- .../Transport/ManagesTransportCallbacks.php | 82 ++++ src/Server/Transport/StdioTransport.php | 149 +++++--- .../Transport/StreamableHttpTransport.php | 234 +++++++----- src/Server/Transport/TransportInterface.php | 92 ++++- tests/Unit/JsonRpc/MessageFactoryTest.php | 4 +- .../Handler/Request/CallToolHandlerTest.php | 39 +- .../Handler/Request/GetPromptHandlerTest.php | 26 +- .../Request/ReadResourceHandlerTest.php | 18 +- tests/Unit/Server/ProtocolTest.php | 272 +++++++++---- 38 files changed, 1973 insertions(+), 445 deletions(-) create mode 100644 examples/custom-method-handlers/CallToolRequestHandler.php create mode 100644 examples/custom-method-handlers/ListToolsRequestHandler.php create mode 100644 examples/http-client-communication/server.php create mode 100644 examples/stdio-client-communication/server.php create mode 100644 src/Server/ClientGateway.php create mode 100644 src/Server/Transport/BaseTransport.php create mode 100644 src/Server/Transport/CallbackStream.php create mode 100644 src/Server/Transport/ManagesTransportCallbacks.php diff --git a/composer.json b/composer.json index ea472ccb..dfb304e7 100644 --- a/composer.json +++ b/composer.json @@ -61,6 +61,7 @@ "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", + "Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/", "Mcp\\Tests\\": "tests/" } }, diff --git a/examples/custom-method-handlers/CallToolRequestHandler.php b/examples/custom-method-handlers/CallToolRequestHandler.php new file mode 100644 index 00000000..22d95b39 --- /dev/null +++ b/examples/custom-method-handlers/CallToolRequestHandler.php @@ -0,0 +1,73 @@ + */ +class CallToolRequestHandler implements RequestHandlerInterface +{ + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest; + } + + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof CallToolRequest); + + $name = $request->name; + $args = $request->arguments ?? []; + + if (!isset($this->toolDefinitions[$name])) { + return new Error($request->getId(), Error::METHOD_NOT_FOUND, \sprintf('Tool not found: %s', $name)); + } + + try { + switch ($name) { + case 'say_hello': + $greetName = (string) ($args['name'] ?? 'world'); + $result = [new TextContent(\sprintf('Hello, %s!', $greetName))]; + break; + case 'sum': + $a = (float) ($args['a'] ?? 0); + $b = (float) ($args['b'] ?? 0); + $result = [new TextContent((string) ($a + $b))]; + break; + default: + $result = [new TextContent('Unknown tool')]; + } + + return new Response($request->getId(), new CallToolResult($result)); + } catch (\Throwable $e) { + return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); + } + } +} diff --git a/examples/custom-method-handlers/ListToolsRequestHandler.php b/examples/custom-method-handlers/ListToolsRequestHandler.php new file mode 100644 index 00000000..498f3a89 --- /dev/null +++ b/examples/custom-method-handlers/ListToolsRequestHandler.php @@ -0,0 +1,46 @@ + */ +class ListToolsRequestHandler implements RequestHandlerInterface +{ + /** + * @param array $toolDefinitions + */ + public function __construct(private array $toolDefinitions) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ListToolsRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListToolsRequest); + + return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); + } +} diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index 9ff0a822..7554a0a7 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -13,19 +13,11 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\Request; -use Mcp\Schema\JsonRpc\Response; -use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Request\ListToolsRequest; -use Mcp\Schema\Result\CallToolResult; -use Mcp\Schema\Result\ListToolsResult; +use Mcp\Example\CustomMethodHandlers\CallToolRequestHandler; +use Mcp\Example\CustomMethodHandlers\ListToolsRequestHandler; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Session\SessionInterface; use Mcp\Server\Transport\StdioTransport; logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...'); @@ -58,73 +50,8 @@ ), ]; -$listToolsHandler = new class($toolDefinitions) implements RequestHandlerInterface { - /** - * @param array $toolDefinitions - */ - public function __construct(private array $toolDefinitions) - { - } - - public function supports(Request $request): bool - { - return $request instanceof ListToolsRequest; - } - - public function handle(Request $request, SessionInterface $session): Response - { - assert($request instanceof ListToolsRequest); - - return new Response($request->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); - } -}; - -$callToolHandler = new class($toolDefinitions) implements RequestHandlerInterface { - /** - * @param array $toolDefinitions - */ - public function __construct(private array $toolDefinitions) - { - } - - public function supports(Request $request): bool - { - return $request instanceof CallToolRequest; - } - - public function handle(Request $request, SessionInterface $session): Response|Error - { - assert($request instanceof CallToolRequest); - - $name = $request->name; - $args = $request->arguments ?? []; - - if (!isset($this->toolDefinitions[$name])) { - return new Error($request->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); - } - - try { - switch ($name) { - case 'say_hello': - $greetName = (string) ($args['name'] ?? 'world'); - $result = [new TextContent(sprintf('Hello, %s!', $greetName))]; - break; - case 'sum': - $a = (float) ($args['a'] ?? 0); - $b = (float) ($args['b'] ?? 0); - $result = [new TextContent((string) ($a + $b))]; - break; - default: - $result = [new TextContent('Unknown tool')]; - } - - return new Response($request->getId(), new CallToolResult($result)); - } catch (Throwable $e) { - return new Response($request->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); - } - } -}; - +$listToolsHandler = new ListToolsRequestHandler($toolDefinitions); +$callToolHandler = new CallToolRequestHandler($toolDefinitions); $capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false); $server = Server::builder() diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php new file mode 100644 index 00000000..dd38cfd4 --- /dev/null +++ b/examples/http-client-communication/server.php @@ -0,0 +1,124 @@ +fromGlobals(); + +$sessionDir = __DIR__.'/sessions'; +$capabilities = new ServerCapabilities(logging: true, tools: true); + +$server = Server::builder() + ->setServerInfo('HTTP Client Communication Demo', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setSession(new FileSessionStore($sessionDir)) + ->setCapabilities($capabilities) + ->addTool( + function (string $projectName, array $milestones, ClientGateway $client): array { + $client->log(LoggingLevel::Info, sprintf('Preparing project briefing for "%s"', $projectName)); + + $totalSteps = max(1, count($milestones)); + + foreach ($milestones as $index => $milestone) { + $progress = ($index + 1) / $totalSteps; + $message = sprintf('Analyzing milestone "%s"', $milestone); + + $client->progress(progress: $progress, total: 1, message: $message); + + usleep(150_000); // Simulate work being done + } + + $prompt = sprintf( + 'Draft a concise stakeholder briefing for the project "%s". Highlight key milestones: %s. Focus on risks and next steps.', + $projectName, + implode(', ', $milestones) + ); + + $response = $client->sample( + prompt: $prompt, + maxTokens: 400, + timeout: 90, + options: ['temperature' => 0.4] + ); + + if ($response instanceof JsonRpcError) { + throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); + } + + $result = $response->result; + $content = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; + + $client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.'); + + return [ + 'project' => $projectName, + 'milestones_reviewed' => $milestones, + 'briefing' => $content, + 'model' => $result->model, + 'stop_reason' => $result->stopReason, + ]; + }, + name: 'prepare_project_briefing', + description: 'Compile a stakeholder briefing with live logging, progress updates, and LLM sampling.' + ) + ->addTool( + function (string $serviceName, ClientGateway $client): array { + $client->log(LoggingLevel::Info, sprintf('Starting maintenance checks for "%s"', $serviceName)); + + $steps = [ + 'Verifying health metrics', + 'Checking recent deployments', + 'Reviewing alert stream', + 'Summarizing findings', + ]; + + foreach ($steps as $index => $step) { + $progress = ($index + 1) / count($steps); + + $client->progress(progress: $progress, total: 1, message: $step); + + usleep(120_000); // Simulate work being done + } + + $client->log(LoggingLevel::Info, sprintf('Maintenance checks complete for "%s"', $serviceName)); + + return [ + 'service' => $serviceName, + 'status' => 'operational', + 'notes' => 'No critical issues detected during automated sweep.', + ]; + }, + name: 'run_service_maintenance', + description: 'Simulate service maintenance with logging and progress notifications.' + ) + ->build(); + +$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory, logger()); + +$response = $server->run($transport); + +(new SapiEmitter())->emit($response); diff --git a/examples/stdio-client-communication/server.php b/examples/stdio-client-communication/server.php new file mode 100644 index 00000000..96d2f052 --- /dev/null +++ b/examples/stdio-client-communication/server.php @@ -0,0 +1,115 @@ +#!/usr/bin/env php +setServerInfo('STDIO Client Communication Demo', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setCapabilities($capabilities) + ->addTool( + function (string $incidentTitle, ClientGateway $client): array { + $client->log(LoggingLevel::Warning, sprintf('Incident triage started: %s', $incidentTitle)); + + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; + + foreach ($steps as $index => $step) { + $progress = ($index + 1) / count($steps); + + $client->progress(progress: $progress, total: 1, message: $step); + + usleep(180_000); // Simulate work being done + } + + $prompt = sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + $sampling = $client->sample( + prompt: $prompt, + maxTokens: 350, + timeout: 90, + options: ['temperature' => 0.5] + ); + + if ($sampling instanceof JsonRpcError) { + throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $sampling->code, $sampling->message)); + } + + $result = $sampling->result; + $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; + + $client->log(LoggingLevel::Info, sprintf('Incident triage completed for %s', $incidentTitle)); + + return [ + 'incident' => $incidentTitle, + 'recommended_actions' => $recommendation, + 'model' => $result->model, + ]; + }, + name: 'coordinate_incident_response', + description: 'Coordinate an incident response with logging, progress, and sampling.' + ) + ->addTool( + function (string $dataset, ClientGateway $client): array { + $client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset)); + + $tasks = [ + 'Validating schema', + 'Scanning for anomalies', + 'Reviewing statistical summary', + ]; + + foreach ($tasks as $index => $task) { + $progress = ($index + 1) / count($tasks); + + $client->progress(progress: $progress, total: 1, message: $task); + + usleep(140_000); // Simulate work being done + } + + $client->log(LoggingLevel::Info, sprintf('Dataset "%s" passed automated checks.', $dataset)); + + return [ + 'dataset' => $dataset, + 'status' => 'passed', + 'notes' => 'No significant integrity issues detected during automated checks.', + ]; + }, + name: 'run_dataset_quality_checks', + description: 'Perform dataset quality checks with progress updates and logging.' + ) + ->build(); + +$transport = new StdioTransport(); + +$status = $server->run($transport); + +exit($status); diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 936a35cf..2557f559 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -12,6 +12,7 @@ namespace Mcp\Capability\Discovery; use Mcp\Capability\Attribute\Schema; +use Mcp\Server\ClientGateway; use phpDocumentor\Reflection\DocBlock\Tags\Param; /** @@ -409,10 +410,19 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl $parametersInfo = []; foreach ($reflection->getParameters() as $rp) { + $reflectionType = $rp->getType(); + + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + $typeName = $reflectionType->getName(); + + if (is_a($typeName, ClientGateway::class, true)) { + continue; + } + } + $paramName = $rp->getName(); $paramTag = $paramTags['$'.$paramName] ?? null; - $reflectionType = $rp->getType(); $typeString = $this->getParameterTypeString($rp, $paramTag); $description = $this->docBlockParser->getParamDescription($paramTag); $hasDefault = $rp->isDefaultValueAvailable(); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index b0333788..e3eb925f 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientGateway; use Psr\Container\ContainerInterface; /** @@ -89,6 +90,17 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); + // Check if parameter is a special injectable type + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + + if (ClientGateway::class === $typeName && isset($arguments['_session'])) { + $finalArgs[$paramPosition] = new ClientGateway($arguments['_session']); + continue; + } + } + if (isset($arguments[$paramName])) { $argument = $arguments[$paramName]; try { diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index 22083c75..cb8ed836 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -59,7 +59,13 @@ public static function fromArray(array $data): static $request->id = $data['id']; if (isset($data['params']['_meta'])) { - $request->meta = $data['params']['_meta']; + $meta = $data['params']['_meta']; + if ($meta instanceof \stdClass) { + $meta = (array) $meta; + } + if (\is_array($meta)) { + $request->meta = $meta; + } } return $request; @@ -75,6 +81,33 @@ public function getId(): string|int return $this->id; } + /** + * @return array|null + */ + public function getMeta(): ?array + { + return $this->meta; + } + + public function withId(string|int $id): static + { + $clone = clone $this; + $clone->id = $id; + + return $clone; + } + + /** + * @param array|null $meta + */ + public function withMeta(?array $meta): static + { + $clone = clone $this; + $clone->meta = $meta; + + return $clone; + } + /** * @return RequestData */ diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index f1521265..7f2d82ba 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -14,23 +14,26 @@ use Mcp\Exception\InvalidArgumentException; /** - * @author Kyrian Obikwelu + * @template TResult * * @phpstan-type ResponseData array{ * jsonrpc: string, * id: string|int, * result: array, * } + * + * @author Kyrian Obikwelu */ class Response implements MessageInterface { /** - * @param string|int $id this MUST be the same as the value of the id member in the Request Object - * @param ResultInterface|array $result the value of this member is determined by the method invoked on the Server + * @param string|int $id this MUST be the same as the value of the id member in the Request Object + * @param TResult $result the value of this member is determined by the method invoked on the Server */ public function __construct( public readonly string|int $id, - public readonly ResultInterface|array $result, + /** @var TResult */ + public readonly mixed $result, ) { } @@ -41,6 +44,8 @@ public function getId(): string|int /** * @param ResponseData $data + * + * @return self> */ public static function fromArray(array $data): self { @@ -67,7 +72,7 @@ public static function fromArray(array $data): self * @return array{ * jsonrpc: string, * id: string|int, - * result: ResultInterface, + * result: mixed, * } */ public function jsonSerialize(): array diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index b9e87c1a..986d6291 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Result; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\AudioContent; use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\TextContent; @@ -40,6 +41,51 @@ public function __construct( ) { } + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + if (!isset($data['role']) || !\is_string($data['role'])) { + throw new InvalidArgumentException('Missing or invalid "role" in CreateSamplingMessageResult data.'); + } + + if (!isset($data['content']) || !\is_array($data['content'])) { + throw new InvalidArgumentException('Missing or invalid "content" in CreateSamplingMessageResult data.'); + } + + if (!isset($data['model']) || !\is_string($data['model'])) { + throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.'); + } + + $role = Role::from($data['role']); + $contentPayload = $data['content']; + + $content = self::hydrateContent($contentPayload); + $stopReason = isset($data['stopReason']) && \is_string($data['stopReason']) ? $data['stopReason'] : null; + + return new self($role, $content, $data['model'], $stopReason); + } + + /** + * @param array $contentData + */ + private static function hydrateContent(array $contentData): TextContent|ImageContent|AudioContent + { + $type = $contentData['type'] ?? null; + + if (!\is_string($type)) { + throw new InvalidArgumentException('Missing or invalid "type" in sampling content payload.'); + } + + return match ($type) { + 'text' => TextContent::fromArray($contentData), + 'image' => ImageContent::fromArray($contentData), + 'audio' => AudioContent::fromArray($contentData), + default => throw new InvalidArgumentException(\sprintf('Unsupported sampling content type "%s".', $type)), + }; + } + /** * @return array{ * role: string, diff --git a/src/Server.php b/src/Server.php index 1eb24e8c..8657610a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -43,12 +43,12 @@ public static function builder(): Builder */ public function run(TransportInterface $transport): mixed { - $this->logger->info('Running server...'); - $transport->initialize(); $this->protocol->connect($transport); + $this->logger->info('Running server...'); + try { return $transport->listen(); } finally { diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 886e61a8..f309a3d4 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -63,7 +63,7 @@ final class Builder private ?string $instructions = null; /** - * @var array + * @var array> */ private array $requestHandlers = []; @@ -176,6 +176,8 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self /** * Register a single custom method handler. + * + * @param RequestHandlerInterface $handler */ public function addRequestHandler(RequestHandlerInterface $handler): self { @@ -187,7 +189,7 @@ public function addRequestHandler(RequestHandlerInterface $handler): self /** * Register multiple custom method handlers. * - * @param iterable $handlers + * @param iterable> $handlers */ public function addRequestHandlers(iterable $handlers): self { diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php new file mode 100644 index 00000000..7e95fe9a --- /dev/null +++ b/src/Server/ClientGateway.php @@ -0,0 +1,183 @@ +notify(new ProgressNotification("Starting analysis...")); + * + * // Request LLM sampling from client + * $response = $client->request(new SamplingRequest($text)); + * + * return $response->content->text; + * } + * ``` + * + * @author Kyrian Obikwelu + */ +final class ClientGateway +{ + public function __construct( + private readonly SessionInterface $session, + ) { + } + + /** + * Send a notification to the client (fire and forget). + * + * This suspends the Fiber to let the transport flush the notification via SSE, + * then immediately resumes execution. + */ + public function notify(Notification $notification): void + { + \Fiber::suspend([ + 'type' => 'notification', + 'notification' => $notification, + 'session_id' => $this->session->getId()->toRfc4122(), + ]); + } + + /** + * Convenience method to send a logging notification to the client. + */ + public function log(LoggingLevel $level, mixed $data, ?string $logger = null): void + { + $this->notify(new LoggingMessageNotification($level, $data, $logger)); + } + + /** + * Convenience method to send a progress notification to the client. + */ + public function progress(float $progress, ?float $total = null, ?string $message = null): void + { + $meta = $this->session->get(Protocol::SESSION_ACTIVE_REQUEST_META, []); + $progressToken = $meta['progressToken'] ?? null; + + if (null === $progressToken) { + // Per the spec the client never asked for progress, so just bail. + return; + } + + $this->notify(new ProgressNotification($progressToken, $progress, $total, $message)); + } + + /** + * Send a request to the client and wait for a response (blocking). + * + * This suspends the Fiber and waits for the client to respond. The transport + * handles polling the session for the response and resuming the Fiber when ready. + * + * @param Request $request The request to send + * @param int $timeout Maximum time to wait for response (seconds) + * + * @return Response>|Error The client's response message + * + * @throws \RuntimeException If Fiber support is not available + */ + public function request(Request $request, int $timeout = 120): Response|Error + { + $response = \Fiber::suspend([ + 'type' => 'request', + 'request' => $request, + 'session_id' => $this->session->getId()->toRfc4122(), + 'timeout' => $timeout, + ]); + + if (!$response instanceof Response && !$response instanceof Error) { + throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); + } + + return $response; + } + + /** + * Create and send an LLM sampling requests. + * + * @param CreateSamplingMessageRequest $request The request to send + * @param int $timeout The timeout in seconds + * + * @return Response|Error The sampling response + */ + public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error + { + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + return $response; + } + + $result = CreateSamplingMessageResult::fromArray($response->result); + + return new Response($response->getId(), $result); + } + + /** + * Convenience method for LLM sampling requests. + * + * @param string $prompt The prompt for the LLM + * @param int $maxTokens Maximum tokens to generate + * @param int $timeout The timeout in seconds + * @param array $options Additional sampling options (temperature, etc.) + * + * @return Response|Error The sampling response + */ + public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error + { + $preferences = $options['preferences'] ?? null; + if (\is_array($preferences)) { + $preferences = ModelPreferences::fromArray($preferences); + } + + if (null !== $preferences && !$preferences instanceof ModelPreferences) { + throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + } + + $samplingRequest = new CreateSamplingMessageRequest( + messages: [ + new SamplingMessage(Role::User, new TextContent(text: $prompt)), + ], + maxTokens: $maxTokens, + preferences: $preferences, + systemPrompt: $options['systemPrompt'] ?? null, + includeContext: $options['includeContext'] ?? null, + temperature: $options['temperature'] ?? null, + stopSequences: $options['stopSequences'] ?? null, + metadata: $options['metadata'] ?? null, + ); + + return $this->createMessage($samplingRequest, $timeout); + } +} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index c1f10b9d..79413908 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -26,6 +26,8 @@ use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel * @author Tobias Nyholm */ @@ -43,6 +45,9 @@ public function supports(Request $request): bool return $request instanceof CallToolRequest; } + /** + * @return Response|Error + */ public function handle(Request $request, SessionInterface $session): Response|Error { \assert($request instanceof CallToolRequest); @@ -58,6 +63,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er throw new ToolNotFoundException($request); } + $arguments['_session'] = $session; + $result = $this->referenceHandler->handle($reference, $arguments); if (!$result instanceof CallToolResult) { diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index 6787e48b..c3d9f844 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -24,6 +24,8 @@ /** * Handles completion/complete requests. * + * @implements RequestHandlerInterface + * * @author Kyrian Obikwelu */ final class CompletionCompleteHandler implements RequestHandlerInterface @@ -39,6 +41,9 @@ public function supports(Request $request): bool return $request instanceof CompletionCompleteRequest; } + /** + * @return Response|Error + */ public function handle(Request $request, SessionInterface $session): Response|Error { \assert($request instanceof CompletionCompleteRequest); diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index cf321981..28e5e909 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -26,6 +26,8 @@ use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ final class GetPromptHandler implements RequestHandlerInterface @@ -42,6 +44,9 @@ public function supports(Request $request): bool return $request instanceof GetPromptRequest; } + /** + * @return Response|Error + */ public function handle(Request $request, SessionInterface $session): Response|Error { \assert($request instanceof GetPromptRequest); @@ -55,6 +60,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er throw new PromptNotFoundException($request); } + $arguments['_session'] = $session; + $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result); @@ -67,11 +74,11 @@ public function handle(Request $request, SessionInterface $session): Response|Er } catch (PromptGetException|ExceptionInterface $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $request->getId()); + return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId()); } catch (\Throwable $e) { $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - return Error::forInternalError('Error while handling prompt', $request->getId()); + return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId()); } } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 28bf109f..32eae194 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -21,6 +21,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ final class InitializeHandler implements RequestHandlerInterface @@ -35,6 +37,9 @@ public function supports(Request $request): bool return $request instanceof InitializeRequest; } + /** + * @return Response + */ public function handle(Request $request, SessionInterface $session): Response { \assert($request instanceof InitializeRequest); diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php index 2db8a7ab..aa75fef0 100644 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -20,6 +20,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ final class ListPromptsHandler implements RequestHandlerInterface @@ -36,6 +38,8 @@ public function supports(Request $request): bool } /** + * @return Response + * * @throws InvalidCursorException */ public function handle(Request $request, SessionInterface $session): Response diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php index 76b48bb0..ce77b62a 100644 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -20,6 +20,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ final class ListResourceTemplatesHandler implements RequestHandlerInterface @@ -36,6 +38,8 @@ public function supports(Request $request): bool } /** + * @return Response + * * @throws InvalidCursorException */ public function handle(Request $request, SessionInterface $session): Response diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php index 7e4a4ce7..4dc5ceb2 100644 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -20,6 +20,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ final class ListResourcesHandler implements RequestHandlerInterface @@ -36,6 +38,8 @@ public function supports(Request $request): bool } /** + * @return Response + * * @throws InvalidCursorException */ public function handle(Request $request, SessionInterface $session): Response diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php index 81854a62..7c7f7788 100644 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -20,6 +20,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel * @author Tobias Nyholm */ @@ -37,6 +39,8 @@ public function supports(Request $request): bool } /** + * @return Response + * * @throws InvalidCursorException When the cursor is invalid */ public function handle(Request $request, SessionInterface $session): Response diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php index 378926c1..507680fa 100644 --- a/src/Server/Handler/Request/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -18,6 +18,8 @@ use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ final class PingHandler implements RequestHandlerInterface @@ -27,6 +29,9 @@ public function supports(Request $request): bool return $request instanceof PingRequest; } + /** + * @return Response + */ public function handle(Request $request, SessionInterface $session): Response { \assert($request instanceof PingRequest); diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 17d2781c..83f0d654 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -25,6 +25,8 @@ use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ final class ReadResourceHandler implements RequestHandlerInterface @@ -41,6 +43,9 @@ public function supports(Request $request): bool return $request instanceof ReadResourceRequest; } + /** + * @return Response|Error + */ public function handle(Request $request, SessionInterface $session): Response|Error { \assert($request instanceof ReadResourceRequest); @@ -55,7 +60,12 @@ public function handle(Request $request, SessionInterface $session): Response|Er throw new ResourceNotFoundException($request); } - $result = $this->referenceHandler->handle($reference, ['uri' => $uri]); + $arguments = [ + 'uri' => $uri, + '_session' => $session, + ]; + + $result = $this->referenceHandler->handle($reference, $arguments); if ($reference instanceof ResourceTemplateReference) { $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php index d89b2c1f..d81c0795 100644 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -17,11 +17,16 @@ use Mcp\Server\Session\SessionInterface; /** + * @template TResult + * * @author Kyrian Obikwelu */ interface RequestHandlerInterface { public function supports(Request $request): bool; + /** + * @return Response|Error + */ public function handle(Request $request, SessionInterface $session): Response|Error; } diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 11ec6a87..c3b42f58 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -17,6 +17,7 @@ use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; @@ -31,17 +32,35 @@ /** * @final * + * @phpstan-import-type McpFiber from \Mcp\Server\Transport\TransportInterface + * @phpstan-import-type FiberSuspend from \Mcp\Server\Transport\TransportInterface + * * @author Christopher Hertel * @author Kyrian Obikwelu */ class Protocol { + /** Session key for request ID counter */ + private const SESSION_REQUEST_ID_COUNTER = '_mcp.request_id_counter'; + + /** Session key for pending outgoing requests */ + private const SESSION_PENDING_REQUESTS = '_mcp.pending_requests'; + + /** Session key for incoming client responses */ + private const SESSION_RESPONSES = '_mcp.responses'; + + /** Session key for outgoing message queue */ + private const SESSION_OUTGOING_QUEUE = '_mcp.outgoing_queue'; + + /** Session key for active request meta */ + public const SESSION_ACTIVE_REQUEST_META = '_mcp.active_request_meta'; + /** @var TransportInterface|null */ private ?TransportInterface $transport = null; /** - * @param array $requestHandlers - * @param array $notificationHandlers + * @param array>> $requestHandlers + * @param array $notificationHandlers */ public function __construct( private readonly array $requestHandlers, @@ -53,6 +72,14 @@ public function __construct( ) { } + /** + * @return TransportInterface + */ + public function getTransport(): TransportInterface + { + return $this->transport; + } + /** * Connect this protocol to a transport. * @@ -72,6 +99,14 @@ public function connect(TransportInterface $transport): void $this->transport->onSessionEnd([$this, 'destroySession']); + $this->transport->setOutgoingMessagesProvider([$this, 'consumeOutgoingMessages']); + + $this->transport->setPendingRequestsProvider([$this, 'getPendingRequests']); + + $this->transport->setResponseFinder([$this, 'checkResponse']); + + $this->transport->setFiberYieldHandler([$this, 'handleFiberYield']); + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } @@ -91,7 +126,7 @@ public function processInput(string $input, ?Uuid $sessionId): void } catch (\JsonException $e) { $this->logger->warning('Failed to decode json message.', ['exception' => $e]); $error = Error::forParseError($e->getMessage()); - $this->sendResponse($error, ['session_id' => $sessionId]); + $this->sendResponse($error, null); return; } @@ -121,13 +156,15 @@ private function handleInvalidMessage(InvalidInputMessageException $exception, S $this->logger->warning('Failed to create message.', ['exception' => $exception]); $error = Error::forInvalidRequest($exception->getMessage()); - $this->sendResponse($error, ['session_id' => $session->getId()]); + $this->sendResponse($error, $session); } private function handleRequest(Request $request, SessionInterface $session): void { $this->logger->info('Handling request.', ['request' => $request]); + $session->set(self::SESSION_ACTIVE_REQUEST_META, $request->getMeta()); + $handlerFound = false; foreach ($this->requestHandlers as $handler) { @@ -138,16 +175,41 @@ private function handleRequest(Request $request, SessionInterface $session): voi $handlerFound = true; try { - $response = $handler->handle($request, $session); - $this->sendResponse($response, ['session_id' => $session->getId()]); + /** @var McpFiber $fiber */ + $fiber = new \Fiber(fn () => $handler->handle($request, $session)); + + $result = $fiber->start(); + + if ($fiber->isSuspended()) { + if (\is_array($result) && isset($result['type'])) { + if ('notification' === $result['type']) { + $notification = $result['notification']; + $this->sendNotification($notification, $session); + } elseif ('request' === $result['type']) { + $request = $result['request']; + $timeout = $result['timeout'] ?? 120; + $this->sendRequest($request, $timeout, $session); + } + } + + $this->transport->attachFiberToSession($fiber, $session->getId()); + + return; + } else { + $finalResult = $fiber->getReturn(); + + $this->sendResponse($finalResult, $session); + } } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInvalidParams($e->getMessage(), $request->getId()); - $this->sendResponse($error, ['session_id' => $session->getId()]); + $this->sendResponse($error, $session); } catch (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); + $error = Error::forInternalError($e->getMessage(), $request->getId()); - $this->sendResponse($error, ['session_id' => $session->getId()]); + $this->sendResponse($error, $session); } break; @@ -155,14 +217,25 @@ private function handleRequest(Request $request, SessionInterface $session): voi if (!$handlerFound) { $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); - $this->sendResponse($error, ['session_id' => $session->getId()]); + $this->sendResponse($error, $session); } } + /** + * @param Response>|Error $response + */ private function handleResponse(Response|Error $response, SessionInterface $session): void { - $this->logger->info('Handling response.', ['response' => $response]); - // TODO: Implement response handling + $this->logger->info('Handling response from client.', ['response' => $response]); + + $messageId = $response->getId(); + + $session->set(self::SESSION_RESPONSES.".{$messageId}", $response->jsonSerialize()); + $session->forget(self::SESSION_ACTIVE_REQUEST_META); + + $this->logger->info('Client response stored in session', [ + 'message_id' => $messageId, + ]); } private function handleNotification(Notification $notification, SessionInterface $session): void @@ -183,56 +256,250 @@ private function handleNotification(Notification $notification, SessionInterface } /** - * @param array $context + * Sends a request to the client and returns the request ID. */ - public function sendRequest(Request $request, array $context = []): void + public function sendRequest(Request $request, int $timeout, SessionInterface $session): int { - $this->logger->info('Sending request.', ['request' => $request, 'context' => $context]); - // TODO: Implement request sending + $counter = $session->get(self::SESSION_REQUEST_ID_COUNTER, 1000); + $requestId = $counter++; + $session->set(self::SESSION_REQUEST_ID_COUNTER, $counter); + + $requestWithId = $request->withId($requestId); + + $this->logger->info('Queueing server request to client', [ + 'request_id' => $requestId, + 'method' => $request::getMethod(), + ]); + + $pending = $session->get(self::SESSION_PENDING_REQUESTS, []); + $pending[$requestId] = [ + 'request_id' => $requestId, + 'timeout' => $timeout, + 'timestamp' => time(), + ]; + $session->set(self::SESSION_PENDING_REQUESTS, $pending); + + $this->queueOutgoing($requestWithId, ['type' => 'request'], $session); + + return $requestId; } /** - * @param array $context + * Queues a notification for later delivery. */ - public function sendResponse(Response|Error $response, array $context = []): void + public function sendNotification(Notification $notification, SessionInterface $session): void { - $this->logger->info('Sending response.', ['response' => $response, 'context' => $context]); + $this->logger->info('Queueing server notification to client', [ + 'method' => $notification::getMethod(), + ]); - $encoded = null; + $this->queueOutgoing($notification, ['type' => 'notification'], $session); + } - try { - if ($response instanceof Response && [] === $response->result) { - $encoded = json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); + /** + * Sends a response either immediately or queued for later delivery. + * + * @param Response>|Error $response + * @param array $context + */ + private function sendResponse(Response|Error $response, ?SessionInterface $session, array $context = []): void + { + if (null === $session) { + $this->logger->info('Sending immediate response', [ + 'response_id' => $response->getId(), + ]); + + try { + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->error('Failed to encode response to JSON.', [ + 'message_id' => $response->getId(), + 'exception' => $e, + ]); + + $fallbackError = new Error( + id: $response->getId(), + code: Error::INTERNAL_ERROR, + message: 'Response could not be encoded to JSON' + ); + + $encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR); } - $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $context['type'] = 'response'; + $this->transport->send($encoded, $context); + } else { + $this->logger->info('Queueing server response', [ + 'response_id' => $response->getId(), + ]); + + $this->queueOutgoing($response, ['type' => 'response'], $session); + } + } + + /** + * Helper to queue outgoing messages in session. + * + * @param Request|Notification|Response>|Error $message + * @param array $context + */ + private function queueOutgoing(Request|Notification|Response|Error $message, array $context, SessionInterface $session): void + { + try { + $encoded = json_encode($message, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), + $this->logger->error('Failed to encode message to JSON.', [ 'exception' => $e, ]); - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); + return; + } + + $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); + $queue[] = [ + 'message' => $encoded, + 'context' => $context, + ]; + $session->set(self::SESSION_OUTGOING_QUEUE, $queue); + } - $encoded = json_encode($fallbackError, \JSON_THROW_ON_ERROR); + /** + * Consume (get and clear) all outgoing messages for a session. + * + * @return array}> + */ + public function consumeOutgoingMessages(Uuid $sessionId): array + { + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); + $session->set(self::SESSION_OUTGOING_QUEUE, []); + $session->save(); + + return $queue; + } + + /** + * Check for a response to a specific request ID. + * + * When a response is found, it is removed from the session, and the + * corresponding pending request is also cleared. + */ + /** + * @return Response>|Error|null + */ + public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null + { + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}"); + + if (null === $responseData) { + return null; } - $context['type'] = 'response'; - $this->transport->send($encoded, $context); + $this->logger->debug('Found and consuming client response.', [ + 'request_id' => $requestId, + 'session_id' => $sessionId->toRfc4122(), + ]); + + $session->set(self::SESSION_RESPONSES.".{$requestId}", null); + $pending = $session->get(self::SESSION_PENDING_REQUESTS, []); + unset($pending[$requestId]); + $session->set(self::SESSION_PENDING_REQUESTS, $pending); + $session->save(); + + try { + if (isset($responseData['error'])) { + return Error::fromArray($responseData); + } + + return Response::fromArray($responseData); + } catch (\Throwable $e) { + $this->logger->error('Failed to reconstruct client response from session.', [ + 'request_id' => $requestId, + 'exception' => $e, + 'response_data' => $responseData, + ]); + + return null; + } } /** - * @param array $context + * Get pending requests for a session. + * + * @return array The pending requests */ - public function sendNotification(Notification $notification, array $context = []): void + public function getPendingRequests(Uuid $sessionId): array { - $this->logger->info('Sending notification.', ['notification' => $notification, 'context' => $context]); - $context['type'] = 'notification'; - // TODO: Implement notification sending + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + + return $session->get(self::SESSION_PENDING_REQUESTS, []); + } + + /** + * Handle values yielded by Fibers during transport-managed resumes. + * + * @param FiberSuspend|null $yieldedValue + */ + public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void + { + if (!$sessionId) { + $this->logger->warning('Fiber yielded value without associated session context.'); + + return; + } + + if (!\is_array($yieldedValue) || !isset($yieldedValue['type'])) { + $this->logger->warning('Fiber yielded unexpected payload.', [ + 'payload' => $yieldedValue, + 'session_id' => $sessionId->toRfc4122(), + ]); + + return; + } + + $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + + $payloadSessionId = $yieldedValue['session_id'] ?? null; + if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) { + $this->logger->warning('Fiber yielded payload with mismatched session ID.', [ + 'payload_session_id' => $payloadSessionId, + 'expected_session_id' => $sessionId->toRfc4122(), + ]); + } + + try { + if ('notification' === $yieldedValue['type']) { + $notification = $yieldedValue['notification'] ?? null; + if (!$notification instanceof Notification) { + $this->logger->warning('Fiber yielded notification without Notification instance.', [ + 'payload' => $yieldedValue, + ]); + + return; + } + + $this->sendNotification($notification, $session); + } elseif ('request' === $yieldedValue['type']) { + $request = $yieldedValue['request'] ?? null; + if (!$request instanceof Request) { + $this->logger->warning('Fiber yielded request without Request instance.', [ + 'payload' => $yieldedValue, + ]); + + return; + } + + $timeout = isset($yieldedValue['timeout']) ? (int) $yieldedValue['timeout'] : 120; + $this->sendRequest($request, $timeout, $session); + } else { + $this->logger->warning('Fiber yielded unknown operation type.', [ + 'type' => $yieldedValue['type'], + ]); + } + } finally { + $session->save(); + } } /** @@ -261,7 +528,7 @@ private function resolveSession(?Uuid $sessionId, array $messages): ?SessionInte // Spec: An initialize request must not be part of a batch. if (\count($messages) > 1) { $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); - $this->sendResponse($error, ['session_id' => $sessionId]); + $this->sendResponse($error, null); return null; } @@ -269,24 +536,31 @@ private function resolveSession(?Uuid $sessionId, array $messages): ?SessionInte // Spec: An initialize request must not have a session ID. if ($sessionId) { $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); - $this->sendResponse($error); + $this->sendResponse($error, null); return null; } - return $this->sessionFactory->create($this->sessionStore); + $session = $this->sessionFactory->create($this->sessionStore); + $this->logger->debug('Created new session for initialize', [ + 'session_id' => $session->getId()->toRfc4122(), + ]); + + $this->transport->setSessionId($session->getId()); + + return $session; } if (!$sessionId) { $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); - $this->sendResponse($error, ['status_code' => 400]); + $this->sendResponse($error, null, ['status_code' => 400]); return null; } if (!$this->sessionStore->exists($sessionId)) { $error = Error::forInvalidRequest('Session not found or has expired.'); - $this->sendResponse($error, ['status_code' => 404]); + $this->sendResponse($error, null, ['status_code' => 404]); return null; } diff --git a/src/Server/Transport/BaseTransport.php b/src/Server/Transport/BaseTransport.php new file mode 100644 index 00000000..c2938b52 --- /dev/null +++ b/src/Server/Transport/BaseTransport.php @@ -0,0 +1,136 @@ + + */ +abstract class BaseTransport +{ + use ManagesTransportCallbacks; + + protected ?Uuid $sessionId = null; + + /** + * @var McpFiber|null + */ + protected ?\Fiber $sessionFiber = null; + + public function __construct( + protected readonly LoggerInterface $logger, + ) { + } + + public function initialize(): void + { + } + + public function close(): void + { + } + + public function setSessionId(?Uuid $sessionId): void + { + $this->sessionId = $sessionId; + } + + /** + * @param McpFiber $fiber + */ + public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void + { + $this->sessionFiber = $fiber; + $this->sessionId = $sessionId; + } + + /** + * @return array}> + */ + protected function getOutgoingMessages(?Uuid $sessionId): array + { + if ($sessionId && \is_callable($this->outgoingMessagesProvider)) { + return ($this->outgoingMessagesProvider)($sessionId); + } + + return []; + } + + /** + * @return array> + */ + protected function getPendingRequests(?Uuid $sessionId): array + { + if ($sessionId && \is_callable($this->pendingRequestsProvider)) { + return ($this->pendingRequestsProvider)($sessionId); + } + + return []; + } + + /** + * @phpstan-return FiberResume + */ + protected function checkForResponse(int $requestId, ?Uuid $sessionId): Response|Error|null + { + if ($sessionId && \is_callable($this->responseFinder)) { + return ($this->responseFinder)($requestId, $sessionId); + } + + return null; + } + + /** + * @param FiberSuspend|null $yielded + */ + protected function handleFiberYield(mixed $yielded, ?Uuid $sessionId): void + { + if (null === $yielded || !\is_callable($this->fiberYieldHandler)) { + return; + } + + try { + ($this->fiberYieldHandler)($yielded, $sessionId); + } catch (\Throwable $e) { + $this->logger->error('Fiber yield handler failed.', [ + 'exception' => $e, + 'sessionId' => $sessionId?->toRfc4122(), + ]); + } + } + + protected function handleMessage(string $payload, ?Uuid $sessionId): void + { + if (\is_callable($this->messageListener)) { + ($this->messageListener)($payload, $sessionId); + } + } + + protected function handleSessionEnd(?Uuid $sessionId): void + { + if ($sessionId && \is_callable($this->sessionEndListener)) { + ($this->sessionEndListener)($sessionId); + } + } +} diff --git a/src/Server/Transport/CallbackStream.php b/src/Server/Transport/CallbackStream.php new file mode 100644 index 00000000..85525232 --- /dev/null +++ b/src/Server/Transport/CallbackStream.php @@ -0,0 +1,156 @@ + + */ +final class CallbackStream implements StreamInterface +{ + private bool $called = false; + + private ?\Throwable $exception = null; + + /** + * @param callable(): void $callback The callback to execute when stream is read + */ + public function __construct(private $callback, private LoggerInterface $logger = new NullLogger()) + { + } + + public function __toString(): string + { + try { + $this->invoke(); + } catch (\Throwable $e) { + $this->exception = $e; + $this->logger->error( + \sprintf('CallbackStream execution failed: %s', $e->getMessage()), + ['exception' => $e] + ); + } + + return ''; + } + + public function read($length): string + { + $this->invoke(); + + if (null !== $this->exception) { + throw $this->exception; + } + + return ''; + } + + public function getContents(): string + { + $this->invoke(); + + if (null !== $this->exception) { + throw $this->exception; + } + + return ''; + } + + public function eof(): bool + { + return $this->called; + } + + public function close(): void + { + // No-op - callback-based stream doesn't need closing + } + + public function detach() + { + return null; + } + + public function getSize(): ?int + { + return null; // Unknown size for callback streams + } + + public function tell(): int + { + return 0; + } + + public function isSeekable(): bool + { + return false; + } + + public function seek($offset, $whence = \SEEK_SET): void + { + throw new \RuntimeException('Stream is not seekable'); + } + + public function rewind(): void + { + throw new \RuntimeException('Stream is not seekable'); + } + + public function isWritable(): bool + { + return false; + } + + public function write($string): int + { + throw new \RuntimeException('Stream is not writable'); + } + + public function isReadable(): bool + { + return !$this->called; + } + + private function invoke(): void + { + if ($this->called) { + return; + } + + $this->called = true; + $this->exception = null; + ($this->callback)(); + } + + public function getMetadata($key = null) + { + return null === $key ? [] : null; + } +} diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index a1bd2946..a9ffca97 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -18,15 +18,9 @@ * * @author Tobias Nyholm */ -class InMemoryTransport implements TransportInterface +class InMemoryTransport extends BaseTransport implements TransportInterface { - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionDestroyListener; - - private ?Uuid $sessionId = null; + use ManagesTransportCallbacks; /** * @param list $messages @@ -58,29 +52,24 @@ public function send(string $data, array $context): void public function listen(): mixed { foreach ($this->messages as $message) { - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $message, $this->sessionId); - } + $this->handleMessage($message, $this->sessionId); } - if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { - \call_user_func($this->sessionDestroyListener, $this->sessionId); - $this->sessionId = null; - } + $this->handleSessionEnd($this->sessionId); + + $this->sessionId = null; return null; } - public function onSessionEnd(callable $listener): void + public function setSessionId(?Uuid $sessionId): void { - $this->sessionDestroyListener = $listener; + $this->sessionId = $sessionId; } public function close(): void { - if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { - \call_user_func($this->sessionDestroyListener, $this->sessionId); - $this->sessionId = null; - } + $this->handleSessionEnd($this->sessionId); + $this->sessionId = null; } } diff --git a/src/Server/Transport/ManagesTransportCallbacks.php b/src/Server/Transport/ManagesTransportCallbacks.php new file mode 100644 index 00000000..a0d1aa6b --- /dev/null +++ b/src/Server/Transport/ManagesTransportCallbacks.php @@ -0,0 +1,82 @@ + + * */ +trait ManagesTransportCallbacks +{ + /** @var callable(string, ?Uuid): void */ + protected $messageListener; + + /** @var callable(Uuid): void */ + protected $sessionEndListener; + + /** @var callable(Uuid): array}> */ + protected $outgoingMessagesProvider; + + /** @var callable(Uuid): array> */ + protected $pendingRequestsProvider; + + /** @var callable(int, Uuid): Response>|Error|null */ + protected $responseFinder; + + /** @var callable(FiberSuspend|null, ?Uuid): void */ + protected $fiberYieldHandler; + + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function onSessionEnd(callable $listener): void + { + $this->sessionEndListener = $listener; + } + + public function setOutgoingMessagesProvider(callable $provider): void + { + $this->outgoingMessagesProvider = $provider; + } + + public function setPendingRequestsProvider(callable $provider): void + { + $this->pendingRequestsProvider = $provider; + } + + /** + * @param callable(int, Uuid):(Response>|Error|null) $finder + */ + public function setResponseFinder(callable $finder): void + { + $this->responseFinder = $finder; + } + + /** + * @param callable(FiberSuspend|null, ?Uuid): void $handler + */ + public function setFiberYieldHandler(callable $handler): void + { + $this->fiberYieldHandler = $handler; + } +} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index be69dd04..5f0afbfb 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,25 +11,17 @@ namespace Mcp\Server\Transport; +use Mcp\Schema\JsonRpc\Error; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Uid\Uuid; /** * @implements TransportInterface * * @author Kyrian Obikwelu - */ -class StdioTransport implements TransportInterface + * */ +class StdioTransport extends BaseTransport implements TransportInterface { - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionEndListener; - - private ?Uuid $sessionId = null; - /** * @param resource $input * @param resource $output @@ -37,80 +29,137 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly LoggerInterface $logger = new NullLogger(), + LoggerInterface $logger = new NullLogger(), ) { + parent::__construct($logger); } - public function initialize(): void + public function send(string $data, array $context): void { + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + + $this->writeLine($data); } - public function onMessage(callable $listener): void + public function listen(): int { - $this->messageListener = $listener; + $this->logger->info('StdioTransport is listening for messages on STDIN...'); + stream_set_blocking($this->input, false); + + while (!feof($this->input)) { + $this->processInput(); + $this->processFiber(); + $this->flushOutgoingMessages(); + } + + $this->logger->info('StdioTransport finished listening.'); + $this->handleSessionEnd($this->sessionId); + + return 0; } - public function send(string $data, array $context): void + protected function processInput(): void { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + $line = fgets($this->input); + if (false === $line) { + usleep(50000); // 50ms - if (isset($context['session_id'])) { - $this->sessionId = $context['session_id']; + return; } - fwrite($this->output, $data.\PHP_EOL); + $trimmedLine = trim($line); + if (!empty($trimmedLine)) { + $this->handleMessage($trimmedLine, $this->sessionId); + } } - public function listen(): int + private function processFiber(): void { - $this->logger->info('StdioTransport is listening for messages on STDIN...'); + if (null === $this->sessionFiber) { + return; + } - $status = 0; - while (!feof($this->input)) { - $line = fgets($this->input); - if (false === $line) { - if (!feof($this->input)) { - $status = 1; - } + if ($this->sessionFiber->isTerminated()) { + $this->handleFiberTermination(); - break; - } + return; + } - $trimmedLine = trim($line); - if (!empty($trimmedLine)) { - $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $trimmedLine, $this->sessionId); - } - } + if (!$this->sessionFiber->isSuspended()) { + return; } - $this->logger->info('StdioTransport finished listening.'); + $pendingRequests = $this->getPendingRequests($this->sessionId); - if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { - \call_user_func($this->sessionEndListener, $this->sessionId); - $this->sessionId = null; + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); + + return; } - return $status; + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout'] ?? 120; + + $response = $this->checkForResponse($requestId, $this->sessionId); + + if (null !== $response) { + $yielded = $this->sessionFiber->resume($response); + $this->handleFiberYield($yielded, $this->sessionId); + + return; + } + + if (time() - $timestamp >= $timeout) { + $error = Error::forInternalError('Request timed out', $requestId); + $yielded = $this->sessionFiber->resume($error); + $this->handleFiberYield($yielded, $this->sessionId); + + return; + } + } } - public function onSessionEnd(callable $listener): void + private function handleFiberTermination(): void { - $this->sessionEndListener = $listener; + $finalResult = $this->sessionFiber->getReturn(); + + if (null !== $finalResult) { + try { + $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); + $this->writeLine($encoded); + } catch (\JsonException $e) { + $this->logger->error('STDIO: Failed to encode final Fiber result.', ['exception' => $e]); + } + } + + $this->sessionFiber = null; } - public function close(): void + private function flushOutgoingMessages(): void { - if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { - \call_user_func($this->sessionEndListener, $this->sessionId); - $this->sessionId = null; + $messages = $this->getOutgoingMessages($this->sessionId); + + foreach ($messages as $message) { + $this->writeLine($message['message']); } + } + private function writeLine(string $payload): void + { + fwrite($this->output, $payload.\PHP_EOL); + } + + public function close(): void + { + $this->handleSessionEnd($this->sessionId); if (\is_resource($this->input)) { fclose($this->input); } - if (\is_resource($this->output)) { fclose($this->output); } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index f4d06b2d..8ff918bc 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -25,24 +25,14 @@ * @implements TransportInterface * * @author Kyrian Obikwelu - */ -class StreamableHttpTransport implements TransportInterface + * */ +class StreamableHttpTransport extends BaseTransport implements TransportInterface { private ResponseFactoryInterface $responseFactory; private StreamFactoryInterface $streamFactory; - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionEndListener; - - private ?Uuid $sessionId = null; - - /** @var string[] */ - private array $outgoingMessages = []; - private ?Uuid $outgoingSessionId = null; - private ?int $outgoingStatusCode = null; + private ?string $immediateResponse = null; + private ?int $immediateStatusCode = null; /** @var array */ private array $corsHeaders = [ @@ -55,8 +45,9 @@ public function __construct( private readonly ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, - private readonly LoggerInterface $logger = new NullLogger(), + LoggerInterface $logger = new NullLogger(), ) { + parent::__construct($logger); $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; @@ -70,44 +61,20 @@ public function initialize(): void public function send(string $data, array $context): void { - $this->outgoingMessages[] = $data; - - if (isset($context['session_id'])) { - $this->outgoingSessionId = $context['session_id']; - } - - if (isset($context['status_code']) && \is_int($context['status_code'])) { - $this->outgoingStatusCode = $context['status_code']; - } - - $this->logger->debug('Sending data to client via StreamableHttpTransport.', [ - 'data' => $data, - 'session_id' => $this->outgoingSessionId?->toRfc4122(), - 'status_code' => $this->outgoingStatusCode, - ]); + $this->immediateResponse = $data; + $this->immediateStatusCode = $context['status_code'] ?? 200; } public function listen(): ResponseInterface { return match ($this->request->getMethod()) { 'OPTIONS' => $this->handleOptionsRequest(), - 'GET' => $this->handleGetRequest(), 'POST' => $this->handlePostRequest(), 'DELETE' => $this->handleDeleteRequest(), - default => $this->handleUnsupportedRequest(), + default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), }; } - public function onMessage(callable $listener): void - { - $this->messageListener = $listener; - } - - public function onSessionEnd(callable $listener): void - { - $this->sessionEndListener = $listener; - } - protected function handleOptionsRequest(): ResponseInterface { return $this->withCorsHeaders($this->responseFactory->createResponse(204)); @@ -115,89 +82,163 @@ protected function handleOptionsRequest(): ResponseInterface protected function handlePostRequest(): ResponseInterface { - $acceptHeader = $this->request->getHeaderLine('Accept'); - if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { - $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); - $this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]); + $body = $this->request->getBody()->getContents(); + $this->handleMessage($body, $this->sessionId); - return $this->createErrorResponse($error, 406); + if (null !== $this->immediateResponse) { + $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($this->immediateResponse)); + + return $this->withCorsHeaders($response); } - if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { - $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); - $this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]); + if (null !== $this->sessionFiber) { + $this->logger->info('Fiber suspended, handling via SSE.'); - return $this->createErrorResponse($error, 415); + return $this->createStreamedResponse(); } - $body = $this->request->getBody()->getContents(); - if (empty($body)) { - $error = Error::forInvalidRequest('Bad Request: Empty request body.'); - $this->logger->warning('Client sent empty request body.'); + return $this->createJsonResponse(); + } - return $this->createErrorResponse($error, 400); + protected function handleDeleteRequest(): ResponseInterface + { + if (!$this->sessionId) { + return $this->createErrorResponse(Error::forInvalidRequest('Mcp-Session-Id header is required.'), 400); } - $this->logger->debug('Received message on StreamableHttpTransport.', [ - 'body' => $body, - 'session_id' => $this->sessionId?->toRfc4122(), - ]); + $this->handleSessionEnd($this->sessionId); - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $body, $this->sessionId); - } + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + protected function createJsonResponse(): ResponseInterface + { + $outgoingMessages = $this->getOutgoingMessages($this->sessionId); - if (empty($this->outgoingMessages)) { + if (empty($outgoingMessages)) { return $this->withCorsHeaders($this->responseFactory->createResponse(202)); } - $responseBody = 1 === \count($this->outgoingMessages) - ? $this->outgoingMessages[0] - : '['.implode(',', $this->outgoingMessages).']'; + $messages = array_column($outgoingMessages, 'message'); + $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; - $status = $this->outgoingStatusCode ?? 200; - - $response = $this->responseFactory->createResponse($status) + $response = $this->responseFactory->createResponse(200) ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($responseBody)); - if ($this->outgoingSessionId) { - $response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122()); + if ($this->sessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); } return $this->withCorsHeaders($response); } - protected function handleGetRequest(): ResponseInterface + protected function createStreamedResponse(): ResponseInterface { - $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); + $callback = function (): void { + try { + $this->logger->info('SSE: Starting request processing loop'); + + while ($this->sessionFiber->isSuspended()) { + $this->flushOutgoingMessages($this->sessionId); + + $pendingRequests = $this->getPendingRequests($this->sessionId); + + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); + continue; + } + + $resumed = false; + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout'] ?? 120; + + $response = $this->checkForResponse($requestId, $this->sessionId); + + if (null !== $response) { + $yielded = $this->sessionFiber->resume($response); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + + if (time() - $timestamp >= $timeout) { + $error = Error::forInternalError('Request timed out', $requestId); + $yielded = $this->sessionFiber->resume($error); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + } + + if (!$resumed) { + usleep(100000); + } // Prevent tight loop + } + + $this->handleFiberTermination(); + } finally { + $this->sessionFiber = null; + } + }; + + $stream = new CallbackStream($callback, $this->logger); + $response = $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody($stream); + + if ($this->sessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); + } return $this->withCorsHeaders($response); } - protected function handleDeleteRequest(): ResponseInterface + private function handleFiberTermination(): void { - if (!$this->sessionId) { - $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); - $this->logger->warning('DELETE request received without session ID.'); - - return $this->createErrorResponse($error, 400); - } - - if (\is_callable($this->sessionEndListener)) { - \call_user_func($this->sessionEndListener, $this->sessionId); + $finalResult = $this->sessionFiber->getReturn(); + + if (null !== $finalResult) { + try { + $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); + echo "event: message\n"; + echo "data: {$encoded}\n\n"; + @ob_flush(); + flush(); + } catch (\JsonException $e) { + $this->logger->error('SSE: Failed to encode final Fiber result.', ['exception' => $e]); + } } - return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + $this->sessionFiber = null; } - protected function handleUnsupportedRequest(): ResponseInterface + private function flushOutgoingMessages(?Uuid $sessionId): void { - $this->logger->warning('Unsupported HTTP method received.', [ - 'method' => $this->request->getMethod(), - ]); + $messages = $this->getOutgoingMessages($sessionId); + + foreach ($messages as $message) { + echo "event: message\n"; + echo "data: {$message['message']}\n\n"; + @ob_flush(); + flush(); + } + } - $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface + { + $payload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); + $response = $this->responseFactory->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); return $this->withCorsHeaders($response); } @@ -210,17 +251,4 @@ protected function withCorsHeaders(ResponseInterface $response): ResponseInterfa return $response; } - - protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface - { - $errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); - - return $this->responseFactory->createResponse($statusCode) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($errorPayload)); - } - - public function close(): void - { - } } diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index a082d070..400c453e 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -11,11 +11,21 @@ namespace Mcp\Server\Transport; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Symfony\Component\Uid\Uuid; /** * @template TResult * + * @phpstan-type FiberReturn (Response|Error) + * @phpstan-type FiberResume (FiberReturn|null) + * @phpstan-type FiberSuspend ( + * array{type: 'notification', notification: \Mcp\Schema\JsonRpc\Notification}| + * array{type: 'request', request: \Mcp\Schema\JsonRpc\Request, timeout?: int} + * ) + * @phpstan-type McpFiber \Fiber + * * @author Christopher Hertel * @author Kyrian Obikwelu */ @@ -26,15 +36,6 @@ interface TransportInterface */ public function initialize(): void; - /** - * Register callback for ALL incoming messages. - * - * The transport calls this whenever ANY message arrives, regardless of source. - * - * @param callable(string $message, ?Uuid $sessionId): void $listener - */ - public function onMessage(callable $listener): void; - /** * Starts the transport's execution process. * @@ -47,32 +48,85 @@ public function onMessage(callable $listener): void; public function listen(): mixed; /** - * Send a message to the client. + * Send a message to the client immediately (bypassing session queue). * - * The transport decides HOW to send based on context + * Used for session resolution errors when no session is available. + * The transport decides HOW to send based on context. * * @param array $context Context about this message: * - 'session_id': Uuid|null * - 'type': 'response'|'request'|'notification' - * - 'in_reply_to': int|string|null (ID of request this responds to) - * - 'expects_response': bool (if this is a request needing response) + * - 'status_code': int (HTTP status code for errors) */ public function send(string $data, array $context): void; /** - * Register callback for session termination. + * Closes the transport and cleans up any resources. + */ + public function close(): void; + + /** + * Register callback for ALL incoming messages. + * + * The transport calls this whenever ANY message arrives, regardless of source. + * + * @param callable(string $message, ?Uuid $sessionId): void $listener + */ + public function onMessage(callable $listener): void; + + /** + * Register a listener for when a session is terminated. * - * This can happen when a client disconnects or explicitly ends their session. + * The transport calls this when a client disconnects or explicitly ends their session. * * @param callable(Uuid $sessionId): void $listener The callback function to execute when destroying a session */ public function onSessionEnd(callable $listener): void; /** - * Closes the transport and cleans up any resources. + * Set a provider function to retrieve all queued outgoing messages. + * + * The transport calls this to retrieve all queued messages for a session. * - * This method should be called when the transport is no longer needed. - * It should clean up any resources and close any connections. + * @param callable(Uuid $sessionId): array}> $provider */ - public function close(): void; + public function setOutgoingMessagesProvider(callable $provider): void; + + /** + * Set a provider function to retrieve all pending server-initiated requests. + * + * The transport calls this to decide if it should wait for a client response before resuming a Fiber. + * + * @param callable(Uuid $sessionId): array> $provider + */ + public function setPendingRequestsProvider(callable $provider): void; + + /** + * Set a finder function to check for a specific client response. + * + * @param callable(int, Uuid):FiberResume $finder + */ + public function setResponseFinder(callable $finder): void; + + /** + * Set a handler for processing values yielded from a suspended Fiber. + * + * The transport calls this to let the Protocol handle new requests/notifications + * that are yielded from a Fiber's execution. + * + * @param callable(FiberSuspend|null, ?Uuid $sessionId): void $handler + */ + public function setFiberYieldHandler(callable $handler): void; + + /** + * @param McpFiber $fiber + */ + public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void; + + /** + * Set the session ID for the current transport context. + * + * @param Uuid|null $sessionId The session ID, or null to clear + */ + public function setSessionId(?Uuid $sessionId): void; } diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 7f591e57..d38aabeb 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -98,7 +98,7 @@ public function testCreateResponseWithIntegerId(): void $results = $this->factory->create($json); $this->assertCount(1, $results); - /** @var Response $result */ + /** @var Response> $result */ $result = $results[0]; $this->assertInstanceOf(Response::class, $result); $this->assertSame(456, $result->getId()); @@ -113,7 +113,7 @@ public function testCreateResponseWithStringId(): void $results = $this->factory->create($json); $this->assertCount(1, $results); - /** @var Response $result */ + /** @var Response> $result */ $result = $results[0]; $this->assertInstanceOf(Response::class, $result); $this->assertSame('response-1', $result->getId()); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 11b799bf..359afa1b 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -30,10 +30,10 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private LoggerInterface|MockObject $logger; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private LoggerInterface&MockObject $logger; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -71,7 +71,7 @@ public function testHandleSuccessfulToolCall(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['name' => 'John']) + ->with($toolReference, ['name' => 'John', '_session' => $this->session]) ->willReturn('Hello, John!'); $toolReference @@ -80,9 +80,7 @@ public function testHandleSuccessfulToolCall(): void ->with('Hello, John!') ->willReturn([new TextContent('Hello, John!')]); - $this->logger - ->expects($this->never()) - ->method('error'); + // Logger may be called for debugging, so we don't assert never() $response = $this->handler->handle($request, $this->session); @@ -106,7 +104,7 @@ public function testHandleToolCallWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->with($toolReference, ['_session' => $this->session]) ->willReturn('Simple result'); $toolReference @@ -143,7 +141,7 @@ public function testHandleToolCallWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, $arguments) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn('Complex result'); $toolReference @@ -194,7 +192,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['param' => 'value']) + ->with($toolReference, ['param' => 'value', '_session' => $this->session]) ->willThrowException($exception); $this->logger @@ -223,7 +221,7 @@ public function testHandleWithNullResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->with($toolReference, ['_session' => $this->session]) ->willReturn(null); $toolReference @@ -260,7 +258,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['key1' => 'value1', 'key2' => 42]) + ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session]) ->willThrowException($exception); $this->logger @@ -270,11 +268,14 @@ public function testHandleLogsErrorWithCorrectParameters(): void 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', [ 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42], + 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], ], ); - $this->handler->handle($request, $this->session); + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); } public function testHandleWithSpecialCharactersInToolName(): void @@ -292,7 +293,7 @@ public function testHandleWithSpecialCharactersInToolName(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->with($toolReference, ['_session' => $this->session]) ->willReturn('Special tool result'); $toolReference @@ -327,7 +328,7 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, $arguments) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn('Unicode handled'); $toolReference @@ -357,7 +358,7 @@ public function testHandleReturnsStructuredContentResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['query' => 'php']) + ->with($toolReference, ['query' => 'php', '_session' => $this->session]) ->willReturn($structuredResult); $toolReference @@ -386,7 +387,7 @@ public function testHandleReturnsCallToolResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['query' => 'php']) + ->with($toolReference, ['query' => 'php', '_session' => $this->session]) ->willReturn($callToolResult); $toolReference diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 3f5171b1..b7f5d259 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -31,9 +31,9 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -70,7 +70,7 @@ public function testHandleSuccessfulPromptGet(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -112,7 +112,7 @@ public function testHandlePromptGetWithArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -145,7 +145,7 @@ public function testHandlePromptGetWithNullArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -178,7 +178,7 @@ public function testHandlePromptGetWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -213,7 +213,7 @@ public function testHandlePromptGetWithMultipleMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -263,7 +263,7 @@ public function testHandlePromptGetExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while handling prompt', $response->message); + $this->assertEquals('Error while handling prompt: Handling prompt "failing_prompt" failed with error: "Failed to get prompt".', $response->message); } public function testHandlePromptGetWithComplexArguments(): void @@ -299,7 +299,7 @@ public function testHandlePromptGetWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -337,7 +337,7 @@ public function testHandlePromptGetWithSpecialCharacters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -367,7 +367,7 @@ public function testHandlePromptGetReturnsEmptyMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn([]); $promptReference @@ -405,7 +405,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 440005d4..2c54110d 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -31,9 +31,9 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -75,7 +75,7 @@ public function testHandleSuccessfulResourceRead(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('test'); $resourceReference @@ -115,7 +115,7 @@ public function testHandleResourceReadWithBlobContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('fake-image-data'); $resourceReference @@ -159,7 +159,7 @@ public function testHandleResourceReadWithMultipleContents(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('binary-data'); $resourceReference @@ -250,7 +250,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('test'); $resourceReference @@ -295,7 +295,7 @@ public function testHandleResourceReadWithEmptyContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn(''); $resourceReference @@ -357,7 +357,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn($expectedContent); $resourceReference diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 859c4271..fa949c38 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -98,16 +98,26 @@ public function testRequestHandledByFirstMatchingHandler(): void $this->sessionStore->method('exists')->willReturn(true); $session->method('getId')->willReturn(Uuid::v4()); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } + + return $default; + }); - return isset($decoded['result']); - }), - $this->anything() - ); + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [$handlerA, $handlerB, $handlerC], @@ -124,6 +134,13 @@ public function testRequestHandledByFirstMatchingHandler(): void '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', $sessionId ); + + // Check that the response was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('result', $message); } #[TestDox('Initialize request must not have a session ID')] @@ -297,17 +314,26 @@ public function testInvalidMessageStructureReturnsError(): void $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } - return isset($decoded['error']) - && Error::INVALID_REQUEST === $decoded['error']['code']; - }), - $this->anything() - ); + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [], @@ -324,6 +350,14 @@ public function testInvalidMessageStructureReturnsError(): void '{"jsonrpc": "2.0", "params": {}}', $sessionId ); + + // Check that the error was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); } #[TestDox('Request without handler returns method not found error')] @@ -334,18 +368,26 @@ public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } - return isset($decoded['error']) - && Error::METHOD_NOT_FOUND === $decoded['error']['code'] - && str_contains($decoded['error']['message'], 'No handler found'); - }), - $this->anything() - ); + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [], @@ -362,6 +404,15 @@ public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void '{"jsonrpc": "2.0", "id": 1, "method": "ping"}', $sessionId ); + + // Check that the error was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::METHOD_NOT_FOUND, $message['error']['code']); + $this->assertStringContainsString('No handler found', $message['error']['message']); } #[TestDox('Handler throwing InvalidArgumentException returns invalid params error')] @@ -376,18 +427,26 @@ public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } - return isset($decoded['error']) - && Error::INVALID_PARAMS === $decoded['error']['code'] - && str_contains($decoded['error']['message'], 'Invalid parameter'); - }), - $this->anything() - ); + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [$handler], @@ -404,6 +463,15 @@ public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', $sessionId ); + + // Check that the error was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INVALID_PARAMS, $message['error']['code']); + $this->assertStringContainsString('Invalid parameter', $message['error']['message']); } #[TestDox('Handler throwing unexpected exception returns internal error')] @@ -418,18 +486,26 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } - return isset($decoded['error']) - && Error::INTERNAL_ERROR === $decoded['error']['code'] - && str_contains($decoded['error']['message'], 'Unexpected error'); - }), - $this->anything() - ); + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [$handler], @@ -446,6 +522,15 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "test"}}', $sessionId ); + + // Check that the error was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('error', $message); + $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); + $this->assertStringContainsString('Unexpected error', $message['error']['message']); } #[TestDox('Notification handler exceptions are caught and logged')] @@ -493,18 +578,26 @@ public function testSuccessfulRequestReturnsResponseWithSessionId(): void $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - $this->transport->expects($this->once()) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } - return isset($decoded['result']); - }), - $this->callback(function ($context) use ($sessionId) { - return isset($context['session_id']) && $context['session_id']->equals($sessionId); - }) - ); + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + // The protocol now queues responses instead of sending them directly + // save() is called once during processInput and once during consumeOutgoingMessages + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [$handler], @@ -520,6 +613,14 @@ public function testSuccessfulRequestReturnsResponseWithSessionId(): void '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', $sessionId ); + + // Check that the response was queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(1, $outgoing); + + $message = json_decode($outgoing[0]['message'], true); + $this->assertArrayHasKey('result', $message); + $this->assertEquals(['status' => 'ok'], $message['result']); } #[TestDox('Batch requests are processed and send multiple responses')] @@ -528,26 +629,38 @@ public function testBatchRequestsAreProcessed(): void $handlerA = $this->createMock(RequestHandlerInterface::class); $handlerA->method('supports')->willReturn(true); $handlerA->method('handle')->willReturnCallback(function ($request) { - return new Response($request->getId(), ['method' => $request::getMethod()]); + return Response::fromArray([ + 'jsonrpc' => '2.0', + 'id' => $request->getId(), + 'result' => ['method' => $request::getMethod()], + ]); }); $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn(Uuid::v4()); + // Configure session mock for queue operations + $queue = []; + $session->method('get')->willReturnCallback(function ($key, $default = null) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + return $queue; + } + + return $default; + }); + + $session->method('set')->willReturnCallback(function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + $this->sessionFactory->method('createWithId')->willReturn($session); $this->sessionStore->method('exists')->willReturn(true); - // Expect two calls to send() - $this->transport->expects($this->exactly(2)) - ->method('send') - ->with( - $this->callback(function ($data) { - $decoded = json_decode($data, true); - - return isset($decoded['result']); - }), - $this->anything() - ); + // The protocol now queues responses instead of sending them directly + $session->expects($this->exactly(2)) + ->method('save'); $protocol = new Protocol( requestHandlers: [$handlerA], @@ -564,6 +677,15 @@ public function testBatchRequestsAreProcessed(): void '[{"jsonrpc": "2.0", "method": "tools/list", "id": 1}, {"jsonrpc": "2.0", "method": "prompts/list", "id": 2}]', $sessionId ); + + // Check that both responses were queued in the session + $outgoing = $protocol->consumeOutgoingMessages($sessionId); + $this->assertCount(2, $outgoing); + + foreach ($outgoing as $outgoingMessage) { + $message = json_decode($outgoingMessage['message'], true); + $this->assertArrayHasKey('result', $message); + } } #[TestDox('Session is saved after processing')] From 42b5261612b910dff94ecaf121c75f5262de1df1 Mon Sep 17 00:00:00 2001 From: OK Xaas Date: Mon, 27 Oct 2025 06:25:09 +0800 Subject: [PATCH 42/66] fix: because the cached discoveryState does not use ->setDiscoveryState(), it is found that the discoveryState cannot be used (#120) Co-authored-by: X2NX --- src/Capability/Discovery/Discoverer.php | 13 +- .../Registry/Loader/DiscoveryLoader.php | 6 +- .../Discovery/CachedDiscovererTest.php | 10 +- .../Capability/Discovery/DiscoveryTest.php | 182 ++++++++---------- 4 files changed, 90 insertions(+), 121 deletions(-) diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 1520a8e1..e6cc328f 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -20,7 +20,6 @@ use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; @@ -48,7 +47,6 @@ class Discoverer { public function __construct( - private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger = new NullLogger(), private ?DocBlockParser $docBlockParser = null, private ?SchemaGenerator $schemaGenerator = null, @@ -95,10 +93,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'base_path' => $basePath, ]); - $emptyState = new DiscoveryState(); - $this->registry->setDiscoveryState($emptyState); - - return $emptyState; + return new DiscoveryState(); } $finder->files() @@ -125,11 +120,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'resourceTemplates' => $discoveredCount['resourceTemplates'], ]); - $discoveryState = new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); - - $this->registry->setDiscoveryState($discoveryState); - - return $discoveryState; + return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); } /** diff --git a/src/Capability/Registry/Loader/DiscoveryLoader.php b/src/Capability/Registry/Loader/DiscoveryLoader.php index 2fadaadf..33991368 100644 --- a/src/Capability/Registry/Loader/DiscoveryLoader.php +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -38,12 +38,14 @@ public function __construct( public function load(ReferenceRegistryInterface $registry): void { // This now encapsulates the discovery process - $discoverer = new Discoverer($registry, $this->logger); + $discoverer = new Discoverer($this->logger); $cachedDiscoverer = $this->cache ? new CachedDiscoverer($discoverer, $this->cache, $this->logger) : $discoverer; - $cachedDiscoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + $discoveryState = $cachedDiscoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + + $registry->setDiscoveryState($discoveryState); } } diff --git a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php index b7b9dc31..75ffd88c 100644 --- a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php +++ b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php @@ -14,7 +14,6 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscoveryState; -use Mcp\Capability\Registry; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; @@ -23,8 +22,7 @@ class CachedDiscovererTest extends TestCase { public function testCachedDiscovererUsesCacheOnSecondCall(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); $cache->expects($this->once()) @@ -47,8 +45,7 @@ public function testCachedDiscovererUsesCacheOnSecondCall(): void public function testCachedDiscovererReturnsCachedResults(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); $cachedState = new DiscoveryState(); @@ -71,8 +68,7 @@ public function testCachedDiscovererReturnsCachedResults(): void public function testCacheKeyGeneration(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 0767ff12..2208a915 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -14,10 +14,6 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; -use Mcp\Capability\Registry; -use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ResourceReference; -use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; @@ -29,155 +25,139 @@ class DiscoveryTest extends TestCase { - private Registry $registry; private Discoverer $discoverer; protected function setUp(): void { - $this->registry = new Registry(); - $this->discoverer = new Discoverer($this->registry); + $this->discoverer = new Discoverer(); } public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $tools = $this->registry->getTools(); + $tools = $discovery->getTools(); $this->assertCount(4, $tools); - $greetUserTool = $this->registry->getTool('greet_user'); - $this->assertInstanceOf(ToolReference::class, $greetUserTool); - $this->assertFalse($greetUserTool->isManual); - $this->assertEquals('greet_user', $greetUserTool->tool->name); - $this->assertEquals('Greets a user by name.', $greetUserTool->tool->description); - $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $greetUserTool->handler); - $this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); - - $repeatActionTool = $this->registry->getTool('repeatAction'); - $this->assertInstanceOf(ToolReference::class, $repeatActionTool); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); - $this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint); - $this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? [])); - - $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); - $this->assertInstanceOf(ToolReference::class, $invokableCalcTool); - $this->assertFalse($invokableCalcTool->isManual); - $this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler); - - $this->assertNull($this->registry->getTool('private_tool_should_be_ignored')); - $this->assertNull($this->registry->getTool('protected_tool_should_be_ignored')); - $this->assertNull($this->registry->getTool('static_tool_should_be_ignored')); - - $resources = $this->registry->getResources(); + $this->assertArrayHasKey('greet_user', $tools); + $this->assertFalse($tools['greet_user']->isManual); + $this->assertEquals('greet_user', $tools['greet_user']->tool->name); + $this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description); + $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler); + $this->assertArrayHasKey('name', $tools['greet_user']->tool->inputSchema['properties'] ?? []); + + $this->assertArrayHasKey('repeatAction', $tools); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); + $this->assertTrue($tools['repeatAction']->tool->annotations->readOnlyHint); + $this->assertEquals(['count', 'loudly', 'mode'], array_keys($tools['repeatAction']->tool->inputSchema['properties'] ?? [])); + + $this->assertArrayHasKey('InvokableCalculator', $tools); + $this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']); + $this->assertFalse($tools['InvokableCalculator']->isManual); + $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); + + $this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools); + $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); + $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); + + $resources = $discovery->getResources(); $this->assertCount(3, $resources); - $appVersionRes = $this->registry->getResource('app://info/version'); - $this->assertInstanceOf(ResourceReference::class, $appVersionRes); - $this->assertFalse($appVersionRes->isManual); - $this->assertEquals('app_version', $appVersionRes->schema->name); - $this->assertEquals('text/plain', $appVersionRes->schema->mimeType); + $this->assertArrayHasKey('app://info/version', $resources); + $this->assertFalse($resources['app://info/version']->isManual); + $this->assertEquals('app_version', $resources['app://info/version']->schema->name); + $this->assertEquals('text/plain', $resources['app://info/version']->schema->mimeType); - $invokableStatusRes = $this->registry->getResource('invokable://config/status'); - $this->assertInstanceOf(ResourceReference::class, $invokableStatusRes); - $this->assertFalse($invokableStatusRes->isManual); - $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler); + $this->assertArrayHasKey('invokable://config/status', $resources); + $this->assertFalse($resources['invokable://config/status']->isManual); + $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); - $prompts = $this->registry->getPrompts(); + $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); - $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); - $this->assertInstanceOf(PromptReference::class, $storyPrompt); - $this->assertFalse($storyPrompt->isManual); - $this->assertCount(2, $storyPrompt->prompt->arguments); - $this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']); + $this->assertArrayHasKey('creative_story_prompt', $prompts); + $this->assertFalse($prompts['creative_story_prompt']->isManual); + $this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments); + $this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']); - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertInstanceOf(PromptReference::class, $simplePrompt); - $this->assertFalse($simplePrompt->isManual); + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts); + $this->assertFalse($prompts['simpleQuestionPrompt']->isManual); - $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); - $this->assertInstanceOf(PromptReference::class, $invokableGreeter); - $this->assertFalse($invokableGreeter->isManual); - $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler); + $this->assertArrayHasKey('InvokableGreeterPrompt', $prompts); + $this->assertFalse($prompts['InvokableGreeterPrompt']->isManual); + $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); - $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentCreatorPrompt); - $this->assertFalse($contentCreatorPrompt->isManual); - $this->assertCount(3, $contentCreatorPrompt->completionProviders); + $this->assertArrayHasKey('content_creator', $prompts); + $this->assertFalse($prompts['content_creator']->isManual); + $this->assertCount(3, $prompts['content_creator']->completionProviders); - $templates = $this->registry->getResourceTemplates(); + $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); - $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $productTemplate); - $this->assertFalse($productTemplate->isManual); - $this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name); - $this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']); - $this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames()); - - $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $invokableUserTemplate); - $this->assertFalse($invokableUserTemplate->isManual); - $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler); + $this->assertArrayHasKey('product://{region}/details/{productId}', $templates); + $this->assertFalse($templates['product://{region}/details/{productId}']->isManual); + $this->assertEquals('product_details_template', $templates['product://{region}/details/{productId}']->resourceTemplate->name); + $this->assertEquals(CompletionProviderFixture::class, $templates['product://{region}/details/{productId}']->completionProviders['region']); + $this->assertEqualsCanonicalizing(['region', 'productId'], $templates['product://{region}/details/{productId}']->getVariableNames()); + + $this->assertArrayHasKey('invokable://user-profile/{userId}', $templates); + $this->assertFalse($templates['invokable://user-profile/{userId}']->isManual); + $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler); } public function testDoesNotDiscoverElementsFromExcludedDirectories() { - $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('hidden_subdir_tool')); - - $this->registry->clear(); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools()); - $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); - $this->assertNull($this->registry->getTool('hidden_subdir_tool')); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); + $this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools()); } public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() { - $this->discoverer->discover(__DIR__, ['EmptyDir']); - $tools = $this->registry->getTools(); - $this->assertEmpty($tools->references); + $discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']); + + $this->assertTrue($discovery->isEmpty()); } public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $repeatActionTool = $this->registry->getTool('repeatAction'); - $this->assertEquals('repeatAction', $repeatActionTool->tool->name); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); + $this->assertArrayHasKey('repeatAction', $tools = $discovery->getTools()); + $this->assertEquals('repeatAction', $tools['repeatAction']->tool->name); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name); - $this->assertNull($simplePrompt->prompt->description); + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts = $discovery->getPrompts()); + $this->assertEquals('simpleQuestionPrompt', $prompts['simpleQuestionPrompt']->prompt->name); + $this->assertNull($prompts['simpleQuestionPrompt']->prompt->description); - $invokableCalc = $this->registry->getTool('InvokableCalculator'); - $this->assertEquals('InvokableCalculator', $invokableCalc->tool->name); - $this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description); + $this->assertArrayHasKey('InvokableCalculator', $tools); + $this->assertEquals('InvokableCalculator', $tools['InvokableCalculator']->tool->name); + $this->assertEquals('An invokable calculator tool.', $tools['InvokableCalculator']->tool->description); } public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $contentPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentPrompt); - $this->assertCount(3, $contentPrompt->completionProviders); + $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); + $this->assertCount(3, $prompts['content_creator']->completionProviders); - $typeProvider = $contentPrompt->completionProviders['type']; + $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); - $statusProvider = $contentPrompt->completionProviders['status']; + $statusProvider = $prompts['content_creator']->completionProviders['status']; $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); - $priorityProvider = $contentPrompt->completionProviders['priority']; + $priorityProvider = $prompts['content_creator']->completionProviders['priority']; $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); - $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $contentTemplate); - $this->assertCount(1, $contentTemplate->completionProviders); + $this->assertArrayHasKey('content://{category}/{slug}', $templates = $discovery->getResourceTemplates()); + $this->assertCount(1, $templates['content://{category}/{slug}']->completionProviders); - $categoryProvider = $contentTemplate->completionProviders['category']; + $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); } } From 48a19b93a7a610157e336f0b1adad4a028a59535 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 26 Oct 2025 23:58:26 +0100 Subject: [PATCH 43/66] [Server] Make CORS headers configurable in StreamableHttpTransport (#118) * feat: make CORS headers configurable in StreamableHttpTransport * docs: add CORS configuration documentation for HTTP transport * refactor: streamline request creation and transport initialization in HTTP client example --- docs/transports.md | 43 +++++++++++++++++++ examples/http-client-communication/server.php | 12 +++--- .../Transport/StreamableHttpTransport.php | 16 ++++--- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/transports.md b/docs/transports.md index 83129ddf..290fd49c 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -110,6 +110,7 @@ $transport = new StreamableHttpTransport( - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. - **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. +- **`corsHeaders`** (optional): `array` - Custom CORS headers to override defaults. Merges with secure defaults. Defaults to `[]`. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. ### PSR-17 Auto-Discovery @@ -136,6 +137,48 @@ $psr17Factory = new Psr17Factory(); $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); ``` +### CORS Configuration + +The transport sets secure CORS defaults that can be customized or disabled: + +```php +// Default CORS headers (backward compatible) +$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); + +// Restrict to specific origin +$transport = new StreamableHttpTransport( + $request, + $responseFactory, + $streamFactory, + ['Access-Control-Allow-Origin' => 'https://myapp.com'] +); + +// Disable CORS for proxy scenarios +$transport = new StreamableHttpTransport( + $request, + $responseFactory, + $streamFactory, + ['Access-Control-Allow-Origin' => ''] +); + +// Custom headers with logger +$transport = new StreamableHttpTransport( + $request, + $responseFactory, + $streamFactory, + [ + 'Access-Control-Allow-Origin' => 'https://api.example.com', + 'Access-Control-Max-Age' => '86400' + ], + $logger +); +``` + +Default CORS headers: +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS` +- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept` + ### Architecture The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php index dd38cfd4..2acce334 100644 --- a/examples/http-client-communication/server.php +++ b/examples/http-client-communication/server.php @@ -12,6 +12,7 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; @@ -21,19 +22,16 @@ use Mcp\Server\ClientGateway; use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; -$psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -$request = $creator->fromGlobals(); +$request = (new Psr17Factory())->createServerRequestFromGlobals(); $sessionDir = __DIR__.'/sessions'; $capabilities = new ServerCapabilities(logging: true, tools: true); +$logger = logger(); $server = Server::builder() ->setServerInfo('HTTP Client Communication Demo', '1.0.0') - ->setLogger(logger()) + ->setLogger($logger) ->setContainer(container()) ->setSession(new FileSessionStore($sessionDir)) ->setCapabilities($capabilities) @@ -117,7 +115,7 @@ function (string $serviceName, ClientGateway $client): array { ) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory, logger()); +$transport = new StreamableHttpTransport($request, logger: $logger); $response = $server->run($transport); diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 8ff918bc..297cad17 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -35,16 +35,16 @@ class StreamableHttpTransport extends BaseTransport implements TransportInterfac private ?int $immediateStatusCode = null; /** @var array */ - private array $corsHeaders = [ - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', - ]; + private array $corsHeaders; + /** + * @param array $corsHeaders + */ public function __construct( private readonly ServerRequestInterface $request, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null, + array $corsHeaders = [], LoggerInterface $logger = new NullLogger(), ) { parent::__construct($logger); @@ -53,6 +53,12 @@ public function __construct( $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + $this->corsHeaders = array_merge([ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept', + ], $corsHeaders); } public function initialize(): void From 7190bee153d1c4456b47301a82e8cb587b6f7326 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 27 Oct 2025 01:09:25 +0100 Subject: [PATCH 44/66] Add more inspector tests for functionality tests of STDIO examples (#115) --- .../McpTaskHandlers.php | 4 +- .../Service/InMemoryTaskRepository.php | 2 +- .../Service/StatsServiceInterface.php | 2 +- .../Service/SystemStatsService.php | 2 +- .../Service/TaskRepositoryInterface.php | 2 +- examples/stdio-custom-dependencies/server.php | 8 +- tests/Inspector/StdioCachedDiscoveryTest.php | 29 +++++++ .../Inspector/StdioCustomDependenciesTest.php | 29 +++++++ ...t.php => StdioDiscoveryCalculatorTest.php} | 2 +- tests/Inspector/StdioEnvVariablesTest.php | 29 +++++++ ....php => StdioExplicitRegistrationTest.php} | 2 +- ...tdioCachedDiscoveryTest-prompts_list.json} | 1 - ...dioCachedDiscoveryTest-resources_list.json | 3 + ...scoveryTest-resources_templates_list.json} | 0 .../StdioCachedDiscoveryTest-tools_list.json | 76 +++++++++++++++++++ ...ioCustomDependenciesTest-prompts_list.json | 3 + ...CustomDependenciesTest-resources_list.json | 10 +++ ...ndenciesTest-resources_templates_list.json | 3 + ...tdioCustomDependenciesTest-tools_list.json | 57 ++++++++++++++ ...oDiscoveryCalculatorTest-prompts_list.json | 3 + ...scoveryCalculatorTest-resources_list.json} | 1 - ...scoveryCalculatorTest-resources_read.json} | 0 ...lculatorTest-resources_templates_list.json | 3 + ...ioDiscoveryCalculatorTest-tools_call.json} | 0 ...ioDiscoveryCalculatorTest-tools_list.json} | 1 - .../StdioEnvVariablesTest-prompts_list.json | 3 + .../StdioEnvVariablesTest-resources_list.json | 3 + ...ariablesTest-resources_templates_list.json | 3 + .../StdioEnvVariablesTest-tools_list.json | 20 +++++ ...xplicitRegistrationTest-prompts_list.json} | 0 ...licitRegistrationTest-resources_list.json} | 0 ...trationTest-resources_templates_list.json} | 0 ...oExplicitRegistrationTest-tools_list.json} | 0 33 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 tests/Inspector/StdioCachedDiscoveryTest.php create mode 100644 tests/Inspector/StdioCustomDependenciesTest.php rename tests/Inspector/{StdioCalculatorExampleTest.php => StdioDiscoveryCalculatorTest.php} (93%) create mode 100644 tests/Inspector/StdioEnvVariablesTest.php rename tests/Inspector/{ManualStdioExampleTest.php => StdioExplicitRegistrationTest.php} (89%) rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-prompts_list.json => StdioCachedDiscoveryTest-prompts_list.json} (95%) create mode 100644 tests/Inspector/snapshots/StdioCachedDiscoveryTest-resources_list.json rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-resources_templates_list.json => StdioCachedDiscoveryTest-resources_templates_list.json} (100%) create mode 100644 tests/Inspector/snapshots/StdioCachedDiscoveryTest-tools_list.json create mode 100644 tests/Inspector/snapshots/StdioCustomDependenciesTest-prompts_list.json create mode 100644 tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_list.json create mode 100644 tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_templates_list.json create mode 100644 tests/Inspector/snapshots/StdioCustomDependenciesTest-tools_list.json create mode 100644 tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-resources_list.json => StdioDiscoveryCalculatorTest-resources_list.json} (99%) rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-resources_read.json => StdioDiscoveryCalculatorTest-resources_read.json} (100%) create mode 100644 tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-tools_call.json => StdioDiscoveryCalculatorTest-tools_call.json} (100%) rename tests/Inspector/snapshots/{StdioCalculatorExampleTest-tools_list.json => StdioDiscoveryCalculatorTest-tools_list.json} (99%) create mode 100644 tests/Inspector/snapshots/StdioEnvVariablesTest-prompts_list.json create mode 100644 tests/Inspector/snapshots/StdioEnvVariablesTest-resources_list.json create mode 100644 tests/Inspector/snapshots/StdioEnvVariablesTest-resources_templates_list.json create mode 100644 tests/Inspector/snapshots/StdioEnvVariablesTest-tools_list.json rename tests/Inspector/snapshots/{ManualStdioExampleTest-prompts_list.json => StdioExplicitRegistrationTest-prompts_list.json} (100%) rename tests/Inspector/snapshots/{ManualStdioExampleTest-resources_list.json => StdioExplicitRegistrationTest-resources_list.json} (100%) rename tests/Inspector/snapshots/{ManualStdioExampleTest-resources_templates_list.json => StdioExplicitRegistrationTest-resources_templates_list.json} (100%) rename tests/Inspector/snapshots/{ManualStdioExampleTest-tools_list.json => StdioExplicitRegistrationTest-tools_list.json} (100%) diff --git a/examples/stdio-custom-dependencies/McpTaskHandlers.php b/examples/stdio-custom-dependencies/McpTaskHandlers.php index 262d1a86..65d2f003 100644 --- a/examples/stdio-custom-dependencies/McpTaskHandlers.php +++ b/examples/stdio-custom-dependencies/McpTaskHandlers.php @@ -13,8 +13,8 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; -use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; +use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; /** diff --git a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php b/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php index 47526c4c..63ce8611 100644 --- a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php +++ b/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\StdioCustomDependencies\Service; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php b/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php index b8485d2a..a7e8b276 100644 --- a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php +++ b/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\StdioCustomDependencies\Service; interface StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/SystemStatsService.php b/examples/stdio-custom-dependencies/Service/SystemStatsService.php index 075dd8a0..5a766792 100644 --- a/examples/stdio-custom-dependencies/Service/SystemStatsService.php +++ b/examples/stdio-custom-dependencies/Service/SystemStatsService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\StdioCustomDependencies\Service; final class SystemStatsService implements StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php b/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php index 975cc711..6c091be1 100644 --- a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php +++ b/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\StdioCustomDependencies\Service; /** * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} diff --git a/examples/stdio-custom-dependencies/server.php b/examples/stdio-custom-dependencies/server.php index 659f4499..4ebd4cbd 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/stdio-custom-dependencies/server.php @@ -13,10 +13,10 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\DependenciesStdioExample\Service\InMemoryTaskRepository; -use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; -use Mcp\Example\DependenciesStdioExample\Service\SystemStatsService; -use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; +use Mcp\Example\StdioCustomDependencies\Service\InMemoryTaskRepository; +use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\StdioCustomDependencies\Service\SystemStatsService; +use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; diff --git a/tests/Inspector/StdioCachedDiscoveryTest.php b/tests/Inspector/StdioCachedDiscoveryTest.php new file mode 100644 index 00000000..dfc8acec --- /dev/null +++ b/tests/Inspector/StdioCachedDiscoveryTest.php @@ -0,0 +1,29 @@ + Date: Mon, 27 Oct 2025 15:40:26 +0800 Subject: [PATCH 45/66] feat(session): implement PSR-16 cache based session storage (#114) --- README.md | 27 ++++++-- docs/server-builder.md | 22 ++++++- src/Server/Session/Psr16StoreSession.php | 81 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 src/Server/Session/Psr16StoreSession.php diff --git a/README.md b/README.md index 8d4aaa81..394cd37c 100644 --- a/README.md +++ b/README.md @@ -190,23 +190,42 @@ $response = $server->run($transport); By default, the SDK uses in-memory sessions. You can configure different session stores: ```php -use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16StoreSession; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; -// Use default in-memory sessions (TTL only) +// Use default in-memory sessions with custom TTL $server = Server::builder() ->setSession(ttl: 7200) // 2 hours ->build(); -// Use file-based sessions +// Override with file-based storage $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); -// Use in-memory with custom TTL +// Override with in-memory storage and custom TTL $server = Server::builder() ->setSession(new InMemorySessionStore(3600)) ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16StoreSession( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); ``` ### Discovery Caching diff --git a/docs/server-builder.md b/docs/server-builder.md index 03fcaece..ac131374 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -139,6 +139,9 @@ Configure session storage and lifecycle. By default, the SDK uses `InMemorySessi ```php use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16StoreSession; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; // Use default in-memory sessions with custom TTL $server = Server::builder() @@ -147,18 +150,35 @@ $server = Server::builder() // Override with file-based storage $server = Server::builder() - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); // Override with in-memory storage and custom TTL $server = Server::builder() ->setSession(new InMemorySessionStore(3600)) ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16StoreSession( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); ``` **Available Session Stores:** - `InMemorySessionStore`: Fast in-memory storage (default) - `FileSessionStore`: Persistent file-based storage +- `Psr16StoreSession`: PSR-16 compliant cache-based storage **Custom Session Stores:** diff --git a/src/Server/Session/Psr16StoreSession.php b/src/Server/Session/Psr16StoreSession.php new file mode 100644 index 00000000..aacf0ffc --- /dev/null +++ b/src/Server/Session/Psr16StoreSession.php @@ -0,0 +1,81 @@ + + * + * PSR-16 compliant cache-based session store. + * + * This implementation uses any PSR-16 compliant cache as the storage backend + * for session data. Each session is stored with a prefixed key using the session ID. + */ +class Psr16StoreSession implements SessionStoreInterface +{ + public function __construct( + private readonly CacheInterface $cache, + private readonly string $prefix = 'mcp-', + private readonly int $ttl = 3600, + ) { + } + + public function exists(Uuid $id): bool + { + try { + return $this->cache->has($this->getKey($id)); + } catch (\Throwable) { + return false; + } + } + + public function read(Uuid $id): string|false + { + try { + return $this->cache->get($this->getKey($id), false); + } catch (\Throwable) { + return false; + } + } + + public function write(Uuid $id, string $data): bool + { + try { + return $this->cache->set($this->getKey($id), $data, $this->ttl); + } catch (\Throwable) { + return false; + } + } + + public function destroy(Uuid $id): bool + { + try { + return $this->cache->delete($this->getKey($id)); + } catch (\Throwable) { + return false; + } + } + + public function gc(): array + { + return []; + } + + private function getKey(Uuid $id): string + { + return $this->prefix.$id; + } +} From 55f860808cf4d1ecea6122d2ffca18af166a5116 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Mon, 27 Oct 2025 22:20:48 +0100 Subject: [PATCH 46/66] fix: extract and pass URI template variables to resource handlers (#123) --- .../McpElements.php | 10 +++++----- phpstan-baseline.neon | 12 ------------ .../Registry/ResourceTemplateReference.php | 19 +++++++++---------- .../Handler/Request/ReadResourceHandler.php | 7 +++++-- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/http-discovery-userprofile/McpElements.php index 7120a5ff..e763891a 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/http-discovery-userprofile/McpElements.php @@ -16,6 +16,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\InvalidArgumentException; use Psr\Log\LoggerInterface; /** @@ -47,7 +48,7 @@ public function __construct( * * @return User user profile data * - * @throws McpServerException if the user is not found + * @throws InvalidArgumentException if the user is not found */ #[McpResourceTemplate( uriTemplate: 'user://{userId}/profile', @@ -61,8 +62,7 @@ public function getUserProfile( ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); if (!isset($this->users[$userId])) { - // Throwing an exception that Processor can turn into an error response - throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); + throw new InvalidArgumentException("User profile not found for ID: {$userId}"); } return $this->users[$userId]; @@ -130,7 +130,7 @@ public function testToolWithoutParams(): array * * @return array[] prompt messages * - * @throws McpServerException if user not found + * @throws InvalidArgumentException if user not found */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( @@ -140,7 +140,7 @@ public function generateBio( ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (!isset($this->users[$userId])) { - throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); + throw new InvalidArgumentException("User not found for bio prompt: {$userId}"); } $user = $this->users[$userId]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f78906ae..f5901b28 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,17 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException\.$#' - identifier: class.notFound - count: 2 - path: examples/http-discovery-userprofile/McpElements.php - - - - message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 2 - path: examples/http-discovery-userprofile/McpElements.php - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' identifier: return.type diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index 951d0aff..b71a943a 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -57,18 +57,17 @@ public function getVariableNames(): array public function matches(string $uri): bool { - if (preg_match($this->uriTemplateRegex, $uri, $matches)) { - $variables = []; - foreach ($this->variableNames as $varName) { - if (isset($matches[$varName])) { - $variables[$varName] = $matches[$varName]; - } - } + return 1 === preg_match($this->uriTemplateRegex, $uri); + } - return true; - } + /** @return array */ + public function extractVariables(string $uri): array + { + $matches = []; + + preg_match($this->uriTemplateRegex, $uri, $matches); - return false; + return array_filter($matches, fn ($key) => \in_array($key, $this->variableNames), \ARRAY_FILTER_USE_KEY); } /** diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 83f0d654..19e426aa 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -65,11 +65,14 @@ public function handle(Request $request, SessionInterface $session): Response|Er '_session' => $session, ]; - $result = $this->referenceHandler->handle($reference, $arguments); - if ($reference instanceof ResourceTemplateReference) { + $variables = $reference->extractVariables($uri); + $arguments = array_merge($arguments, $variables); + + $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); } else { + $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result, $uri, $reference->schema->mimeType); } From 8885d29a4eedbe191aa11530d45b8fa8ebf9bf0b Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 30 Oct 2025 11:49:13 +0100 Subject: [PATCH 47/66] fix(server): add completion handler to builder (#126) --- src/Server/Builder.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f309a3d4..90bf53a4 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -391,15 +391,16 @@ public function build(): Server $referenceHandler = new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ - new Handler\Request\PingHandler(), - new Handler\Request\InitializeHandler($configuration), - new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), + new Handler\Request\CompletionCompleteHandler($registry, $container), + new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + new Handler\Request\InitializeHandler($configuration), + new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), + new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), + new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), - new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), - new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), ]); $notificationHandlers = array_merge($this->notificationHandlers, [ From ca18caf29dd9b922c957ba5abdc7bd426023abcb Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 30 Oct 2025 17:30:58 +0100 Subject: [PATCH 48/66] =?UTF-8?q?[Server]=20Fix:=20standardize=20error=20h?= =?UTF-8?q?andling=20across=20handlers=20with=20MCP=20specificatio?= =?UTF-8?q?=E2=80=A6=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: standardize error handling across handlers with MCP specification compliance * Update mcp-elements.md Co-authored-by: Christopher Hertel * docs: clarify error handling behavior for all element types * docs: remove unnecessary whitespace in mcp-elements.md * fix: simplify exception signatures to accept simple messages * refactor: use specific exceptions in examples for better error handling * feat: throw exceptions from reference provider instead of returning null --------- Co-authored-by: Christopher Hertel --- docs/mcp-elements.md | 55 ++++++++++++------- examples/http-client-communication/server.php | 3 +- .../McpElements.php | 11 ++-- .../CachedCalculatorElements.php | 3 +- .../McpElements.php | 11 ++-- src/Capability/Registry.php | 31 ++++++----- .../Registry/ReferenceProviderInterface.php | 19 +++++-- src/Exception/PromptGetException.php | 8 --- src/Exception/PromptNotFoundException.php | 6 +- src/Exception/ResourceNotFoundException.php | 6 +- src/Exception/ResourceReadException.php | 8 --- src/Exception/ToolCallException.php | 8 --- src/Exception/ToolNotFoundException.php | 6 +- src/Schema/JsonRpc/Error.php | 5 ++ .../Handler/Request/CallToolHandler.php | 19 +++---- .../Request/CompletionCompleteHandler.php | 49 +++++++++-------- .../Handler/Request/GetPromptHandler.php | 18 +++--- .../Handler/Request/ReadResourceHandler.php | 12 ++-- .../Registry/RegistryProviderTest.php | 43 ++++++++++----- .../Unit/Capability/Registry/RegistryTest.php | 46 +++++++++++----- .../Handler/Request/CallToolHandlerTest.php | 55 ++++++++++++++++--- .../Handler/Request/GetPromptHandlerTest.php | 10 ++-- .../Request/ReadResourceHandlerTest.php | 31 ++++++++--- 23 files changed, 278 insertions(+), 185 deletions(-) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 911b3d52..1bb2690d 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -154,16 +154,21 @@ public function getMultipleContent(): array #### Error Handling -Tools can throw exceptions which are automatically converted to proper JSON-RPC error responses: +Tool handlers can throw any exception, but the type determines how it's handled: + +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\ToolCallException; + #[McpTool] public function divideNumbers(float $a, float $b): float { if ($b === 0.0) { - throw new \InvalidArgumentException('Division by zero is not allowed'); + throw new ToolCallException('Division by zero is not allowed'); } - + return $a / $b; } @@ -171,14 +176,15 @@ public function divideNumbers(float $a, float $b): float public function processFile(string $filename): string { if (!file_exists($filename)) { - throw new \InvalidArgumentException("File not found: {$filename}"); + throw new ToolCallException("File not found: {$filename}"); } - + return file_get_contents($filename); } ``` -The SDK will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand. +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. + ## Resources @@ -298,24 +304,31 @@ public function getMultipleResources(): array #### Error Handling -Resource handlers can throw exceptions for error cases: +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\ResourceReadException; + #[McpResource(uri: 'file://{path}')] public function getFile(string $path): string { if (!file_exists($path)) { - throw new \InvalidArgumentException("File not found: {$path}"); + throw new ResourceReadException("File not found: {$path}"); } - + if (!is_readable($path)) { - throw new \RuntimeException("File not readable: {$path}"); + throw new ResourceReadException("File not readable: {$path}"); } - + return file_get_contents($path); } ``` +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. + ## Resource Templates Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules @@ -449,6 +462,8 @@ public function explicitMessages(): array } ``` +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + #### Valid Message Roles - **`user`**: User input or questions @@ -456,33 +471,35 @@ public function explicitMessages(): array #### Error Handling -Prompt handlers can throw exceptions for invalid inputs: +Prompt handlers can throw any exception, but the type determines how it's handled: +- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\PromptGetException; + #[McpPrompt] public function generatePrompt(string $topic, string $style): array { $validStyles = ['casual', 'formal', 'technical']; - + if (!in_array($style, $validStyles)) { - throw new \InvalidArgumentException( + throw new PromptGetException( "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) ); } - + return [ ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] ]; } ``` -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. ## Completion Providers -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools -and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have -dynamic parameters that benefit from completion hints. +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. ### Completion Provider Types diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php index 2acce334..8191a8e2 100644 --- a/examples/http-client-communication/server.php +++ b/examples/http-client-communication/server.php @@ -14,6 +14,7 @@ use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Exception\ToolCallException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Error as JsonRpcError; @@ -64,7 +65,7 @@ function (string $projectName, array $milestones, ClientGateway $client): array ); if ($response instanceof JsonRpcError) { - throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); + throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); } $result = $response->result; diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/http-discovery-userprofile/McpElements.php index e763891a..933bd51e 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/http-discovery-userprofile/McpElements.php @@ -16,7 +16,8 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; -use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\ResourceReadException; use Psr\Log\LoggerInterface; /** @@ -48,7 +49,7 @@ public function __construct( * * @return User user profile data * - * @throws InvalidArgumentException if the user is not found + * @throws ResourceReadException if the user is not found */ #[McpResourceTemplate( uriTemplate: 'user://{userId}/profile', @@ -62,7 +63,7 @@ public function getUserProfile( ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); if (!isset($this->users[$userId])) { - throw new InvalidArgumentException("User profile not found for ID: {$userId}"); + throw new ResourceReadException("User not found for ID: {$userId}"); } return $this->users[$userId]; @@ -130,7 +131,7 @@ public function testToolWithoutParams(): array * * @return array[] prompt messages * - * @throws InvalidArgumentException if user not found + * @throws PromptGetException if user not found */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( @@ -140,7 +141,7 @@ public function generateBio( ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (!isset($this->users[$userId])) { - throw new InvalidArgumentException("User not found for bio prompt: {$userId}"); + throw new PromptGetException("User not found for bio prompt: {$userId}"); } $user = $this->users[$userId]; diff --git a/examples/stdio-cached-discovery/CachedCalculatorElements.php b/examples/stdio-cached-discovery/CachedCalculatorElements.php index 2d5249df..1da3d277 100644 --- a/examples/stdio-cached-discovery/CachedCalculatorElements.php +++ b/examples/stdio-cached-discovery/CachedCalculatorElements.php @@ -14,6 +14,7 @@ namespace Mcp\Example\StdioCachedDiscovery; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\ToolCallException; /** * Example MCP elements for demonstrating cached discovery. @@ -39,7 +40,7 @@ public function multiply(int $a, int $b): int public function divide(int $a, int $b): float { if (0 === $b) { - throw new \InvalidArgumentException('Division by zero is not allowed'); + throw new ToolCallException('Division by zero is not allowed'); } return $a / $b; diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/stdio-discovery-calculator/McpElements.php index 71aea372..21330313 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/stdio-discovery-calculator/McpElements.php @@ -13,6 +13,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\ToolCallException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -44,10 +45,10 @@ public function __construct( * @param float $b the second operand * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide') * - * @return float|string the result of the calculation, or an error message string + * @return float the result of the calculation */ #[McpTool(name: 'calculate')] - public function calculate(float $a, float $b, string $operation): float|string + public function calculate(float $a, float $b, string $operation): float { $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); @@ -65,16 +66,16 @@ public function calculate(float $a, float $b, string $operation): float|string break; case 'divide': if (0 == $b) { - return 'Error: Division by zero.'; + throw new ToolCallException('Division by zero is not allowed.'); } $result = $a / $b; break; default: - return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; + throw new ToolCallException("Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."); } if (!$this->config['allow_negative'] && $result < 0) { - return 'Error: Negative results are disabled.'; + throw new ToolCallException('Negative results are disabled.'); } return round($result, $this->config['precision']); diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 94db079f..3a13c323 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -23,6 +23,9 @@ use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\InvalidCursorException; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; @@ -209,43 +212,41 @@ public function clear(): void } } - public function getTool(string $name): ?ToolReference + public function getTool(string $name): ToolReference { - return $this->tools[$name] ?? null; + return $this->tools[$name] ?? throw new ToolNotFoundException($name); } public function getResource( string $uri, bool $includeTemplates = true, - ): ResourceReference|ResourceTemplateReference|null { + ): ResourceReference|ResourceTemplateReference { $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; } - if (!$includeTemplates) { - return null; - } - - foreach ($this->resourceTemplates as $template) { - if ($template->matches($uri)) { - return $template; + if ($includeTemplates) { + foreach ($this->resourceTemplates as $template) { + if ($template->matches($uri)) { + return $template; + } } } $this->logger->debug('No resource matched URI.', ['uri' => $uri]); - return null; + throw new ResourceNotFoundException($uri); } - public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference { - return $this->resourceTemplates[$uriTemplate] ?? null; + return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate); } - public function getPrompt(string $name): ?PromptReference + public function getPrompt(string $name): PromptReference { - return $this->prompts[$name] ?? null; + return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } public function getTools(?int $limit = null, ?string $cursor = null): Page diff --git a/src/Capability/Registry/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 2f60014b..0af66e1f 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,6 +11,9 @@ namespace Mcp\Capability\Registry; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; /** @@ -23,23 +26,31 @@ interface ReferenceProviderInterface { /** * Gets a tool reference by name. + * + * @throws ToolNotFoundException */ - public function getTool(string $name): ?ToolReference; + public function getTool(string $name): ToolReference; /** * Gets a resource reference by URI (includes template matching if enabled). + * + * @throws ResourceNotFoundException */ - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null; + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference; /** * Gets a resource template reference by URI template. + * + * @throws ResourceNotFoundException */ - public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference; + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference; /** * Gets a prompt reference by name. + * + * @throws PromptNotFoundException */ - public function getPrompt(string $name): ?PromptReference; + public function getPrompt(string $name): PromptReference; /** * Gets all registered tools. diff --git a/src/Exception/PromptGetException.php b/src/Exception/PromptGetException.php index 8970ea58..7eec0daf 100644 --- a/src/Exception/PromptGetException.php +++ b/src/Exception/PromptGetException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ final class PromptGetException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly GetPromptRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Handling prompt "%s" failed with error: "%s".', $request->name, $previous->getMessage()), previous: $previous); - } } diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php index 82872e8b..81b7c6e5 100644 --- a/src/Exception/PromptNotFoundException.php +++ b/src/Exception/PromptNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly GetPromptRequest $request, + public readonly string $name, ) { - parent::__construct(\sprintf('Prompt not found for name: "%s".', $request->name)); + parent::__construct(\sprintf('Prompt not found: "%s".', $name)); } } diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php index b5624bbc..420ac1a8 100644 --- a/src/Exception/ResourceNotFoundException.php +++ b/src/Exception/ResourceNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly ReadResourceRequest $request, + public readonly string $uri, ) { - parent::__construct(\sprintf('Resource not found for uri: "%s".', $request->uri)); + parent::__construct(\sprintf('Resource not found for uri: "%s".', $uri)); } } diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php index 913064b2..a89dec8e 100644 --- a/src/Exception/ResourceReadException.php +++ b/src/Exception/ResourceReadException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ final class ResourceReadException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly ReadResourceRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Reading resource "%s" failed with error: "%s".', $request->uri, $previous?->getMessage() ?? ''), previous: $previous); - } } diff --git a/src/Exception/ToolCallException.php b/src/Exception/ToolCallException.php index 71978d9d..01ba9f45 100644 --- a/src/Exception/ToolCallException.php +++ b/src/Exception/ToolCallException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ final class ToolCallException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly CallToolRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Tool call "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); - } } diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php index 3795d74e..0a864e75 100644 --- a/src/Exception/ToolNotFoundException.php +++ b/src/Exception/ToolNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly CallToolRequest $request, + public readonly string $name, ) { - parent::__construct(\sprintf('Tool not found for call: "%s".', $request->name)); + parent::__construct(\sprintf('Tool not found: "%s".', $name)); } } diff --git a/src/Schema/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index ae802580..d5273eb1 100644 --- a/src/Schema/JsonRpc/Error.php +++ b/src/Schema/JsonRpc/Error.php @@ -106,6 +106,11 @@ public static function forServerError(string $message, string|int $id = ''): sel return new self($id, self::SERVER_ERROR, $message); } + public static function forResourceNotFound(string $message, string|int $id = ''): self + { + return new self($id, self::RESOURCE_NOT_FOUND, $message); + } + public function getId(): string|int { return $this->id; diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 79413908..df4aff51 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -13,9 +13,9 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\ExceptionInterface; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -59,9 +59,6 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getTool($toolName); - if (null === $reference) { - throw new ToolNotFoundException($request); - } $arguments['_session'] = $session; @@ -77,17 +74,19 @@ public function handle(Request $request, SessionInterface $session): Response|Er ]); return new Response($request->getId(), $result); - } catch (ToolNotFoundException $e) { - $this->logger->error('Tool not found', ['name' => $toolName]); - - return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); - } catch (ToolCallException|ExceptionInterface $e) { + } catch (ToolCallException $e) { $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ 'tool' => $toolName, 'arguments' => $arguments, ]); - return Error::forInternalError('Error while executing tool', $request->getId()); + $errorContent = [new TextContent($e->getMessage())]; + + return new Response($request->getId(), CallToolResult::error($errorContent)); + } catch (ToolNotFoundException $e) { + $this->logger->error('Tool not found', ['name' => $toolName]); + + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unhandled error during tool execution', [ 'name' => $toolName, diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index c3d9f844..f1c1b9d6 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -13,10 +13,14 @@ use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -51,41 +55,38 @@ public function handle(Request $request, SessionInterface $session): Response|Er $name = $request->argument['name'] ?? ''; $value = $request->argument['value'] ?? ''; - $reference = match ($request->ref->type) { - 'ref/prompt' => $this->referenceProvider->getPrompt($request->ref->name), - 'ref/resource' => $this->referenceProvider->getResourceTemplate($request->ref->uri), - default => null, - }; - - if (null === $reference) { - return new Response($request->getId(), new CompletionCompleteResult([])); - } + try { + $reference = match (true) { + $request->ref instanceof PromptReference => $this->referenceProvider->getPrompt($request->ref->name), + $request->ref instanceof ResourceReference => $this->referenceProvider->getResource($request->ref->uri), + }; - $providers = $reference->completionProviders; - $provider = $providers[$name] ?? null; - if (null === $provider) { - return new Response($request->getId(), new CompletionCompleteResult([])); - } + $providers = $reference->completionProviders; + $provider = $providers[$name] ?? null; + if (null === $provider) { + return new Response($request->getId(), new CompletionCompleteResult([])); + } - if (\is_string($provider)) { - if (!class_exists($provider)) { - return Error::forInternalError('Invalid completion provider', $request->getId()); + if (\is_string($provider)) { + if (!class_exists($provider)) { + return Error::forInternalError('Invalid completion provider', $request->getId()); + } + $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); } - $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); - } - if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $request->getId()); - } + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $request->getId()); + } - try { $completions = $provider->getCompletions($value); $total = \count($completions); $hasMore = $total > 100; $paged = \array_slice($completions, 0, 100); return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); - } catch (\Throwable) { + } catch (PromptNotFoundException|ResourceNotFoundException $e) { + return Error::forResourceNotFound($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { return Error::forInternalError('Error while handling completion request', $request->getId()); } } diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 28e5e909..1c8758ab 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,7 +13,6 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\ExceptionInterface; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; @@ -56,9 +55,6 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getPrompt($promptName); - if (null === $reference) { - throw new PromptNotFoundException($request); - } $arguments['_session'] = $session; @@ -67,18 +63,18 @@ public function handle(Request $request, SessionInterface $session): Response|Er $formatted = $reference->formatResult($result); return new Response($request->getId(), new GetPromptResult($formatted)); + } catch (PromptGetException $e) { + $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + + return Error::forInternalError($e->getMessage(), $request->getId()); } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); - return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); - } catch (PromptGetException|ExceptionInterface $e) { - $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - - return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId()); + return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); + $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); - return Error::forInternalError('Error while handling prompt: '.$e->getMessage(), $request->getId()); + return Error::forInternalError('Error while handling prompt', $request->getId()); } } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 19e426aa..c5160cd5 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -15,6 +15,7 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -56,9 +57,6 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $reference = $this->referenceProvider->getResource($uri); - if (null === $reference) { - throw new ResourceNotFoundException($request); - } $arguments = [ 'uri' => $uri, @@ -77,12 +75,16 @@ public function handle(Request $request, SessionInterface $session): Response|Er } return new Response($request->getId(), new ReadResourceResult($formatted)); + } catch (ResourceReadException $e) { + $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + + return Error::forInternalError($e->getMessage(), $request->getId()); } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri]); - return new Error($request->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); + return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage())); return Error::forInternalError('Error while reading resource', $request->getId()); } diff --git a/tests/Unit/Capability/Registry/RegistryProviderTest.php b/tests/Unit/Capability/Registry/RegistryProviderTest.php index b1eaa857..cebf474c 100644 --- a/tests/Unit/Capability/Registry/RegistryProviderTest.php +++ b/tests/Unit/Capability/Registry/RegistryProviderTest.php @@ -16,6 +16,9 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -45,10 +48,12 @@ public function testGetToolReturnsRegisteredTool(): void $this->assertFalse($toolRef->isManual); } - public function testGetToolReturnsNullForUnregisteredTool(): void + public function testGetToolThrowsExceptionForUnregisteredTool(): void { - $toolRef = $this->registry->getTool('non_existent_tool'); - $this->assertNull($toolRef); + $this->expectException(ToolNotFoundException::class); + $this->expectExceptionMessage('Tool not found: "non_existent_tool".'); + + $this->registry->getTool('non_existent_tool'); } public function testGetResourceReturnsRegisteredResource(): void @@ -65,10 +70,12 @@ public function testGetResourceReturnsRegisteredResource(): void $this->assertFalse($resourceRef->isManual); } - public function testGetResourceReturnsNullForUnregisteredResource(): void + public function testGetResourceThrowsExceptionForUnregisteredResource(): void { - $resourceRef = $this->registry->getResource('test://non_existent'); - $this->assertNull($resourceRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://non_existent".'); + + $this->registry->getResource('test://non_existent'); } public function testGetResourceMatchesResourceTemplate(): void @@ -84,15 +91,17 @@ public function testGetResourceMatchesResourceTemplate(): void $this->assertEquals($handler, $resourceRef->handler); } - public function testGetResourceWithIncludeTemplatesFalse(): void + public function testGetResourceWithIncludeTemplatesFalseThrowsException(): void { $template = $this->createValidResourceTemplate('test://{id}'); $handler = fn (string $id) => "content for {$id}"; $this->registry->registerResourceTemplate($template, $handler); - $resourceRef = $this->registry->getResource('test://123', false); - $this->assertNull($resourceRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://123".'); + + $this->registry->getResource('test://123', false); } public function testGetResourcePrefersDirectResourceOverTemplate(): void @@ -125,10 +134,12 @@ public function testGetResourceTemplateReturnsRegisteredTemplate(): void $this->assertFalse($templateRef->isManual); } - public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void + public function testGetResourceTemplateThrowsExceptionForUnregisteredTemplate(): void { - $templateRef = $this->registry->getResourceTemplate('test://{non_existent}'); - $this->assertNull($templateRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://{non_existent}".'); + + $this->registry->getResourceTemplate('test://{non_existent}'); } public function testGetPromptReturnsRegisteredPrompt(): void @@ -145,10 +156,12 @@ public function testGetPromptReturnsRegisteredPrompt(): void $this->assertFalse($promptRef->isManual); } - public function testGetPromptReturnsNullForUnregisteredPrompt(): void + public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void { - $promptRef = $this->registry->getPrompt('non_existent_prompt'); - $this->assertNull($promptRef); + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found: "non_existent_prompt".'); + + $this->registry->getPrompt('non_existent_prompt'); } public function testGetToolsReturnsAllRegisteredTools(): void diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index e1f47689..33cb967e 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -13,6 +13,9 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -264,23 +267,36 @@ public function testClearRemovesOnlyDiscoveredElements(): void $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Removed 4 discovered elements from internal registry.'); + // Test that all elements exist + $this->registry->getTool('manual_tool'); + $this->registry->getResource('test://manual'); + $this->registry->getPrompt('manual_prompt'); + $this->registry->getResourceTemplate('manual://{id}'); + $this->registry->getTool('discovered_tool'); + $this->registry->getResource('test://discovered'); + $this->registry->getPrompt('discovered_prompt'); + $this->registry->getResourceTemplate('discovered://{id}'); $this->registry->clear(); - $this->assertNotNull($this->registry->getTool('manual_tool')); - $this->assertNull($this->registry->getTool('discovered_tool')); - $this->assertNotNull($this->registry->getResource('test://manual')); - $this->assertNull( - $this->registry->getResource('test://discovered', false), - ); // Don't include templates to avoid debug log - $this->assertNotNull($this->registry->getPrompt('manual_prompt')); - $this->assertNull($this->registry->getPrompt('discovered_prompt')); - $this->assertNotNull($this->registry->getResourceTemplate('manual://{id}')); - $this->assertNull($this->registry->getResourceTemplate('discovered://{id}')); + // Manual elements should still exist + $this->registry->getTool('manual_tool'); + $this->registry->getResource('test://manual'); + $this->registry->getPrompt('manual_prompt'); + $this->registry->getResourceTemplate('manual://{id}'); + + // Test that all discovered elements throw exceptions + $this->expectException(ToolNotFoundException::class); + $this->registry->getTool('discovered_tool'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResource('test://discovered'); + + $this->expectException(PromptNotFoundException::class); + $this->registry->getPrompt('discovered_prompt'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResourceTemplate('discovered://{id}'); } public function testClearLogsNothingWhenNoDiscoveredElements(): void @@ -294,7 +310,7 @@ public function testClearLogsNothingWhenNoDiscoveredElements(): void $this->registry->clear(); - $this->assertNotNull($this->registry->getTool('manual_tool')); + $this->registry->getTool('manual_tool'); } public function testRegisterToolHandlesStringHandler(): void diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 359afa1b..00b410f2 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -164,7 +164,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void ->expects($this->once()) ->method('getTool') ->with('nonexistent_tool') - ->willThrowException(new ToolNotFoundException($request)); + ->willThrowException(new ToolNotFoundException('nonexistent_tool')); $this->logger ->expects($this->once()) @@ -177,10 +177,10 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } - public function testHandleToolExecutionExceptionReturnsError(): void + public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); + $exception = new ToolCallException('Tool execution failed'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -201,9 +201,15 @@ public function testHandleToolExecutionExceptionReturnsError(): void $response = $this->handler->handle($request, $this->session); - $this->assertInstanceOf(Error::class, $response); + $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('Tool execution failed', $result->content[0]->text); } public function testHandleWithNullResult(): void @@ -246,7 +252,7 @@ public function testConstructorWithDefaultLogger(): void public function testHandleLogsErrorWithCorrectParameters(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); - $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); + $exception = new ToolCallException('Custom error message'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -265,7 +271,7 @@ public function testHandleLogsErrorWithCorrectParameters(): void ->expects($this->once()) ->method('error') ->with( - 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', + 'Error while executing tool "test_tool": "Custom error message".', [ 'tool' => 'test_tool', 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], @@ -274,8 +280,43 @@ public function testHandleLogsErrorWithCorrectParameters(): void $response = $this->handler->handle($request, $this->session); + // ToolCallException should now return Response with CallToolResult having isError=true + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('Custom error message', $result->content[0]->text); + } + + public function testHandleGenericExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new \RuntimeException('Internal database connection failed'); + + $toolReference = $this->createMock(ToolReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value', '_session' => $this->session]) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + // Generic exceptions should return Error, not Response $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); } public function testHandleWithSpecialCharactersInToolName(): void diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index b7f5d259..03abe085 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -231,7 +231,7 @@ public function testHandlePromptGetWithMultipleMessages(): void public function testHandlePromptNotFoundExceptionReturnsError(): void { $request = $this->createGetPromptRequest('nonexistent_prompt'); - $exception = new PromptNotFoundException($request); + $exception = new PromptNotFoundException('nonexistent_prompt'); $this->referenceProvider ->expects($this->once()) @@ -243,14 +243,14 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); - $this->assertEquals('Prompt not found for name: "nonexistent_prompt".', $response->message); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); } public function testHandlePromptGetExceptionReturnsError(): void { $request = $this->createGetPromptRequest('failing_prompt'); - $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); + $exception = new PromptGetException('Failed to get prompt'); $this->referenceProvider ->expects($this->once()) @@ -263,7 +263,7 @@ public function testHandlePromptGetExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while handling prompt: Handling prompt "failing_prompt" failed with error: "Failed to get prompt".', $response->message); + $this->assertEquals('Failed to get prompt', $response->message); } public function testHandlePromptGetWithComplexArguments(): void diff --git a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 2c54110d..92b5a6f2 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -178,7 +178,7 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void { $uri = 'file://nonexistent/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once()) @@ -194,14 +194,31 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } - public function testHandleResourceReadExceptionReturnsGenericError(): void + public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void { $uri = 'file://corrupted/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceReadException( - $request, - new \RuntimeException('Failed to read resource: corrupted data'), - ); + $exception = new ResourceReadException('Failed to read resource: corrupted data'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Failed to read resource: corrupted data', $response->message); + } + + public function testHandleGenericExceptionReturnsGenericError(): void + { + $uri = 'file://problematic/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new \RuntimeException('Internal database connection failed'); $this->referenceProvider ->expects($this->once()) @@ -382,7 +399,7 @@ public function testHandleResourceNotFoundWithCustomMessage(): void { $uri = 'file://custom/missing.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once()) From 757b959bfd2a0842c6af49e5d546e45426aea686 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 30 Oct 2025 17:47:53 +0100 Subject: [PATCH 49/66] [Server] Feat: Add comprehensive HTTP inspector tests with improved framework (#125) * feat: add comprehensive HTTP inspector tests and improve test framework * chore: remove debug flag from server configuration --- .../Http/HttpClientCommunicationTest.php | 64 ++++ .../Http/HttpCombinedRegistrationTest.php | 50 +++ .../Http/HttpComplexToolSchemaTest.php | 87 ++++++ .../Http/HttpDiscoveryUserProfileTest.php | 72 +++++ .../Http/HttpInspectorSnapshotTestCase.php | 102 +++++++ .../Inspector/Http/HttpSchemaShowcaseTest.php | 116 +++++++ ...ombinedRegistrationTest-prompts_list.json} | 0 ...mbinedRegistrationTest-resources_list.json | 9 + ...onTest-resources_read-config_priority.json | 9 + ...trationTest-resources_templates_list.json} | 0 ...st-tools_call-discovered_status_check.json | 9 + ...trationTest-tools_call-manual_greeter.json | 9 + ...tpCombinedRegistrationTest-tools_list.json | 28 ++ ...tpComplexToolSchemaTest-prompts_list.json} | 0 ...ComplexToolSchemaTest-resources_list.json} | 0 ...lSchemaTest-resources_templates_list.json} | 0 ..._call-schedule_event_all_day_reminder.json | 9 + ...ols_call-schedule_event_high_priority.json | 9 + ...ools_call-schedule_event_low_priority.json | 9 + ...call-schedule_event_meeting_with_time.json | 9 + .../HttpComplexToolSchemaTest-tools_list.json | 67 +++++ ...eTest-prompts_get-generate_bio_prompt.json | 11 + ...DiscoveryUserProfileTest-prompts_list.json | 20 ++ ...scoveryUserProfileTest-resources_list.json | 16 + ...Test-resources_read-read_user_id_list.json | 9 + ...-resources_read-read_user_profile_101.json | 9 + ...-resources_read-read_user_profile_102.json | 9 + ...rProfileTest-resources_templates_list.json | 10 + ...erProfileTest-tools_call-send_welcome.json | 9 + ...t-tools_call-test_tool_without_params.json | 9 + ...tpDiscoveryUserProfileTest-tools_list.json | 58 ++++ .../HttpSchemaShowcaseTest-prompts_list.json} | 0 ...ttpSchemaShowcaseTest-resources_list.json} | 0 ...howcaseTest-resources_templates_list.json} | 0 ...owcaseTest-tools_call-calculate_range.json | 9 + ...maShowcaseTest-tools_call-format_text.json | 9 + ...owcaseTest-tools_call-generate_config.json | 9 + ...maShowcaseTest-tools_call-manage_list.json | 9 + ...howcaseTest-tools_call-schedule_event.json | 9 + ...wcaseTest-tools_call-validate_profile.json | 9 + .../HttpSchemaShowcaseTest-tools_list.json | 284 ++++++++++++++++++ tests/Inspector/InspectorSnapshotTestCase.php | 109 ++++--- .../Stdio/StdioCachedDiscoveryTest.php | 93 ++++++ .../Stdio/StdioCustomDependenciesTest.php | 77 +++++ .../Stdio/StdioDiscoveryCalculatorTest.php | 50 +++ .../Inspector/Stdio/StdioEnvVariablesTest.php | 55 ++++ .../Stdio/StdioExplicitRegistrationTest.php | 82 +++++ .../Stdio/StdioInspectorSnapshotTestCase.php | 37 +++ ...tdioCachedDiscoveryTest-prompts_list.json} | 0 ...dioCachedDiscoveryTest-resources_list.json | 3 + ...scoveryTest-resources_templates_list.json} | 0 ...dDiscoveryTest-tools_call-add_numbers.json | 9 + ...yTest-tools_call-add_numbers_negative.json | 9 + ...scoveryTest-tools_call-divide_numbers.json | 9 + ...est-tools_call-divide_numbers_decimal.json | 9 + ...overyTest-tools_call-multiply_numbers.json | 9 + ...Test-tools_call-multiply_numbers_zero.json | 9 + ...oCachedDiscoveryTest-tools_call-power.json | 9 + ...ryTest-tools_call-power_zero_exponent.json | 9 + .../StdioCachedDiscoveryTest-tools_list.json | 0 ...ioCustomDependenciesTest-prompts_list.json | 3 + ...CustomDependenciesTest-resources_list.json | 0 ...Test-resources_read-read_system_stats.json | 9 + ...ndenciesTest-resources_templates_list.json | 3 + ...mDependenciesTest-tools_call-add_task.json | 9 + ...ndenciesTest-tools_call-complete_task.json | 9 + ...enciesTest-tools_call-list_user_tasks.json | 9 + ...tdioCustomDependenciesTest-tools_list.json | 0 ...oDiscoveryCalculatorTest-prompts_list.json | 3 + ...iscoveryCalculatorTest-resources_list.json | 0 ...latorTest-resources_read-read_config.json} | 0 ...iscoveryCalculatorTest-resources_read.json | 9 + ...lculatorTest-resources_templates_list.json | 3 + ...culatorTest-tools_call-calculate_sum.json} | 0 ...culatorTest-tools_call-update_setting.json | 9 + ...dioDiscoveryCalculatorTest-tools_call.json | 9 + ...dioDiscoveryCalculatorTest-tools_list.json | 0 .../StdioEnvVariablesTest-prompts_list.json | 3 + .../StdioEnvVariablesTest-resources_list.json | 3 + ...ariablesTest-resources_templates_list.json | 3 + ...lesTest-tools_call-process_data_debug.json | 9 + ...sTest-tools_call-process_data_default.json | 9 + ...st-tools_call-process_data_production.json | 9 + .../StdioEnvVariablesTest-tools_list.json | 0 ...ompts_get-personalized_greeting_alice.json | 11 + ...prompts_get-personalized_greeting_bob.json | 11 + ...ExplicitRegistrationTest-prompts_list.json | 0 ...plicitRegistrationTest-resources_list.json | 0 ...nTest-resources_read-read_app_version.json | 9 + ...-resources_read-read_item_123_details.json | 9 + ...-resources_read-read_item_ABC_details.json | 9 + ...strationTest-resources_templates_list.json | 0 ...RegistrationTest-tools_call-echo_text.json | 9 + ...st-tools_call-echo_text_special_chars.json | 9 + ...ioExplicitRegistrationTest-tools_list.json | 0 tests/Inspector/StdioCachedDiscoveryTest.php | 29 -- .../Inspector/StdioCustomDependenciesTest.php | 29 -- .../StdioDiscoveryCalculatorTest.php | 38 --- tests/Inspector/StdioEnvVariablesTest.php | 29 -- .../StdioExplicitRegistrationTest.php | 29 -- 100 files changed, 1877 insertions(+), 189 deletions(-) create mode 100644 tests/Inspector/Http/HttpClientCommunicationTest.php create mode 100644 tests/Inspector/Http/HttpCombinedRegistrationTest.php create mode 100644 tests/Inspector/Http/HttpComplexToolSchemaTest.php create mode 100644 tests/Inspector/Http/HttpDiscoveryUserProfileTest.php create mode 100644 tests/Inspector/Http/HttpInspectorSnapshotTestCase.php create mode 100644 tests/Inspector/Http/HttpSchemaShowcaseTest.php rename tests/Inspector/{snapshots/StdioCachedDiscoveryTest-prompts_list.json => Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json} (100%) create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json rename tests/Inspector/{snapshots/StdioCachedDiscoveryTest-resources_templates_list.json => Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json} (100%) create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json create mode 100644 tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json rename tests/Inspector/{snapshots/StdioCustomDependenciesTest-prompts_list.json => Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json} (100%) rename tests/Inspector/{snapshots/StdioCachedDiscoveryTest-resources_list.json => Http/snapshots/HttpComplexToolSchemaTest-resources_list.json} (100%) rename tests/Inspector/{snapshots/StdioCustomDependenciesTest-resources_templates_list.json => Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json} (100%) create mode 100644 tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json create mode 100644 tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json create mode 100644 tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json create mode 100644 tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json create mode 100644 tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json create mode 100644 tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json rename tests/Inspector/{snapshots/StdioDiscoveryCalculatorTest-prompts_list.json => Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json} (100%) rename tests/Inspector/{snapshots/StdioEnvVariablesTest-resources_list.json => Http/snapshots/HttpSchemaShowcaseTest-resources_list.json} (100%) rename tests/Inspector/{snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json => Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json} (100%) create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json create mode 100644 tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json create mode 100644 tests/Inspector/Stdio/StdioCachedDiscoveryTest.php create mode 100644 tests/Inspector/Stdio/StdioCustomDependenciesTest.php create mode 100644 tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php create mode 100644 tests/Inspector/Stdio/StdioEnvVariablesTest.php create mode 100644 tests/Inspector/Stdio/StdioExplicitRegistrationTest.php create mode 100644 tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php rename tests/Inspector/{snapshots/StdioEnvVariablesTest-prompts_list.json => Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json} (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json rename tests/Inspector/{snapshots/StdioEnvVariablesTest-resources_templates_list.json => Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json} (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json rename tests/Inspector/{ => Stdio}/snapshots/StdioCachedDiscoveryTest-tools_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json rename tests/Inspector/{ => Stdio}/snapshots/StdioCustomDependenciesTest-resources_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json rename tests/Inspector/{ => Stdio}/snapshots/StdioCustomDependenciesTest-tools_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json rename tests/Inspector/{ => Stdio}/snapshots/StdioDiscoveryCalculatorTest-resources_list.json (100%) rename tests/Inspector/{snapshots/StdioDiscoveryCalculatorTest-resources_read.json => Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json} (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json rename tests/Inspector/{snapshots/StdioDiscoveryCalculatorTest-tools_call.json => Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json} (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json rename tests/Inspector/{ => Stdio}/snapshots/StdioDiscoveryCalculatorTest-tools_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json rename tests/Inspector/{ => Stdio}/snapshots/StdioEnvVariablesTest-tools_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json rename tests/Inspector/{ => Stdio}/snapshots/StdioExplicitRegistrationTest-prompts_list.json (100%) rename tests/Inspector/{ => Stdio}/snapshots/StdioExplicitRegistrationTest-resources_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json rename tests/Inspector/{ => Stdio}/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json (100%) create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json create mode 100644 tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json rename tests/Inspector/{ => Stdio}/snapshots/StdioExplicitRegistrationTest-tools_list.json (100%) delete mode 100644 tests/Inspector/StdioCachedDiscoveryTest.php delete mode 100644 tests/Inspector/StdioCustomDependenciesTest.php delete mode 100644 tests/Inspector/StdioDiscoveryCalculatorTest.php delete mode 100644 tests/Inspector/StdioEnvVariablesTest.php delete mode 100644 tests/Inspector/StdioExplicitRegistrationTest.php diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php new file mode 100644 index 00000000..0b396e2e --- /dev/null +++ b/tests/Inspector/Http/HttpClientCommunicationTest.php @@ -0,0 +1,64 @@ +markTestSkipped('Test skipped: SDK cannot handle logging/setLevel requests required by logging capability, and built-in PHP server does not support sampling.'); + } + + public static function provideMethods(): array + { + return [ + ...parent::provideMethods(), + 'Prepare Project Briefing (Simple)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'prepare_project_briefing', + 'toolArgs' => [ + 'projectName' => 'Website Redesign', + 'milestones' => ['Discovery', 'Design', 'Development', 'Testing'], + ], + ], + 'testName' => 'prepare_project_briefing_simple', + ], + 'Prepare Project Briefing (Complex)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'prepare_project_briefing', + 'toolArgs' => [ + 'projectName' => 'Mobile App Launch', + 'milestones' => ['Market Research', 'UI/UX Design', 'MVP Development', 'Beta Testing', 'Marketing Campaign', 'Public Launch'], + ], + ], + 'testName' => 'prepare_project_briefing_complex', + ], + 'Run Service Maintenance' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'run_service_maintenance', + 'toolArgs' => [ + 'serviceName' => 'Payment Gateway API', + ], + ], + 'testName' => 'run_service_maintenance', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/http-client-communication/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php new file mode 100644 index 00000000..932c200b --- /dev/null +++ b/tests/Inspector/Http/HttpCombinedRegistrationTest.php @@ -0,0 +1,50 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'manualGreeter', + 'toolArgs' => ['user' => 'HTTP Test User'], + ], + 'testName' => 'manual_greeter', + ], + 'Discovered Status Check Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'discovered_status_check', + 'toolArgs' => [], + ], + 'testName' => 'discovered_status_check', + ], + 'Read Priority Config (Manual Override)' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'config://priority', + ], + 'testName' => 'config_priority', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/http-combined-registration/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php new file mode 100644 index 00000000..93f3ca30 --- /dev/null +++ b/tests/Inspector/Http/HttpComplexToolSchemaTest.php @@ -0,0 +1,87 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Team Standup', + 'date' => '2024-12-01', + 'type' => 'meeting', + 'time' => '09:00', + 'priority' => 'normal', + 'attendees' => ['alice@example.com', 'bob@example.com'], + 'sendInvites' => true, + ], + ], + 'testName' => 'schedule_event_meeting_with_time', + ], + 'Schedule Event (All Day Reminder)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Project Deadline', + 'date' => '2024-12-15', + 'type' => 'reminder', + 'priority' => 'high', + ], + ], + 'testName' => 'schedule_event_all_day_reminder', + ], + 'Schedule Event (Call with High Priority)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Client Call', + 'date' => '2024-12-02', + 'type' => 'call', + 'time' => '14:30', + 'priority' => 'high', + 'attendees' => ['client@example.com'], + 'sendInvites' => false, + ], + ], + 'testName' => 'schedule_event_high_priority', + ], + 'Schedule Event (Other Event with Low Priority)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Office Party', + 'date' => '2024-12-20', + 'type' => 'other', + 'time' => '18:00', + 'priority' => 'low', + 'attendees' => ['team@company.com'], + ], + ], + 'testName' => 'schedule_event_low_priority', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/http-complex-tool-schema/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php new file mode 100644 index 00000000..abffbacd --- /dev/null +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -0,0 +1,72 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'send_welcome', + 'toolArgs' => ['userId' => '101', 'customMessage' => 'Welcome to our platform!'], + ], + 'testName' => 'send_welcome', + ], + 'Test Tool Without Params' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'test_tool_without_params', + 'toolArgs' => [], + ], + 'testName' => 'test_tool_without_params', + ], + 'Read User Profile 101' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'user://101/profile', + ], + 'testName' => 'read_user_profile_101', + ], + 'Read User Profile 102' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'user://102/profile', + ], + 'testName' => 'read_user_profile_102', + ], + 'Read User ID List' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'user://list/ids', + ], + 'testName' => 'read_user_id_list', + ], + 'Generate Bio Prompt (Formal)' => [ + 'method' => 'prompts/get', + 'options' => [ + 'promptName' => 'generate_bio_prompt', + 'promptArgs' => ['userId' => '101', 'tone' => 'formal'], + ], + 'testName' => 'generate_bio_prompt', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/http-discovery-userprofile/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php new file mode 100644 index 00000000..41f224a9 --- /dev/null +++ b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php @@ -0,0 +1,102 @@ +startServer(); + } + + protected function tearDown(): void + { + $this->stopServer(); + parent::tearDown(); + } + + abstract protected function getServerScript(): string; + + protected function getServerConnectionArgs(): array + { + return [\sprintf('http://127.0.0.1:%d', $this->serverPort)]; + } + + protected function getTransport(): string + { + return 'http'; + } + + private function startServer(): void + { + $this->serverPort = 8000 + (getmypid() % 1000); + + $this->serverProcess = new Process([ + 'php', + '-S', + \sprintf('127.0.0.1:%d', $this->serverPort), + $this->getServerScript(), + ]); + + $this->serverProcess->start(); + + $timeout = 5; // seconds + $startTime = time(); + + while (time() - $startTime < $timeout) { + if ($this->serverProcess->isRunning() && $this->isServerReady()) { + return; + } + usleep(100000); // 100ms + } + + $this->fail(\sprintf('Server failed to start on port %d within %d seconds', $this->serverPort, $timeout)); + } + + private function stopServer(): void + { + if (isset($this->serverProcess)) { + $this->serverProcess->stop(1, \SIGTERM); + } + } + + private function isServerReady(): bool + { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 1, + 'method' => 'GET', + ], + ]); + + // Try a simple health check - this will likely fail with MCP but should respond + $response = @file_get_contents(\sprintf('http://127.0.0.1:%d', $this->serverPort), false, $context); + + // We don't care about the response content, just that the server is accepting connections + return false !== $response || false === str_contains(error_get_last()['message'] ?? '', 'Connection refused'); + } + + protected function getSnapshotFilePath(string $method, ?string $testName = null): string + { + $className = substr(static::class, strrpos(static::class, '\\') + 1); + $suffix = $testName ? '-'.preg_replace('/[^a-zA-Z0-9_]/', '_', $testName) : ''; + + return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).$suffix.'.json'; + } +} diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php new file mode 100644 index 00000000..e4b137da --- /dev/null +++ b/tests/Inspector/Http/HttpSchemaShowcaseTest.php @@ -0,0 +1,116 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'format_text', + 'toolArgs' => ['text' => 'Hello World Test', 'format' => 'uppercase'], + ], + 'testName' => 'format_text', + ], + 'Calculate Range Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'calculate_range', + 'toolArgs' => ['first' => 10, 'second' => 5, 'operation' => 'multiply', 'precision' => 2], + ], + 'testName' => 'calculate_range', + ], + 'Validate Profile Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'validate_profile', + 'toolArgs' => [ + 'profile' => ['name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'role' => 'user'], + ], + ], + 'testName' => 'validate_profile', + ], + 'Manage List Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'manage_list', + 'toolArgs' => [ + 'items' => ['apple', 'banana', 'cherry', 'date'], + 'action' => 'sort', + ], + ], + 'testName' => 'manage_list', + ], + 'Generate Config Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'generate_config', + 'toolArgs' => [ + 'appName' => 'TestApp', + 'baseUrl' => 'https://example.com', + 'environment' => 'development', + 'debug' => true, + 'port' => 8080, + ], + ], + 'testName' => 'generate_config', + ], + 'Schedule Event Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Team Meeting', + 'startTime' => '2024-12-01T14:30:00Z', + 'durationHours' => 1.5, + 'priority' => 'high', + 'attendees' => ['alice@example.com', 'bob@example.com'], + ], + ], + 'testName' => 'schedule_event', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/http-schema-showcase/server.php'; + } + + protected function normalizeTestOutput(string $output, ?string $testName = null): string + { + return match ($testName) { + 'validate_profile' => preg_replace( + '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', + '2025-01-01 00:00:00', + $output + ), + 'generate_config' => preg_replace( + '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', + '2025-01-01T00:00:00+00:00', + $output + ), + 'schedule_event' => preg_replace([ + '/event_[a-f0-9]{13,}/', + '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', + ], [ + 'event_test123456789', + '2025-01-01T00:00:00+00:00', + ], $output), + default => $output, + }; + } +} diff --git a/tests/Inspector/snapshots/StdioCachedDiscoveryTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCachedDiscoveryTest-prompts_list.json rename to tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json new file mode 100644 index 00000000..2d0c6ce2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json @@ -0,0 +1,9 @@ +{ + "resources": [ + { + "name": "priority_config_discovered", + "uri": "config://priority", + "description": "A resource discovered via attributes." + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json new file mode 100644 index 00000000..053dcceb --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "config://priority", + "mimeType": "text/plain", + "text": "Discovered Priority Config: Low" + } + ] +} diff --git a/tests/Inspector/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json rename to tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json new file mode 100644 index 00000000..d849f400 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "System status: OK (discovered)" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json new file mode 100644 index 00000000..4d8cf0da --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Hello HTTP Test User, from manual registration!" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json new file mode 100644 index 00000000..04d8ea1c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json @@ -0,0 +1,28 @@ +{ + "tools": [ + { + "name": "manualGreeter", + "description": "A manually registered tool.", + "inputSchema": { + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "the user to greet" + } + }, + "required": [ + "user" + ] + } + }, + { + "name": "discovered_status_check", + "description": "A tool discovered via attributes.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/tests/Inspector/snapshots/StdioCustomDependenciesTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCustomDependenciesTest-prompts_list.json rename to tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json diff --git a/tests/Inspector/snapshots/StdioCachedDiscoveryTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCachedDiscoveryTest-resources_list.json rename to tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json diff --git a/tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_templates_list.json rename to tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json new file mode 100644 index 00000000..1e7667fa --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json new file mode 100644 index 00000000..5309d2e9 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"client@example.com\"\n ],\n \"invites_will_be_sent\": false\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json new file mode 100644 index 00000000..a9f4d35f --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Office Party\\\" scheduled successfully for \\\"2024-12-20\\\".\",\n \"event_details\": {\n \"title\": \"Office Party\",\n \"date\": \"2024-12-20\",\n \"type\": \"other\",\n \"time\": \"18:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"team@company.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json new file mode 100644 index 00000000..68c6f014 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Team Standup\\\" scheduled successfully for \\\"2024-12-01\\\".\",\n \"event_details\": {\n \"title\": \"Team Standup\",\n \"date\": \"2024-12-01\",\n \"type\": \"meeting\",\n \"time\": \"09:00\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"invites_will_be_sent\": true\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json new file mode 100644 index 00000000..5f47adca --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json @@ -0,0 +1,67 @@ +{ + "tools": [ + { + "name": "schedule_event", + "description": "Schedules a new event.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "the title of the event" + }, + "date": { + "type": "string", + "description": "the date of the event (YYYY-MM-DD)" + }, + "type": { + "type": "string", + "description": "the type of event", + "enum": [ + "meeting", + "reminder", + "call", + "other" + ] + }, + "time": { + "type": [ + "null", + "string" + ], + "description": "the time of the event (HH:MM), optional", + "default": null + }, + "priority": { + "type": "integer", + "description": "The priority of the event. Defaults to Normal.", + "default": 1, + "enum": [ + 0, + 1, + 2 + ] + }, + "attendees": { + "type": [ + "array", + "null" + ], + "description": "an optional list of attendee email addresses", + "default": null + }, + "sendInvites": { + "type": "boolean", + "description": "send calendar invites to attendees? Defaults to true if attendees are provided", + "default": true + } + }, + "required": [ + "title", + "date", + "type" + ] + } + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json new file mode 100644 index 00000000..74dff36e --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json @@ -0,0 +1,11 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Write a short, formal biography for Alice (Role: admin, Email: alice@example.com). Highlight their role within the system." + } + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json new file mode 100644 index 00000000..3b140f83 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json @@ -0,0 +1,20 @@ +{ + "prompts": [ + { + "name": "generate_bio_prompt", + "description": "Generates a prompt to write a bio for a user.", + "arguments": [ + { + "name": "userId", + "description": "the user ID to generate the bio for", + "required": true + }, + { + "name": "tone", + "description": "Desired tone (e.g., 'formal', 'casual').", + "required": false + } + ] + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json new file mode 100644 index 00000000..a9d91a86 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json @@ -0,0 +1,16 @@ +{ + "resources": [ + { + "name": "system_status", + "uri": "system://status", + "description": "Current system status and runtime information", + "mimeType": "application/json" + }, + { + "name": "user_id_list", + "uri": "user://list/ids", + "description": "Provides a list of all available user IDs.", + "mimeType": "application/json" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json new file mode 100644 index 00000000..04c7ae82 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "user://list/ids", + "mimeType": "application/json", + "text": "[\n 101,\n 102,\n 103\n]" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json new file mode 100644 index 00000000..39931e8c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "user://101/profile", + "mimeType": "application/json", + "text": "{\n \"name\": \"Alice\",\n \"email\": \"alice@example.com\",\n \"role\": \"admin\"\n}" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json new file mode 100644 index 00000000..c3e1dcf8 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "user://102/profile", + "mimeType": "application/json", + "text": "{\n \"name\": \"Bob\",\n \"email\": \"bob@example.com\",\n \"role\": \"user\"\n}" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json new file mode 100644 index 00000000..c92be4ad --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json @@ -0,0 +1,10 @@ +{ + "resourceTemplates": [ + { + "name": "user_profile", + "uriTemplate": "user://{userId}/profile", + "description": "Get profile information for a specific user ID.", + "mimeType": "application/json" + } + ] +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json new file mode 100644 index 00000000..95ed1898 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message_sent\": \"Welcome, Alice! Welcome to our platform!\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json new file mode 100644 index 00000000..cac9850a --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Test tool without params\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json new file mode 100644 index 00000000..f515430e --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json @@ -0,0 +1,58 @@ +{ + "tools": [ + { + "name": "calculator", + "description": "Perform basic math operations (add, subtract, multiply, divide)", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + }, + "operation": { + "type": "string", + "default": "add" + } + }, + "required": [ + "a", + "b" + ] + } + }, + { + "name": "send_welcome", + "description": "Sends a welcome message to a user.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user to message" + }, + "customMessage": { + "type": [ + "null", + "string" + ], + "description": "an optional custom message part", + "default": null + } + }, + "required": [ + "userId" + ] + } + }, + { + "name": "test_tool_without_params", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json rename to tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json diff --git a/tests/Inspector/snapshots/StdioEnvVariablesTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioEnvVariablesTest-resources_list.json rename to tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json rename to tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json new file mode 100644 index 00000000..817d33d9 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json new file mode 100644 index 00000000..eb9d89de --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json new file mode 100644 index 00000000..e193e9fb --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"config\": {\n \"app\": {\n \"name\": \"TestApp\",\n \"env\": \"development\",\n \"debug\": true,\n \"url\": \"https://example.com\",\n \"port\": 8080\n },\n \"generated_at\": \"2025-01-01T00:00:00+00:00\",\n \"version\": \"1.0.0\",\n \"features\": {\n \"logging\": true,\n \"caching\": false,\n \"analytics\": false,\n \"rate_limiting\": false\n }\n },\n \"validation\": {\n \"app_name_valid\": true,\n \"url_valid\": true,\n \"port_in_range\": true\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json new file mode 100644 index 00000000..25623f28 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"original_count\": 4,\n \"processed_count\": 4,\n \"action\": \"sort\",\n \"original\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"processed\": [\n \"apple\",\n \"banana\",\n \"cherry\",\n \"date\"\n ],\n \"stats\": {\n \"average_length\": 5.25,\n \"shortest\": 4,\n \"longest\": 6\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json new file mode 100644 index 00000000..924527dc --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"event\": {\n \"id\": \"event_test123456789\",\n \"title\": \"Team Meeting\",\n \"start_time\": \"2025-01-01T00:00:00+00:00\",\n \"end_time\": \"2025-01-01T00:00:00+00:00\",\n \"duration_hours\": 1.5,\n \"priority\": \"high\",\n \"attendees\": [\n \"alice@example.com\",\n \"bob@example.com\"\n ],\n \"created_at\": \"2025-01-01T00:00:00+00:00\"\n },\n \"info\": {\n \"attendee_count\": 2,\n \"is_all_day\": false,\n \"is_future\": false,\n \"timezone_note\": \"Times are in UTC\"\n }\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json new file mode 100644 index 00000000..9fe2fa53 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"valid\": true,\n \"profile\": {\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\",\n \"age\": 30,\n \"role\": \"user\"\n },\n \"errors\": [],\n \"warnings\": [],\n \"processed_at\": \"2025-01-01 00:00:00\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json new file mode 100644 index 00000000..9b9b90e7 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json @@ -0,0 +1,284 @@ +{ + "tools": [ + { + "name": "format_text", + "description": "Formats text with validation constraints. Text must be 5-100 characters and contain only letters, numbers, spaces, and basic punctuation.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to format", + "minLength": 5, + "maxLength": 100, + "pattern": "^[a-zA-Z0-9\\s\\.,!?\\-]+$" + }, + "format": { + "type": "string", + "default": "sentence", + "description": "Format style", + "enum": [ + "uppercase", + "lowercase", + "title", + "sentence" + ] + } + }, + "required": [ + "text" + ] + } + }, + { + "name": "calculate_range", + "description": "Performs mathematical operations with numeric constraints.", + "inputSchema": { + "type": "object", + "properties": { + "first": { + "type": "number", + "description": "First number (must be between 0 and 1000)", + "minimum": 0, + "maximum": 1000 + }, + "second": { + "type": "number", + "description": "Second number (must be between 0 and 1000)", + "minimum": 0, + "maximum": 1000 + }, + "operation": { + "type": "string", + "description": "Operation to perform", + "enum": [ + "add", + "subtract", + "multiply", + "divide", + "power" + ] + }, + "precision": { + "type": "integer", + "default": 2, + "description": "Decimal precision (must be multiple of 2, between 0-10)", + "minimum": 0, + "maximum": 10, + "multipleOf": 2 + } + }, + "required": [ + "first", + "second", + "operation" + ] + } + }, + { + "name": "validate_profile", + "description": "Validates and processes user profile data with strict schema requirements.", + "inputSchema": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "description": "User profile information", + "properties": { + "name": { + "type": "string", + "minLength": 2, + "maxLength": 50, + "description": "Full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "Valid email address" + }, + "age": { + "type": "integer", + "minimum": 13, + "maximum": 120, + "description": "Age in years" + }, + "role": { + "type": "string", + "enum": [ + "user", + "admin", + "moderator", + "guest" + ], + "description": "User role" + }, + "preferences": { + "type": "object", + "properties": { + "notifications": { + "type": "boolean" + }, + "theme": { + "type": "string", + "enum": [ + "light", + "dark", + "auto" + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "name", + "email", + "age" + ], + "additionalProperties": true + } + }, + "required": [ + "profile" + ] + } + }, + { + "name": "manage_list", + "description": "Manages a list of items with size and uniqueness constraints.", + "inputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "description": "List of items to manage (2-10 unique strings)", + "minItems": 2, + "maxItems": 10, + "uniqueItems": true + }, + "action": { + "type": "string", + "default": "sort", + "description": "Action to perform on the list", + "enum": [ + "sort", + "reverse", + "shuffle", + "deduplicate", + "filter_short", + "filter_long" + ] + } + }, + "required": [ + "items" + ] + } + }, + { + "name": "generate_config", + "description": "Generates configuration with format-validated inputs.", + "inputSchema": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "description": "Application name (alphanumeric with hyphens)", + "minLength": 3, + "maxLength": 20, + "pattern": "^[a-zA-Z0-9\\-]+$" + }, + "baseUrl": { + "type": "string", + "description": "Valid URL for the application", + "format": "uri" + }, + "environment": { + "type": "string", + "default": "development", + "description": "Environment type", + "enum": [ + "development", + "staging", + "production" + ] + }, + "debug": { + "type": "boolean", + "default": true, + "description": "Enable debug mode" + }, + "port": { + "type": "integer", + "default": 8080, + "description": "Port number (1024-65535)", + "minimum": 1024, + "maximum": 65535 + } + }, + "required": [ + "appName", + "baseUrl" + ] + } + }, + { + "name": "schedule_event", + "description": "Schedules an event with time validation and constraints.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Event title (3-50 characters)", + "minLength": 3, + "maxLength": 50 + }, + "startTime": { + "type": "string", + "description": "Event start time in ISO 8601 format", + "format": "date-time" + }, + "durationHours": { + "type": "number", + "description": "Duration in hours (minimum 0.5, maximum 24)", + "minimum": 0.5, + "maximum": 24, + "multipleOf": 0.5 + }, + "priority": { + "type": "string", + "default": "medium", + "description": "Event priority level", + "enum": [ + "low", + "medium", + "high", + "urgent" + ] + }, + "attendees": { + "type": "array", + "default": [], + "items": { + "type": "string", + "format": "email" + }, + "description": "List of attendee email addresses", + "maxItems": 20 + } + }, + "required": [ + "title", + "startTime", + "durationHours" + ] + } + } + ] +} diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 8fe3d96a..c8335308 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Inspector; +use Mcp\Schema\Enum\LoggingLevel; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -19,67 +20,105 @@ abstract class InspectorSnapshotTestCase extends TestCase { private const INSPECTOR_VERSION = '0.16.8'; - /** - * @param array $toolArgs - */ + /** @param array $options */ #[DataProvider('provideMethods')] - public function testMethodOutputMatchesSnapshot( + public function testOutputMatchesSnapshot( string $method, - ?string $toolName = null, - array $toolArgs = [], - ?string $uri = null, + array $options = [], + ?string $testName = null, ): void { $inspector = \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION); + $args = [ - 'npx', $inspector, '--cli', 'php', $this->getServerScript(), '--method', $method, + 'npx', + $inspector, + '--cli', + ...$this->getServerConnectionArgs(), + '--transport', + $this->getTransport(), + '--method', + $method, ]; // Options for tools/call - if (null !== $toolName) { + if (isset($options['toolName'])) { $args[] = '--tool-name'; - $args[] = $toolName; + $args[] = $options['toolName']; - foreach ($toolArgs as $key => $value) { + foreach ($options['toolArgs'] ?? [] as $key => $value) { $args[] = '--tool-arg'; - $args[] = \sprintf('%s=%s', $key, $value); + if (\is_array($value)) { + $args[] = \sprintf('%s=%s', $key, json_encode($value)); + } elseif (\is_bool($value)) { + $args[] = \sprintf('%s=%s', $key, $value ? '1' : '0'); + } else { + $args[] = \sprintf('%s=%s', $key, $value); + } } } // Options for resources/read - if (null !== $uri) { + if (isset($options['uri'])) { $args[] = '--uri'; - $args[] = $uri; + $args[] = $options['uri']; + } + + // Options for prompts/get + if (isset($options['promptName'])) { + $args[] = '--prompt-name'; + $args[] = $options['promptName']; + + foreach ($options['promptArgs'] ?? [] as $key => $value) { + $args[] = '--prompt-args'; + if (\is_array($value)) { + $args[] = \sprintf('%s=%s', $key, json_encode($value)); + } elseif (\is_bool($value)) { + $args[] = \sprintf('%s=%s', $key, $value ? '1' : '0'); + } else { + $args[] = \sprintf('%s=%s', $key, $value); + } + } + } + + // Options for logging/setLevel + if (isset($options['logLevel'])) { + $args[] = '--log-level'; + $args[] = $options['logLevel'] instanceof LoggingLevel ? $options['logLevel']->value : $options['logLevel']; + } + + // Options for env variables + if (isset($options['envVars'])) { + foreach ($options['envVars'] as $key => $value) { + $args[] = '-e'; + $args[] = \sprintf('%s=%s', $key, $value); + } } - $output = (new Process($args)) + $output = (new Process(command: $args)) ->mustRun() ->getOutput(); - $snapshotFile = $this->getSnapshotFilePath($method); + $snapshotFile = $this->getSnapshotFilePath($method, $testName); + + $normalizedOutput = $this->normalizeTestOutput($output, $testName); if (!file_exists($snapshotFile)) { - file_put_contents($snapshotFile, $output.\PHP_EOL); + file_put_contents($snapshotFile, $normalizedOutput.\PHP_EOL); $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); } $expected = file_get_contents($snapshotFile); - $this->assertJsonStringEqualsJsonString($expected, $output); + $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput); } - /** - * List of methods to test. - * - * @return array - */ - abstract public static function provideMethods(): array; - - abstract protected function getServerScript(): string; + protected function normalizeTestOutput(string $output, ?string $testName = null): string + { + return $output; + } - /** - * @return array - */ - protected static function provideListMethods(): array + /** @return array> */ + public static function provideMethods(): array { return [ 'Prompt Listing' => ['method' => 'prompts/list'], @@ -89,10 +128,10 @@ protected static function provideListMethods(): array ]; } - private function getSnapshotFilePath(string $method): string - { - $className = substr(static::class, strrpos(static::class, '\\') + 1); + abstract protected function getSnapshotFilePath(string $method, ?string $testName = null): string; - return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).'.json'; - } + /** @return array */ + abstract protected function getServerConnectionArgs(): array; + + abstract protected function getTransport(): string; } diff --git a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php new file mode 100644 index 00000000..ede61ed3 --- /dev/null +++ b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php @@ -0,0 +1,93 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'add_numbers', + 'toolArgs' => ['a' => 5, 'b' => 3], + ], + 'testName' => 'add_numbers', + ], + 'Add Numbers (Negative)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'add_numbers', + 'toolArgs' => ['a' => -10, 'b' => 7], + ], + 'testName' => 'add_numbers_negative', + ], + 'Multiply Numbers Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'multiply_numbers', + 'toolArgs' => ['a' => 4, 'b' => 6], + ], + 'testName' => 'multiply_numbers', + ], + 'Multiply Numbers (Zero)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'multiply_numbers', + 'toolArgs' => ['a' => 15, 'b' => 0], + ], + 'testName' => 'multiply_numbers_zero', + ], + 'Divide Numbers Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'divide_numbers', + 'toolArgs' => ['a' => 20, 'b' => 4], + ], + 'testName' => 'divide_numbers', + ], + 'Divide Numbers (Decimal Result)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'divide_numbers', + 'toolArgs' => ['a' => 7, 'b' => 2], + ], + 'testName' => 'divide_numbers_decimal', + ], + 'Power Tool' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'power', + 'toolArgs' => ['base' => 2, 'exponent' => 8], + ], + 'testName' => 'power', + ], + 'Power Tool (Zero Exponent)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'power', + 'toolArgs' => ['base' => 5, 'exponent' => 0], + ], + 'testName' => 'power_zero_exponent', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/stdio-cached-discovery/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php new file mode 100644 index 00000000..e4285e27 --- /dev/null +++ b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php @@ -0,0 +1,77 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'add_task', + 'toolArgs' => ['userId' => 'alice', 'description' => 'Complete the project documentation'], + ], + 'testName' => 'add_task', + ], + 'List User Tasks' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'list_user_tasks', + 'toolArgs' => ['userId' => 'alice'], + ], + 'testName' => 'list_user_tasks', + ], + 'Complete Task' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'complete_task', + 'toolArgs' => ['taskId' => 1], + ], + 'testName' => 'complete_task', + ], + 'Read System Statistics Resource' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'stats://system/overview', + ], + 'testName' => 'read_system_stats', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/stdio-custom-dependencies/server.php'; + } + + protected function normalizeTestOutput(string $output, ?string $testName = null): string + { + return match ($testName) { + 'add_task' => preg_replace( + '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}/', + '2025-01-01T00:00:00+00:00', + $output + ), + 'read_system_stats' => preg_replace( + '/\\\\"server_uptime_seconds\\\\": -?\d+\.?\d*/', + '\\"server_uptime_seconds\\": 12345', + $output + ), + default => $output, + }; + } +} diff --git a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php new file mode 100644 index 00000000..8a62b2c6 --- /dev/null +++ b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php @@ -0,0 +1,50 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'calculate', + 'toolArgs' => ['a' => 12.5, 'b' => 7.3, 'operation' => 'add'], + ], + 'testName' => 'calculate_sum', + ], + 'Update Setting' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'update_setting', + 'toolArgs' => ['setting' => 'precision', 'value' => 3], + ], + 'testName' => 'update_setting', + ], + 'Read Config' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'config://calculator/settings', + ], + 'testName' => 'read_config', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/stdio-discovery-calculator/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php new file mode 100644 index 00000000..97601d1a --- /dev/null +++ b/tests/Inspector/Stdio/StdioEnvVariablesTest.php @@ -0,0 +1,55 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'process_data_by_mode', + 'toolArgs' => ['input' => 'test data'], + ], + 'testName' => 'process_data_default', + ], + 'Process Data (Debug Mode)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'process_data_by_mode', + 'toolArgs' => ['input' => 'debug test'], + 'envVars' => ['APP_MODE' => 'debug'], + ], + 'testName' => 'process_data_debug', + ], + 'Process Data (Production Mode)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'process_data_by_mode', + 'toolArgs' => ['input' => 'production data'], + 'envVars' => ['APP_MODE' => 'production'], + ], + 'testName' => 'process_data_production', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/stdio-env-variables/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php new file mode 100644 index 00000000..103d3e67 --- /dev/null +++ b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php @@ -0,0 +1,82 @@ + [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'echo_text', + 'toolArgs' => ['text' => 'Hello World!'], + ], + 'testName' => 'echo_text', + ], + 'Echo Tool with Special Characters' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'echo_text', + 'toolArgs' => ['text' => 'Test with emoji 🎉 and symbols @#$%'], + ], + 'testName' => 'echo_text_special_chars', + ], + 'Read App Version Resource' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'app://version', + ], + 'testName' => 'read_app_version', + ], + 'Read Item Details (123)' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'item://123/details', + ], + 'testName' => 'read_item_123_details', + ], + 'Read Item Details (ABC)' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'item://ABC/details', + ], + 'testName' => 'read_item_ABC_details', + ], + 'Personalized Greeting Prompt (Alice)' => [ + 'method' => 'prompts/get', + 'options' => [ + 'promptName' => 'personalized_greeting', + 'promptArgs' => ['userName' => 'Alice'], + ], + 'testName' => 'personalized_greeting_alice', + ], + 'Personalized Greeting Prompt (Bob)' => [ + 'method' => 'prompts/get', + 'options' => [ + 'promptName' => 'personalized_greeting', + 'promptArgs' => ['userName' => 'Bob'], + ], + 'testName' => 'personalized_greeting_bob', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/stdio-explicit-registration/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php b/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php new file mode 100644 index 00000000..a88896c3 --- /dev/null +++ b/tests/Inspector/Stdio/StdioInspectorSnapshotTestCase.php @@ -0,0 +1,37 @@ +getServerScript()]; + } + + protected function getTransport(): string + { + return 'stdio'; + } + + protected function getSnapshotFilePath(string $method, ?string $testName = null): string + { + $className = substr(static::class, strrpos(static::class, '\\') + 1); + $suffix = $testName ? '-'.preg_replace('/[^a-zA-Z0-9_]/', '_', $testName) : ''; + + return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).$suffix.'.json'; + } +} diff --git a/tests/Inspector/snapshots/StdioEnvVariablesTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioEnvVariablesTest-prompts_list.json rename to tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json new file mode 100644 index 00000000..d02ef58d --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json @@ -0,0 +1,3 @@ +{ + "resources": [] +} diff --git a/tests/Inspector/snapshots/StdioEnvVariablesTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioEnvVariablesTest-resources_templates_list.json rename to tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json new file mode 100644 index 00000000..3bb28b3d --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "8" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json new file mode 100644 index 00000000..2a25b87b --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "-3" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json new file mode 100644 index 00000000..957d6df4 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "5" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json new file mode 100644 index 00000000..1ae0005d --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "3.5" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json new file mode 100644 index 00000000..b391c653 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "24" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json new file mode 100644 index 00000000..04988535 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "0" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json new file mode 100644 index 00000000..d4289e41 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "256" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json new file mode 100644 index 00000000..5088e95f --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "1" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioCachedDiscoveryTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCachedDiscoveryTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCustomDependenciesTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json new file mode 100644 index 00000000..bdea849a --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "stats://system/overview", + "mimeType": "application/json", + "text": "{\n \"total_tasks\": 3,\n \"completed_tasks\": 0,\n \"pending_tasks\": 3,\n \"server_uptime_seconds\": 12345\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json new file mode 100644 index 00000000..9ded3d2a --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"id\": 4,\n \"userId\": \"alice\",\n \"description\": \"Complete the project documentation\",\n \"completed\": false,\n \"createdAt\": \"2025-01-01T00:00:00+00:00\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json new file mode 100644 index 00000000..3d852eda --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Task 1 completed.\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json new file mode 100644 index 00000000..6fac3026 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "[]" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioCustomDependenciesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCustomDependenciesTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_read.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-resources_read.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json new file mode 100644 index 00000000..c15d9a8e --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "config://calculator/settings", + "mimeType": "application/json", + "text": "{\n \"precision\": 2,\n \"allow_negative\": true\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-tools_call.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json new file mode 100644 index 00000000..37b42155 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Precision updated to 3.\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json new file mode 100644 index 00000000..a73c8b94 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "19.8" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioDiscoveryCalculatorTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json new file mode 100644 index 00000000..d02ef58d --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json @@ -0,0 +1,3 @@ +{ + "resources": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json new file mode 100644 index 00000000..3b11d407 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"mode\": \"debug\",\n \"processed_input\": \"DEBUG TEST\",\n \"message\": \"Processed in DEBUG mode.\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json new file mode 100644 index 00000000..fde189ee --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"mode\": \"default\",\n \"original_input\": \"test data\",\n \"message\": \"Processed in default mode (APP_MODE not recognized or not set).\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json new file mode 100644 index 00000000..dd4cd9dc --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"mode\": \"production\",\n \"processed_input_length\": 15,\n \"message\": \"Processed in PRODUCTION mode (summary only).\"\n}" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioEnvVariablesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioEnvVariablesTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json new file mode 100644 index 00000000..b5777fa4 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json @@ -0,0 +1,11 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Craft a personalized greeting for Alice." + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json new file mode 100644 index 00000000..e432a6f4 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json @@ -0,0 +1,11 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Craft a personalized greeting for Bob." + } + } + ] +} diff --git a/tests/Inspector/snapshots/StdioExplicitRegistrationTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioExplicitRegistrationTest-prompts_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json diff --git a/tests/Inspector/snapshots/StdioExplicitRegistrationTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioExplicitRegistrationTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json new file mode 100644 index 00000000..1547bdaa --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "app://version", + "mimeType": "text/plain", + "text": "1.0-manual" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json new file mode 100644 index 00000000..d3eca519 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "item://123/details", + "mimeType": "application/json", + "text": "{\n \"id\": \"123\",\n \"name\": \"Item 123\",\n \"description\": \"Details for item 123 from manual template.\"\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json new file mode 100644 index 00000000..6a2dd65f --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "item://ABC/details", + "mimeType": "application/json", + "text": "{\n \"id\": \"ABC\",\n \"name\": \"Item ABC\",\n \"description\": \"Details for item ABC from manual template.\"\n}" + } + ] +} diff --git a/tests/Inspector/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json new file mode 100644 index 00000000..9bad1e77 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Echo: Hello World!" + } + ], + "isError": false +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json new file mode 100644 index 00000000..bf4fc3c7 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Echo: Test with emoji 🎉 and symbols @#$%" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioExplicitRegistrationTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioExplicitRegistrationTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json diff --git a/tests/Inspector/StdioCachedDiscoveryTest.php b/tests/Inspector/StdioCachedDiscoveryTest.php deleted file mode 100644 index dfc8acec..00000000 --- a/tests/Inspector/StdioCachedDiscoveryTest.php +++ /dev/null @@ -1,29 +0,0 @@ - [ - 'method' => 'tools/call', - 'toolName' => 'calculate', - 'toolArgs' => ['a' => 12.5, 'b' => 7.3, 'operation' => 'add'], - ], - 'Read Config' => [ - 'method' => 'resources/read', - 'toolName' => null, // can be removed with newer PHPUnit versions - 'toolArgs' => [], // can be removed with newer PHPUnit versions - 'uri' => 'config://calculator/settings', - ], - ]; - } - - protected function getServerScript(): string - { - return \dirname(__DIR__, 2).'/examples/stdio-discovery-calculator/server.php'; - } -} diff --git a/tests/Inspector/StdioEnvVariablesTest.php b/tests/Inspector/StdioEnvVariablesTest.php deleted file mode 100644 index d347cd69..00000000 --- a/tests/Inspector/StdioEnvVariablesTest.php +++ /dev/null @@ -1,29 +0,0 @@ - Date: Fri, 31 Oct 2025 21:02:54 +0100 Subject: [PATCH 50/66] fix(server): consistent arg name in session store impl (#127) --- src/Server/Session/FileSessionStore.php | 12 ++++++------ src/Server/Session/InMemorySessionStore.php | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 217d1eb4..0a7b7cd4 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -50,9 +50,9 @@ public function exists(Uuid $id): bool return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; } - public function read(Uuid $sessionId): string|false + public function read(Uuid $id): string|false { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); if (!is_file($path)) { return false; @@ -73,9 +73,9 @@ public function read(Uuid $sessionId): string|false return $data; } - public function write(Uuid $sessionId, string $data): bool + public function write(Uuid $id, string $data): bool { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); $tmp = $path.'.tmp'; if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { @@ -98,9 +98,9 @@ public function write(Uuid $sessionId, string $data): bool return true; } - public function destroy(Uuid $sessionId): bool + public function destroy(Uuid $id): bool { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); if (is_file($path)) { @unlink($path); diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index 4051ba76..9f8077c6 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -35,9 +35,9 @@ public function exists(Uuid $id): bool return isset($this->store[$id->toRfc4122()]); } - public function read(Uuid $sessionId): string|false + public function read(Uuid $id): string|false { - $session = $this->store[$sessionId->toRfc4122()] ?? ''; + $session = $this->store[$id->toRfc4122()] ?? ''; if ('' === $session) { return false; } @@ -45,7 +45,7 @@ public function read(Uuid $sessionId): string|false $currentTimestamp = $this->clock->now()->getTimestamp(); if ($currentTimestamp - $session['timestamp'] > $this->ttl) { - unset($this->store[$sessionId->toRfc4122()]); + unset($this->store[$id->toRfc4122()]); return false; } @@ -53,9 +53,9 @@ public function read(Uuid $sessionId): string|false return $session['data']; } - public function write(Uuid $sessionId, string $data): bool + public function write(Uuid $id, string $data): bool { - $this->store[$sessionId->toRfc4122()] = [ + $this->store[$id->toRfc4122()] = [ 'data' => $data, 'timestamp' => $this->clock->now()->getTimestamp(), ]; @@ -63,10 +63,10 @@ public function write(Uuid $sessionId, string $data): bool return true; } - public function destroy(Uuid $sessionId): bool + public function destroy(Uuid $id): bool { - if (isset($this->store[$sessionId->toRfc4122()])) { - unset($this->store[$sessionId->toRfc4122()]); + if (isset($this->store[$id->toRfc4122()])) { + unset($this->store[$id->toRfc4122()]); } return true; From fb3c1e65a60838cbaeb7d25398dafb481ef0ae52 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 1 Nov 2025 09:02:24 +0100 Subject: [PATCH 51/66] Enable servers to send sampling messages to clients (#101) --- README.md | 1 + composer.json | 1 + docs/client-communication.md | 101 ++++++++++++++ docs/examples.md | 10 ++ examples/http-client-communication/server.php | 11 +- .../ClientAwareService.php | 71 ++++++++++ .../stdio-client-communication/server.php | 56 +------- src/Capability/Registry/ReferenceHandler.php | 21 ++- src/Exception/ClientException.php | 33 +++++ src/Schema/Content/SamplingMessage.php | 2 +- src/Schema/Enum/SamplingContext.php | 19 +++ .../Request/CreateSamplingMessageRequest.php | 27 ++-- src/Server/ClientAwareInterface.php | 17 +++ src/Server/ClientAwareTrait.php | 52 ++++++++ src/Server/ClientGateway.php | 124 +++++++++--------- .../CreateSamplingMessageRequestTest.php | 51 +++++++ 16 files changed, 460 insertions(+), 137 deletions(-) create mode 100644 docs/client-communication.md create mode 100644 examples/stdio-client-communication/ClientAwareService.php create mode 100644 src/Exception/ClientException.php create mode 100644 src/Schema/Enum/SamplingContext.php create mode 100644 src/Server/ClientAwareInterface.php create mode 100644 src/Server/ClientAwareTrait.php create mode 100644 tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php diff --git a/README.md b/README.md index 394cd37c..52469357 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ $server = Server::builder() - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage - [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts +- [Client Communiocation](docs/client-communication.md) - Communicating back to the client from server-side **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs diff --git a/composer.json b/composer.json index dfb304e7..30a94724 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", + "Mcp\\Example\\StdioClientCommunication\\": "examples/stdio-client-communication/", "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", diff --git a/docs/client-communication.md b/docs/client-communication.md new file mode 100644 index 00000000..8da4bc65 --- /dev/null +++ b/docs/client-communication.md @@ -0,0 +1,101 @@ +# Client Communication + +MCP supports various ways a server can communicate back to a server on top of the main request-response flow. + +## Table of Contents + +- [ClientGateway](#client-gateway) +- [Sampling](#sampling) +- [Logging](#logging) +- [Notification](#notification) +- [Progress](#progress) + +## ClientGateway + +Every communication back to client is handled using the `Mcp\Server\ClientGateway` and its dedicated methods per +operation. To use the `ClientGateway` in your code, there are two ways to do so: + +### 1. Method Argument Injection + +Every refernce of a MCP element, that translates to an actual method call, can just add an type-hinted argument for the +`ClientGateway` and the SDK will take care to include the gateway in the arguments of the method call: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\ClientGateway; + +class MyService +{ + #[McpTool('my_tool', 'My Tool Description')] + public function myTool(ClientGateway $client): string + { + $client->log(...); +``` + +### 2. Implementing `ClientAwareInterface` + +Whenever a service class of an MCP element implements the interface `Mcp\Server\ClientAwareInterface` the `setClient` +method of that class will get called while handling the reference, and in combination with `Mcp\Server\ClientAwareTrait` +this ends up with code like this: + +```php +use Mcp\Capability\Attribute\McpTool; +use Mcp\Server\ClientAwareInterface; +use Mcp\Server\ClientAwareTrait; + +class MyService implements ClientAwareInterface +{ + use ClientAwareTrait; + + #[McpTool('my_tool', 'My Tool Description')] + public function myTool(): string + { + $this->log(...); +``` + +## Sampling + +With [sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) servers can request clients to +execute "completions" or "generations" with a language model for them: + +```php +$result = $clientGateway->sample('Roses are red, violets are', 350, 90, ['temperature' => 0.5]); +``` + +The `sample` method accepts four arguments: + +1. `message`, which is **required** and accepts a string, an instance of `Content` or an array of `SampleMessage` instances. +2. `maxTokens`, which defaults to `1000` +3. `timeout` in seconds, which defaults to `120` +4. `options` which might include `system_prompt`, `preferences` for model choice, `includeContext`, `temperature`, `stopSequences` and `metadata` + +[Find more details to sampling payload in the specification.](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#protocol-messages) + +## Logging + +The [Logging](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging) utility enables servers +to send structured log messages as notifcation to clients: + +```php +use Mcp\Schema\Enum\LoggingLevel; + +$clientGateway->log(LoggingLevel::Warning, 'The end is near.'); +``` + +## Progress + +With a [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress#progress) +notification a server can update a client while an operation is ongoing: + +```php +$clientGateway->progress(4.2, 10, 'Downloading needed images.'); +``` + +## Notification + +Lastly, the server can push all kind of notifications, that implement the `Mcp\Schema\JsonRpc\Notification` interface +to the client to: + +```php +$clientGateway->notify($yourNotification); +``` diff --git a/docs/examples.md b/docs/examples.md index 77fdc1f1..a242b9a8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -163,6 +163,16 @@ $server = Server::builder() ->setDiscovery(__DIR__, ['.'], [], $cache) ``` +### Client Communication + +**File**: `examples/stdio-client-communication/` + +**What it demostrates:** +- Server initiated communcation back to the client +- Logging, sampling, progress and notifications +- Using `ClientGateway` in service class via `ClientAwareInterface` and corresponding trait +- Using `ClientGateway` in tool method via method argument injection + ## HTTP Examples ### Discovery User Profile diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php index 8191a8e2..12f18eec 100644 --- a/examples/http-client-communication/server.php +++ b/examples/http-client-communication/server.php @@ -14,10 +14,8 @@ use Http\Discovery\Psr17Factory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Exception\ToolCallException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; -use Mcp\Schema\JsonRpc\Error as JsonRpcError; use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\ClientGateway; @@ -57,18 +55,13 @@ function (string $projectName, array $milestones, ClientGateway $client): array implode(', ', $milestones) ); - $response = $client->sample( - prompt: $prompt, + $result = $client->sample( + message: $prompt, maxTokens: 400, timeout: 90, options: ['temperature' => 0.4] ); - if ($response instanceof JsonRpcError) { - throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); - } - - $result = $response->result; $content = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; $client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.'); diff --git a/examples/stdio-client-communication/ClientAwareService.php b/examples/stdio-client-communication/ClientAwareService.php new file mode 100644 index 00000000..ebb797aa --- /dev/null +++ b/examples/stdio-client-communication/ClientAwareService.php @@ -0,0 +1,71 @@ +logger->info('SamplingTool instantiated for sampling example.'); + } + + /** + * @return array{incident: string, recommended_actions: string, model: string} + */ + #[McpTool('coordinate_incident_response', 'Coordinate an incident response with logging, progress, and sampling.')] + public function coordinateIncident(string $incidentTitle): array + { + $this->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); + + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; + + foreach ($steps as $index => $step) { + $progress = ($index + 1) / \count($steps); + + $this->progress($progress, 1, $step); + + usleep(180_000); // Simulate work being done + } + + $prompt = \sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + $result = $this->sample($prompt, 350, 90, ['temperature' => 0.5]); + + $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; + + $this->log(LoggingLevel::Info, \sprintf('Incident triage completed for %s', $incidentTitle)); + + return [ + 'incident' => $incidentTitle, + 'recommended_actions' => $recommendation, + 'model' => $result->model, + ]; + } +} diff --git a/examples/stdio-client-communication/server.php b/examples/stdio-client-communication/server.php index 96d2f052..596da5ad 100644 --- a/examples/stdio-client-communication/server.php +++ b/examples/stdio-client-communication/server.php @@ -13,70 +13,18 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; -use Mcp\Schema\JsonRpc\Error as JsonRpcError; use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\ClientGateway; use Mcp\Server\Transport\StdioTransport; -$capabilities = new ServerCapabilities(logging: true, tools: true); - $server = Server::builder() ->setServerInfo('STDIO Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) - ->setCapabilities($capabilities) - ->addTool( - function (string $incidentTitle, ClientGateway $client): array { - $client->log(LoggingLevel::Warning, sprintf('Incident triage started: %s', $incidentTitle)); - - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / count($steps); - - $client->progress(progress: $progress, total: 1, message: $step); - - usleep(180_000); // Simulate work being done - } - - $prompt = sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); - - $sampling = $client->sample( - prompt: $prompt, - maxTokens: 350, - timeout: 90, - options: ['temperature' => 0.5] - ); - - if ($sampling instanceof JsonRpcError) { - throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $sampling->code, $sampling->message)); - } - - $result = $sampling->result; - $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; - - $client->log(LoggingLevel::Info, sprintf('Incident triage completed for %s', $incidentTitle)); - - return [ - 'incident' => $incidentTitle, - 'recommended_actions' => $recommendation, - 'model' => $result->model, - ]; - }, - name: 'coordinate_incident_response', - description: 'Coordinate an incident response with logging, progress, and sampling.' - ) + ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) + ->setDiscovery(__DIR__) ->addTool( function (string $dataset, ClientGateway $client): array { $client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset)); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index e3eb925f..7ce8c737 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,7 +13,9 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; +use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** @@ -31,12 +33,18 @@ public function __construct( */ public function handle(ElementReference $reference, array $arguments): mixed { + $session = $arguments['_session']; + if (\is_string($reference->handler)) { if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { $reflection = new \ReflectionMethod($reference->handler, '__invoke'); $instance = $this->getClassInstance($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + return \call_user_func($instance, ...$arguments); } @@ -49,7 +57,7 @@ public function handle(ElementReference $reference, array $arguments): mixed } if (\is_callable($reference->handler)) { - $reflection = $this->getReflectionForCallable($reference->handler); + $reflection = $this->getReflectionForCallable($reference->handler, $session); $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func($reference->handler, ...$arguments); @@ -59,6 +67,11 @@ public function handle(ElementReference $reference, array $arguments): mixed [$className, $methodName] = $reference->handler; $reflection = new \ReflectionMethod($className, $methodName); $instance = $this->getClassInstance($className); + + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func([$instance, $methodName], ...$arguments); @@ -130,7 +143,7 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ - private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction + private function getReflectionForCallable(callable $handler, SessionInterface $session): \ReflectionMethod|\ReflectionFunction { if (\is_string($handler)) { return new \ReflectionFunction($handler); @@ -143,6 +156,10 @@ private function getReflectionForCallable(callable $handler): \ReflectionMethod| if (\is_array($handler) && 2 === \count($handler)) { [$class, $method] = $handler; + if ($class instanceof ClientAwareInterface) { + $class->setClient(new ClientGateway($session)); + } + return new \ReflectionMethod($class, $method); } diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php new file mode 100644 index 00000000..f77394ff --- /dev/null +++ b/src/Exception/ClientException.php @@ -0,0 +1,33 @@ + + */ +class ClientException extends Exception +{ + public function __construct( + private readonly Error $error, + ) { + parent::__construct($error->message); + } + + public function getError(): Error + { + return $this->error; + } +} diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index b34835dd..48aaa713 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -18,7 +18,7 @@ * Describes a message issued to or received from an LLM API during sampling. * * @phpstan-type SamplingMessageData = array{ - * role: string, + * role: 'user'|'assistant', * content: TextContent|ImageContent|AudioContent * } * diff --git a/src/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php new file mode 100644 index 00000000..4c1f1851 --- /dev/null +++ b/src/Schema/Enum/SamplingContext.php @@ -0,0 +1,19 @@ + $metadata Optional metadata to pass through to the LLM provider. The format of - * this metadata is provider-specific. + * Allowed values: "none", "thisServer", "allServers" + * @param ?float $temperature The temperature to use for sampling. The client MAY ignore this request. + * @param ?string[] $stopSequences A list of sequences to stop sampling at. The client MAY ignore this request. + * @param ?array $metadata Optional metadata to pass through to the LLM provider. The format of + * this metadata is provider-specific. */ public function __construct( public readonly array $messages, public readonly int $maxTokens, public readonly ?ModelPreferences $preferences = null, public readonly ?string $systemPrompt = null, - public readonly ?string $includeContext = null, + public readonly ?SamplingContext $includeContext = null, public readonly ?float $temperature = null, public readonly ?array $stopSequences = null, public readonly ?array $metadata = null, ) { + foreach ($this->messages as $message) { + if (!$message instanceof SamplingMessage) { + throw new InvalidArgumentException('Messages must be instance of SamplingMessage.'); + } + } } public static function getMethod(): string @@ -114,7 +119,7 @@ protected function getParams(): array } if (null !== $this->includeContext) { - $params['includeContext'] = $this->includeContext; + $params['includeContext'] = $this->includeContext->value; } if (null !== $this->temperature) { diff --git a/src/Server/ClientAwareInterface.php b/src/Server/ClientAwareInterface.php new file mode 100644 index 00000000..86c8c2ef --- /dev/null +++ b/src/Server/ClientAwareInterface.php @@ -0,0 +1,17 @@ +client = $client; + } + + private function notify(Notification $notification): void + { + $this->client->notify($notification); + } + + private function log(LoggingLevel $level, mixed $data, ?string $logger = null): void + { + $this->client->log($level, $data, $logger); + } + + private function progress(float $progress, ?float $total = null, ?string $message = null): void + { + $this->client->progress($progress, $total, $message); + } + + /** + * @param SampleOptions $options + */ + private function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult + { + return $this->client->sample($prompt, $maxTokens, $timeout, $options); + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 7e95fe9a..8179aee2 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -11,10 +11,17 @@ namespace Mcp\Server; +use Mcp\Exception\ClientException; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\RuntimeException; +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -46,6 +53,15 @@ * } * ``` * + * @phpstan-type SampleOptions array{ + * preferences?: ModelPreferences, + * systemPrompt?: string, + * temperature?: float, + * includeContext?: SamplingContext, + * stopSequences?: string[], + * metadata?: array, + * } + * * @author Kyrian Obikwelu */ final class ClientGateway @@ -95,89 +111,77 @@ public function progress(float $progress, ?float $total = null, ?string $message } /** - * Send a request to the client and wait for a response (blocking). + * Convenience method for LLM sampling requests. * - * This suspends the Fiber and waits for the client to respond. The transport - * handles polling the session for the response and resuming the Fiber when ready. + * @param SamplingMessage[]|TextContent|AudioContent|ImageContent|string $message The message for the LLM + * @param int $maxTokens Maximum tokens to generate + * @param int $timeout The timeout in seconds + * @param SampleOptions $options Additional sampling options (temperature, etc.) * - * @param Request $request The request to send - * @param int $timeout Maximum time to wait for response (seconds) + * @return CreateSamplingMessageResult The sampling response * - * @return Response>|Error The client's response message - * - * @throws \RuntimeException If Fiber support is not available + * @throws ClientException if the client request results in an error message */ - public function request(Request $request, int $timeout = 120): Response|Error + public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult { - $response = \Fiber::suspend([ - 'type' => 'request', - 'request' => $request, - 'session_id' => $this->session->getId()->toRfc4122(), - 'timeout' => $timeout, - ]); + $preferences = $options['preferences'] ?? null; + if (null !== $preferences && !$preferences instanceof ModelPreferences) { + throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + } - if (!$response instanceof Response && !$response instanceof Error) { - throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); + if (\is_string($message)) { + $message = new TextContent($message); + } + if (\is_object($message) && \in_array($message::class, [TextContent::class, AudioContent::class, ImageContent::class], true)) { + $message = [new SamplingMessage(Role::User, $message)]; } - return $response; - } + $request = new CreateSamplingMessageRequest( + messages: $message, + maxTokens: $maxTokens, + preferences: $preferences, + systemPrompt: $options['systemPrompt'] ?? null, + includeContext: $options['includeContext'] ?? null, + temperature: $options['temperature'] ?? null, + stopSequences: $options['stopSequences'] ?? null, + metadata: $options['metadata'] ?? null, + ); - /** - * Create and send an LLM sampling requests. - * - * @param CreateSamplingMessageRequest $request The request to send - * @param int $timeout The timeout in seconds - * - * @return Response|Error The sampling response - */ - public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error - { $response = $this->request($request, $timeout); if ($response instanceof Error) { - return $response; + throw new ClientException($response); } - $result = CreateSamplingMessageResult::fromArray($response->result); - - return new Response($response->getId(), $result); + return CreateSamplingMessageResult::fromArray($response->result); } /** - * Convenience method for LLM sampling requests. + * Send a request to the client and wait for a response (blocking). * - * @param string $prompt The prompt for the LLM - * @param int $maxTokens Maximum tokens to generate - * @param int $timeout The timeout in seconds - * @param array $options Additional sampling options (temperature, etc.) + * This suspends the Fiber and waits for the client to respond. The transport + * handles polling the session for the response and resuming the Fiber when ready. * - * @return Response|Error The sampling response + * @param Request $request The request to send + * @param int $timeout Maximum time to wait for response (seconds) + * + * @return Response>|Error The client's response message + * + * @throws RuntimeException If Fiber support is not available */ - public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error + private function request(Request $request, int $timeout = 120): Response|Error { - $preferences = $options['preferences'] ?? null; - if (\is_array($preferences)) { - $preferences = ModelPreferences::fromArray($preferences); - } + $response = \Fiber::suspend([ + 'type' => 'request', + 'request' => $request, + 'session_id' => $this->session->getId()->toRfc4122(), + 'timeout' => $timeout, + ]); - if (null !== $preferences && !$preferences instanceof ModelPreferences) { - throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + if (!$response instanceof Response && !$response instanceof Error) { + throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); } - $samplingRequest = new CreateSamplingMessageRequest( - messages: [ - new SamplingMessage(Role::User, new TextContent(text: $prompt)), - ], - maxTokens: $maxTokens, - preferences: $preferences, - systemPrompt: $options['systemPrompt'] ?? null, - includeContext: $options['includeContext'] ?? null, - temperature: $options['temperature'] ?? null, - stopSequences: $options['stopSequences'] ?? null, - metadata: $options['metadata'] ?? null, - ); - - return $this->createMessage($samplingRequest, $timeout); + return $response; } } diff --git a/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php new file mode 100644 index 00000000..77ea7945 --- /dev/null +++ b/tests/Unit/Schema/Request/CreateSamplingMessageRequestTest.php @@ -0,0 +1,51 @@ +assertCount(3, $request->messages); + $this->assertSame(150, $request->maxTokens); + } + + public function testConstructorWithInvalidSetOfMessages() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Messages must be instance of SamplingMessage.'); + + $messages = [ + new SamplingMessage(Role::User, new TextContent('My name is George.')), + new SamplingMessage(Role::Assistant, new TextContent('Hi George, nice to meet you!')), + new TextContent('What is my name?'), + ]; + + /* @phpstan-ignore argument.type */ + new CreateSamplingMessageRequest($messages, 150); + } +} From ff26b4ff61b8f5d4a6e6649a7c0a9eb6e1a2a7b1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 2 Nov 2025 18:07:43 +0100 Subject: [PATCH 52/66] refactor: transport agnostic examples (#131) --- composer.json | 20 +-- docs/examples.md | 53 ++++---- examples/README.md | 13 +- examples/bootstrap.php | 31 +++++ .../CachedCalculatorElements.php | 2 +- .../server.php | 9 +- .../ClientAwareService.php | 2 +- .../server.php | 11 +- .../DiscoveredElements.php | 2 +- .../ManualHandlers.php | 2 +- .../server.php | 14 +-- .../McpEventScheduler.php | 6 +- .../Model/EventPriority.php | 2 +- .../Model/EventType.php | 2 +- .../server.php | 12 +- .../McpTaskHandlers.php | 6 +- .../Service/InMemoryTaskRepository.php | 2 +- .../Service/StatsServiceInterface.php | 2 +- .../Service/SystemStatsService.php | 2 +- .../Service/TaskRepositoryInterface.php | 2 +- .../server.php | 21 ++-- examples/custom-method-handlers/server.php | 13 +- .../McpElements.php | 2 +- .../server.php | 13 +- .../McpElements.php | 4 +- .../UserIdCompletionProvider.php | 2 +- .../server.php | 12 +- .../EnvToolHandler.php | 2 +- .../server.php | 13 +- .../SimpleHandlers.php | 2 +- .../server.php | 13 +- examples/http-client-communication/server.php | 116 ------------------ .../SchemaShowcaseElements.php | 2 +- .../server.php | 12 +- src/Server/Transport/TransportInterface.php | 2 +- .../Http/HttpClientCommunicationTest.php | 2 +- .../Http/HttpCombinedRegistrationTest.php | 2 +- .../Http/HttpComplexToolSchemaTest.php | 2 +- .../Http/HttpDiscoveryUserProfileTest.php | 2 +- .../Http/HttpInspectorSnapshotTestCase.php | 2 - .../Inspector/Http/HttpSchemaShowcaseTest.php | 2 +- .../Stdio/StdioCachedDiscoveryTest.php | 2 +- .../Stdio/StdioCustomDependenciesTest.php | 2 +- .../Stdio/StdioDiscoveryCalculatorTest.php | 2 +- .../Inspector/Stdio/StdioEnvVariablesTest.php | 2 +- .../Stdio/StdioExplicitRegistrationTest.php | 2 +- 46 files changed, 157 insertions(+), 289 deletions(-) rename examples/{stdio-cached-discovery => cached-discovery}/CachedCalculatorElements.php (96%) rename examples/{stdio-cached-discovery => cached-discovery}/server.php (85%) rename examples/{stdio-client-communication => client-communication}/ClientAwareService.php (97%) rename examples/{stdio-client-communication => client-communication}/server.php (89%) rename examples/{http-combined-registration => combined-registration}/DiscoveredElements.php (95%) rename examples/{http-combined-registration => combined-registration}/ManualHandlers.php (95%) rename examples/{http-combined-registration => combined-registration}/server.php (67%) rename examples/{http-complex-tool-schema => complex-tool-schema}/McpEventScheduler.php (93%) rename examples/{http-complex-tool-schema => complex-tool-schema}/Model/EventPriority.php (87%) rename examples/{http-complex-tool-schema => complex-tool-schema}/Model/EventType.php (88%) rename examples/{http-complex-tool-schema => complex-tool-schema}/server.php (64%) rename examples/{stdio-custom-dependencies => custom-dependencies}/McpTaskHandlers.php (93%) rename examples/{stdio-custom-dependencies => custom-dependencies}/Service/InMemoryTaskRepository.php (97%) rename examples/{stdio-custom-dependencies => custom-dependencies}/Service/StatsServiceInterface.php (87%) rename examples/{stdio-custom-dependencies => custom-dependencies}/Service/SystemStatsService.php (94%) rename examples/{stdio-custom-dependencies => custom-dependencies}/Service/TaskRepositoryInterface.php (93%) rename examples/{stdio-custom-dependencies => custom-dependencies}/server.php (63%) rename examples/{stdio-discovery-calculator => discovery-calculator}/McpElements.php (99%) rename examples/{stdio-discovery-calculator => discovery-calculator}/server.php (71%) rename examples/{http-discovery-userprofile => discovery-userprofile}/McpElements.php (97%) rename examples/{http-discovery-userprofile => discovery-userprofile}/UserIdCompletionProvider.php (92%) rename examples/{http-discovery-userprofile => discovery-userprofile}/server.php (86%) rename examples/{stdio-env-variables => env-variables}/EnvToolHandler.php (97%) rename examples/{stdio-env-variables => env-variables}/server.php (81%) rename examples/{stdio-explicit-registration => explicit-registration}/SimpleHandlers.php (97%) rename examples/{stdio-explicit-registration => explicit-registration}/server.php (80%) delete mode 100644 examples/http-client-communication/server.php rename examples/{http-schema-showcase => schema-showcase}/SchemaShowcaseElements.php (99%) rename examples/{http-schema-showcase => schema-showcase}/server.php (63%) diff --git a/composer.json b/composer.json index 30a94724..7f25eee7 100644 --- a/composer.json +++ b/composer.json @@ -52,17 +52,17 @@ }, "autoload-dev": { "psr-4": { - "Mcp\\Example\\HttpCombinedRegistration\\": "examples/http-combined-registration/", - "Mcp\\Example\\HttpComplexToolSchema\\": "examples/http-complex-tool-schema/", - "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", - "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", - "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", - "Mcp\\Example\\StdioClientCommunication\\": "examples/stdio-client-communication/", - "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", - "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", - "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", + "Mcp\\Example\\CachedDiscovery\\": "examples/cached-discovery/", + "Mcp\\Example\\ClientCommunication\\": "examples/client-communication/", + "Mcp\\Example\\CombinedRegistration\\": "examples/combined-registration/", + "Mcp\\Example\\ComplexToolSchema\\": "examples/complex-tool-schema/", + "Mcp\\Example\\CustomDependencies\\": "examples/custom-dependencies/", "Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/", + "Mcp\\Example\\DiscoveryCalculator\\": "examples/discovery-calculator/", + "Mcp\\Example\\DiscoveryUserProfile\\": "examples/discovery-userprofile/", + "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", + "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", + "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", "Mcp\\Tests\\": "tests/" } }, diff --git a/docs/examples.md b/docs/examples.md index a242b9a8..e004636e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,10 +7,7 @@ specific features and can be run independently to understand how the SDK works. - [Getting Started](#getting-started) - [Running Examples](#running-examples) -- [STDIO Examples](#stdio-examples) -- [HTTP Examples](#http-examples) -- [Advanced Patterns](#advanced-patterns) -- [Testing and Debugging](#testing-and-debugging) +- [Examples](#examples) ## Getting Started @@ -26,28 +23,30 @@ composer install ## Running Examples -### STDIO Examples +The bootstrapping of the example will choose the used transport based on the SAPI you use. -STDIO examples use standard input/output for communication: +### STDIO Transport + +The STDIO transport will use standard input/output for communication: ```bash # Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php # Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php # Or configure the script path in your MCP client -# Path: php examples/stdio-discovery-calculator/server.php +# Path: php examples/discovery-calculator/server.php ``` -### HTTP Examples +### HTTP Transport -HTTP examples run as web servers: +The Streamable HTTP transport will be chosen if running examples with a web servers: ```bash # Start the server -php -S localhost:8000 examples/http-discovery-userprofile/server.php +php -S localhost:8000 examples/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -59,11 +58,11 @@ curl -X POST http://localhost:8000 \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' ``` -## STDIO Examples +## Examples ### Discovery Calculator -**File**: `examples/stdio-discovery-calculator/` +**File**: `examples/discovery-calculator/` **What it demonstrates:** - Attribute-based discovery using `#[McpTool]` and `#[McpResource]` @@ -87,14 +86,14 @@ public function getConfiguration(): array **Usage:** ```bash # Interactive testing -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php -# Or configure in MCP client: php examples/stdio-discovery-calculator/server.php +# Or configure in MCP client: php examples/discovery-calculator/server.php ``` ### Explicit Registration -**File**: `examples/stdio-explicit-registration/` +**File**: `examples/explicit-registration/` **What it demonstrates:** - Manual registration of tools, resources, and prompts @@ -111,7 +110,7 @@ $server = Server::builder() ### Environment Variables -**File**: `examples/stdio-env-variables/` +**File**: `examples/env-variables/` **What it demonstrates:** - Environment variable integration @@ -125,7 +124,7 @@ $server = Server::builder() ### Custom Dependencies -**File**: `examples/stdio-custom-dependencies/` +**File**: `examples/custom-dependencies/` **What it demonstrates:** - Dependency injection with PSR-11 containers @@ -145,7 +144,7 @@ $server = Server::builder() ### Cached Discovery -**File**: `examples/stdio-cached-discovery/` +**File**: `examples/cached-discovery/` **What it demonstrates:** - Discovery caching for improved performance @@ -165,7 +164,7 @@ $server = Server::builder() ### Client Communication -**File**: `examples/stdio-client-communication/` +**File**: `examples/client-communication/` **What it demostrates:** - Server initiated communcation back to the client @@ -173,11 +172,9 @@ $server = Server::builder() - Using `ClientGateway` in service class via `ClientAwareInterface` and corresponding trait - Using `ClientGateway` in tool method via method argument injection -## HTTP Examples - ### Discovery User Profile -**File**: `examples/http-discovery-userprofile/` +**File**: `examples/discovery-userprofile/` **What it demonstrates:** - HTTP transport with StreamableHttpTransport @@ -205,7 +202,7 @@ public function generateBio(string $userId, string $tone = 'professional'): arra **Usage:** ```bash # Start the HTTP server -php -S localhost:8000 examples/http-discovery-userprofile/server.php +php -S localhost:8000 examples/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -215,7 +212,7 @@ npx @modelcontextprotocol/inspector http://localhost:8000 ### Combined Registration -**File**: `examples/http-combined-registration/` +**File**: `examples/combined-registration/` **What it demonstrates:** - Mixing attribute discovery with manual registration @@ -232,7 +229,7 @@ $server = Server::builder() ### Complex Tool Schema -**File**: `examples/http-complex-tool-schema/` +**File**: `examples/complex-tool-schema/` **What it demonstrates:** - Advanced JSON schema definitions @@ -255,7 +252,7 @@ public function scheduleEvent(array $eventData): array ### Schema Showcase -**File**: `examples/http-schema-showcase/` +**File**: `examples/schema-showcase/` **What it demonstrates:** - Comprehensive JSON schema features diff --git a/examples/README.md b/examples/README.md index 96b8773b..27874d71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,14 +3,15 @@ This directory contains various examples of how to use the PHP MCP SDK. You can run the examples with the dependencies already installed in the root directory of the SDK. +The bootstrapping of the example will choose the used transport based on the SAPI you use. For running an example, you execute the `server.php` like this: ```bash -# For examples using STDIO transport -php examples/stdio-discovery-calculator/server.php +# For using the STDIO transport: +php examples/discovery-calculator/server.php -# For examples using Streamable HTTP transport -php -S localhost:8000 examples/http-discovery-userprofile/server.php +# For using the Streamable HTTP transport: +php -S localhost:8000 examples/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -18,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php ``` ## Debugging @@ -29,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php ``` diff --git a/examples/bootstrap.php b/examples/bootstrap.php index e4a4b98d..8c0508ab 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -9,7 +9,13 @@ * file that was distributed with this source code. */ +use Http\Discovery\Psr17Factory; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Capability\Registry\Container; +use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Transport\TransportInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -21,6 +27,31 @@ exit(1); }); +/** + * @return TransportInterface|TransportInterface + */ +function transport(): TransportInterface +{ + if ('cli' === \PHP_SAPI) { + return new StdioTransport(logger: logger()); + } + + return new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + ); +} + +function shutdown(ResponseInterface|int $result): never +{ + if ('cli' === \PHP_SAPI) { + exit($result); + } + + (new SapiEmitter())->emit($result); + exit(0); +} + function logger(): LoggerInterface { return new class extends AbstractLogger { diff --git a/examples/stdio-cached-discovery/CachedCalculatorElements.php b/examples/cached-discovery/CachedCalculatorElements.php similarity index 96% rename from examples/stdio-cached-discovery/CachedCalculatorElements.php rename to examples/cached-discovery/CachedCalculatorElements.php index 1da3d277..ef67b0ff 100644 --- a/examples/stdio-cached-discovery/CachedCalculatorElements.php +++ b/examples/cached-discovery/CachedCalculatorElements.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCachedDiscovery; +namespace Mcp\Example\CachedDiscovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; diff --git a/examples/stdio-cached-discovery/server.php b/examples/cached-discovery/server.php similarity index 85% rename from examples/stdio-cached-discovery/server.php rename to examples/cached-discovery/server.php index 38b5e9ce..1d6c1e16 100644 --- a/examples/stdio-cached-discovery/server.php +++ b/examples/cached-discovery/server.php @@ -16,7 +16,7 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; use Symfony\Component\Cache\Adapter\PhpFilesAdapter; use Symfony\Component\Cache\Psr16Cache; @@ -25,14 +25,13 @@ $server = Server::builder() ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) ->setDiscovery(__DIR__, cache: new Psr16Cache(new PhpFilesAdapter(directory: __DIR__.'/cache'))) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/stdio-client-communication/ClientAwareService.php b/examples/client-communication/ClientAwareService.php similarity index 97% rename from examples/stdio-client-communication/ClientAwareService.php rename to examples/client-communication/ClientAwareService.php index ebb797aa..3733614e 100644 --- a/examples/stdio-client-communication/ClientAwareService.php +++ b/examples/client-communication/ClientAwareService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioClientCommunication; +namespace Mcp\Example\ClientCommunication; use Mcp\Capability\Attribute\McpTool; use Mcp\Schema\Content\TextContent; diff --git a/examples/stdio-client-communication/server.php b/examples/client-communication/server.php similarity index 89% rename from examples/stdio-client-communication/server.php rename to examples/client-communication/server.php index 596da5ad..7d586bad 100644 --- a/examples/stdio-client-communication/server.php +++ b/examples/client-communication/server.php @@ -17,12 +17,13 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\ClientGateway; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; $server = Server::builder() - ->setServerInfo('STDIO Client Communication Demo', '1.0.0') + ->setServerInfo('Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) ->setDiscovery(__DIR__) ->addTool( @@ -56,8 +57,6 @@ function (string $dataset, ClientGateway $client): array { ) ->build(); -$transport = new StdioTransport(); +$result = $server->run(transport()); -$status = $server->run($transport); - -exit($status); +shutdown($result); diff --git a/examples/http-combined-registration/DiscoveredElements.php b/examples/combined-registration/DiscoveredElements.php similarity index 95% rename from examples/http-combined-registration/DiscoveredElements.php rename to examples/combined-registration/DiscoveredElements.php index 7d030679..f7142466 100644 --- a/examples/http-combined-registration/DiscoveredElements.php +++ b/examples/combined-registration/DiscoveredElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpCombinedRegistration; +namespace Mcp\Example\CombinedRegistration; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/http-combined-registration/ManualHandlers.php b/examples/combined-registration/ManualHandlers.php similarity index 95% rename from examples/http-combined-registration/ManualHandlers.php rename to examples/combined-registration/ManualHandlers.php index 21f86e9d..65a86bc6 100644 --- a/examples/http-combined-registration/ManualHandlers.php +++ b/examples/combined-registration/ManualHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpCombinedRegistration; +namespace Mcp\Example\CombinedRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/http-combined-registration/server.php b/examples/combined-registration/server.php similarity index 67% rename from examples/http-combined-registration/server.php rename to examples/combined-registration/server.php index 2c3184a6..02f26a4e 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/combined-registration/server.php @@ -13,15 +13,9 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Example\HttpCombinedRegistration\ManualHandlers; +use Mcp\Example\CombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') @@ -37,8 +31,6 @@ ) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/http-complex-tool-schema/McpEventScheduler.php b/examples/complex-tool-schema/McpEventScheduler.php similarity index 93% rename from examples/http-complex-tool-schema/McpEventScheduler.php rename to examples/complex-tool-schema/McpEventScheduler.php index a9b3edc7..253ff5cb 100644 --- a/examples/http-complex-tool-schema/McpEventScheduler.php +++ b/examples/complex-tool-schema/McpEventScheduler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema; +namespace Mcp\Example\ComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\HttpComplexToolSchema\Model\EventPriority; -use Mcp\Example\HttpComplexToolSchema\Model\EventType; +use Mcp\Example\ComplexToolSchema\Model\EventPriority; +use Mcp\Example\ComplexToolSchema\Model\EventType; use Psr\Log\LoggerInterface; final class McpEventScheduler diff --git a/examples/http-complex-tool-schema/Model/EventPriority.php b/examples/complex-tool-schema/Model/EventPriority.php similarity index 87% rename from examples/http-complex-tool-schema/Model/EventPriority.php rename to examples/complex-tool-schema/Model/EventPriority.php index 1654be0e..e46a69da 100644 --- a/examples/http-complex-tool-schema/Model/EventPriority.php +++ b/examples/complex-tool-schema/Model/EventPriority.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/http-complex-tool-schema/Model/EventType.php b/examples/complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/http-complex-tool-schema/Model/EventType.php rename to examples/complex-tool-schema/Model/EventType.php index 5711662d..eaf0f431 100644 --- a/examples/http-complex-tool-schema/Model/EventType.php +++ b/examples/complex-tool-schema/Model/EventType.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventType: string { diff --git a/examples/http-complex-tool-schema/server.php b/examples/complex-tool-schema/server.php similarity index 64% rename from examples/http-complex-tool-schema/server.php rename to examples/complex-tool-schema/server.php index b6d29378..92f80b61 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/complex-tool-schema/server.php @@ -13,14 +13,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Event Scheduler Server', '1.0.0') @@ -30,8 +24,6 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-custom-dependencies/McpTaskHandlers.php b/examples/custom-dependencies/McpTaskHandlers.php similarity index 93% rename from examples/stdio-custom-dependencies/McpTaskHandlers.php rename to examples/custom-dependencies/McpTaskHandlers.php index 65d2f003..00127a78 100644 --- a/examples/stdio-custom-dependencies/McpTaskHandlers.php +++ b/examples/custom-dependencies/McpTaskHandlers.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies; +namespace Mcp\Example\CustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; /** diff --git a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php b/examples/custom-dependencies/Service/InMemoryTaskRepository.php similarity index 97% rename from examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php rename to examples/custom-dependencies/Service/InMemoryTaskRepository.php index 63ce8611..dbf3b6ab 100644 --- a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php +++ b/examples/custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php b/examples/custom-dependencies/Service/StatsServiceInterface.php similarity index 87% rename from examples/stdio-custom-dependencies/Service/StatsServiceInterface.php rename to examples/custom-dependencies/Service/StatsServiceInterface.php index a7e8b276..079f7e23 100644 --- a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php +++ b/examples/custom-dependencies/Service/StatsServiceInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; interface StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/SystemStatsService.php b/examples/custom-dependencies/Service/SystemStatsService.php similarity index 94% rename from examples/stdio-custom-dependencies/Service/SystemStatsService.php rename to examples/custom-dependencies/Service/SystemStatsService.php index 5a766792..5cd44880 100644 --- a/examples/stdio-custom-dependencies/Service/SystemStatsService.php +++ b/examples/custom-dependencies/Service/SystemStatsService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; final class SystemStatsService implements StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php b/examples/custom-dependencies/Service/TaskRepositoryInterface.php similarity index 93% rename from examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php rename to examples/custom-dependencies/Service/TaskRepositoryInterface.php index 6c091be1..7216634c 100644 --- a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php +++ b/examples/custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; /** * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} diff --git a/examples/stdio-custom-dependencies/server.php b/examples/custom-dependencies/server.php similarity index 63% rename from examples/stdio-custom-dependencies/server.php rename to examples/custom-dependencies/server.php index 4ebd4cbd..cd450e51 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/custom-dependencies/server.php @@ -13,14 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\StdioCustomDependencies\Service\InMemoryTaskRepository; -use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\StdioCustomDependencies\Service\SystemStatsService; -use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\InMemoryTaskRepository; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\SystemStatsService; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Dependencies (Stdio) Server...'); +logger()->info('Starting MCP Custom Dependencies Server...'); $container = container(); @@ -32,15 +32,14 @@ $server = Server::builder() ->setServerInfo('Task Manager Server', '1.0.0') - ->setLogger(logger()) ->setContainer($container) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index 7554a0a7..62f41df9 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -18,9 +18,9 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...'); +logger()->info('Starting MCP Custom Method Handlers Server...'); $toolDefinitions = [ 'say_hello' => new Tool( @@ -56,16 +56,15 @@ $server = Server::builder() ->setServerInfo('Custom Handlers Server', '1.0.0') - ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) ->setCapabilities($capabilities) ->addRequestHandlers([$listToolsHandler, $callToolHandler]) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/discovery-calculator/McpElements.php similarity index 99% rename from examples/stdio-discovery-calculator/McpElements.php rename to examples/discovery-calculator/McpElements.php index 21330313..6276de57 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/discovery-calculator/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioDiscoveryCalculator; +namespace Mcp\Example\DiscoveryCalculator; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/stdio-discovery-calculator/server.php b/examples/discovery-calculator/server.php similarity index 71% rename from examples/stdio-discovery-calculator/server.php rename to examples/discovery-calculator/server.php index 3f113489..c6d75e4d 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/discovery-calculator/server.php @@ -14,22 +14,21 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Stdio Calculator Server...'); +logger()->info('Starting MCP Calculator Server...'); $server = Server::builder() - ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setServerInfo('Calculator', '1.1.0', 'Basic Calculator') ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/discovery-userprofile/McpElements.php similarity index 97% rename from examples/http-discovery-userprofile/McpElements.php rename to examples/discovery-userprofile/McpElements.php index 933bd51e..8418f09a 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/discovery-userprofile/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; @@ -39,7 +39,7 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger, ) { - $this->logger->debug('HttpDiscoveryUserProfile McpElements instantiated.'); + $this->logger->debug('DiscoveryUserProfile McpElements instantiated.'); } /** diff --git a/examples/http-discovery-userprofile/UserIdCompletionProvider.php b/examples/discovery-userprofile/UserIdCompletionProvider.php similarity index 92% rename from examples/http-discovery-userprofile/UserIdCompletionProvider.php rename to examples/discovery-userprofile/UserIdCompletionProvider.php index 37b1c5b7..69dfe4f0 100644 --- a/examples/http-discovery-userprofile/UserIdCompletionProvider.php +++ b/examples/discovery-userprofile/UserIdCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Completion\ProviderInterface; diff --git a/examples/http-discovery-userprofile/server.php b/examples/discovery-userprofile/server.php similarity index 86% rename from examples/http-discovery-userprofile/server.php rename to examples/discovery-userprofile/server.php index c958bfb9..48033716 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/discovery-userprofile/server.php @@ -13,14 +13,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('HTTP User Profiles', '1.0.0') @@ -72,8 +66,6 @@ function (): array { ) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-env-variables/EnvToolHandler.php b/examples/env-variables/EnvToolHandler.php similarity index 97% rename from examples/stdio-env-variables/EnvToolHandler.php rename to examples/env-variables/EnvToolHandler.php index 49c914d5..f7cad817 100644 --- a/examples/stdio-env-variables/EnvToolHandler.php +++ b/examples/env-variables/EnvToolHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioEnvVariables; +namespace Mcp\Example\EnvVariables; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/stdio-env-variables/server.php b/examples/env-variables/server.php similarity index 81% rename from examples/stdio-env-variables/server.php rename to examples/env-variables/server.php index 13c36bcf..48c43825 100644 --- a/examples/stdio-env-variables/server.php +++ b/examples/env-variables/server.php @@ -14,18 +14,17 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; /* |-------------------------------------------------------------------------- - | MCP Stdio Environment Variable Example Server + | MCP Environment Variable Example Server |-------------------------------------------------------------------------- | | This server demonstrates how to use environment variables to modify tool | behavior. The MCP client can set the APP_MODE environment variable to | control the server's behavior. | - | Configure your MCP Client (eg. Cursor) for this server like this: + | Configure your MCP Client (e.g. Cursor) for this server like this: | | { | "mcpServers": { @@ -47,7 +46,7 @@ | */ -logger()->info('Starting MCP Stdio Environment Variable Example Server...'); +logger()->info('Starting MCP Environment Variable Example Server...'); $server = Server::builder() ->setServerInfo('Env Var Server', '1.0.0') @@ -55,10 +54,8 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/stdio-explicit-registration/SimpleHandlers.php b/examples/explicit-registration/SimpleHandlers.php similarity index 97% rename from examples/stdio-explicit-registration/SimpleHandlers.php rename to examples/explicit-registration/SimpleHandlers.php index 0a119e77..0fe385c1 100644 --- a/examples/stdio-explicit-registration/SimpleHandlers.php +++ b/examples/explicit-registration/SimpleHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioExplicitRegistration; +namespace Mcp\Example\ExplicitRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-explicit-registration/server.php b/examples/explicit-registration/server.php similarity index 80% rename from examples/stdio-explicit-registration/server.php rename to examples/explicit-registration/server.php index 2189bf16..5a61feef 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/explicit-registration/server.php @@ -13,15 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\StdioExplicitRegistration\SimpleHandlers; +use Mcp\Example\ExplicitRegistration\SimpleHandlers; use Mcp\Schema\ServerCapabilities; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; -logger()->info('Starting MCP Manual Registration (Stdio) Server...'); +logger()->info('Starting MCP Manual Registration Server...'); $server = Server::builder() - ->setServerInfo('Manual Reg Server', '1.0.0') + ->setServerInfo('Explicit Registration Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') @@ -41,10 +40,8 @@ )) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php deleted file mode 100644 index 12f18eec..00000000 --- a/examples/http-client-communication/server.php +++ /dev/null @@ -1,116 +0,0 @@ -createServerRequestFromGlobals(); - -$sessionDir = __DIR__.'/sessions'; -$capabilities = new ServerCapabilities(logging: true, tools: true); -$logger = logger(); - -$server = Server::builder() - ->setServerInfo('HTTP Client Communication Demo', '1.0.0') - ->setLogger($logger) - ->setContainer(container()) - ->setSession(new FileSessionStore($sessionDir)) - ->setCapabilities($capabilities) - ->addTool( - function (string $projectName, array $milestones, ClientGateway $client): array { - $client->log(LoggingLevel::Info, sprintf('Preparing project briefing for "%s"', $projectName)); - - $totalSteps = max(1, count($milestones)); - - foreach ($milestones as $index => $milestone) { - $progress = ($index + 1) / $totalSteps; - $message = sprintf('Analyzing milestone "%s"', $milestone); - - $client->progress(progress: $progress, total: 1, message: $message); - - usleep(150_000); // Simulate work being done - } - - $prompt = sprintf( - 'Draft a concise stakeholder briefing for the project "%s". Highlight key milestones: %s. Focus on risks and next steps.', - $projectName, - implode(', ', $milestones) - ); - - $result = $client->sample( - message: $prompt, - maxTokens: 400, - timeout: 90, - options: ['temperature' => 0.4] - ); - - $content = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; - - $client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.'); - - return [ - 'project' => $projectName, - 'milestones_reviewed' => $milestones, - 'briefing' => $content, - 'model' => $result->model, - 'stop_reason' => $result->stopReason, - ]; - }, - name: 'prepare_project_briefing', - description: 'Compile a stakeholder briefing with live logging, progress updates, and LLM sampling.' - ) - ->addTool( - function (string $serviceName, ClientGateway $client): array { - $client->log(LoggingLevel::Info, sprintf('Starting maintenance checks for "%s"', $serviceName)); - - $steps = [ - 'Verifying health metrics', - 'Checking recent deployments', - 'Reviewing alert stream', - 'Summarizing findings', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / count($steps); - - $client->progress(progress: $progress, total: 1, message: $step); - - usleep(120_000); // Simulate work being done - } - - $client->log(LoggingLevel::Info, sprintf('Maintenance checks complete for "%s"', $serviceName)); - - return [ - 'service' => $serviceName, - 'status' => 'operational', - 'notes' => 'No critical issues detected during automated sweep.', - ]; - }, - name: 'run_service_maintenance', - description: 'Simulate service maintenance with logging and progress notifications.' - ) - ->build(); - -$transport = new StreamableHttpTransport($request, logger: $logger); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); diff --git a/examples/http-schema-showcase/SchemaShowcaseElements.php b/examples/schema-showcase/SchemaShowcaseElements.php similarity index 99% rename from examples/http-schema-showcase/SchemaShowcaseElements.php rename to examples/schema-showcase/SchemaShowcaseElements.php index d19bfa1f..6c7a4b93 100644 --- a/examples/http-schema-showcase/SchemaShowcaseElements.php +++ b/examples/schema-showcase/SchemaShowcaseElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpSchemaShowcase; +namespace Mcp\Example\SchemaShowcase; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; diff --git a/examples/http-schema-showcase/server.php b/examples/schema-showcase/server.php similarity index 63% rename from examples/http-schema-showcase/server.php rename to examples/schema-showcase/server.php index 6b38a3c2..b908cebd 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/schema-showcase/server.php @@ -13,14 +13,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Schema Showcase', '1.0.0') @@ -30,8 +24,6 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index 400c453e..5a874a76 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -16,7 +16,7 @@ use Symfony\Component\Uid\Uuid; /** - * @template TResult + * @template-covariant TResult * * @phpstan-type FiberReturn (Response|Error) * @phpstan-type FiberResume (FiberReturn|null) diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php index 0b396e2e..ba287fd0 100644 --- a/tests/Inspector/Http/HttpClientCommunicationTest.php +++ b/tests/Inspector/Http/HttpClientCommunicationTest.php @@ -59,6 +59,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-client-communication/server.php'; + return \dirname(__DIR__, 3).'/examples/client-communication/server.php'; } } diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php index 932c200b..36f133cd 100644 --- a/tests/Inspector/Http/HttpCombinedRegistrationTest.php +++ b/tests/Inspector/Http/HttpCombinedRegistrationTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-combined-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/combined-registration/server.php'; } } diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php index 93f3ca30..6c104e28 100644 --- a/tests/Inspector/Http/HttpComplexToolSchemaTest.php +++ b/tests/Inspector/Http/HttpComplexToolSchemaTest.php @@ -82,6 +82,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-complex-tool-schema/server.php'; + return \dirname(__DIR__, 3).'/examples/complex-tool-schema/server.php'; } } diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index abffbacd..b0ffef81 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -67,6 +67,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-discovery-userprofile/server.php'; + return \dirname(__DIR__, 3).'/examples/discovery-userprofile/server.php'; } } diff --git a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php index 41f224a9..5db629e4 100644 --- a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php +++ b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php @@ -21,14 +21,12 @@ abstract class HttpInspectorSnapshotTestCase extends InspectorSnapshotTestCase protected function setUp(): void { - parent::setUp(); $this->startServer(); } protected function tearDown(): void { $this->stopServer(); - parent::tearDown(); } abstract protected function getServerScript(): string; diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php index e4b137da..9ed61ae1 100644 --- a/tests/Inspector/Http/HttpSchemaShowcaseTest.php +++ b/tests/Inspector/Http/HttpSchemaShowcaseTest.php @@ -87,7 +87,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-schema-showcase/server.php'; + return \dirname(__DIR__, 3).'/examples/schema-showcase/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php index ede61ed3..438a3f8e 100644 --- a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php +++ b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php @@ -88,6 +88,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-cached-discovery/server.php'; + return \dirname(__DIR__, 3).'/examples/cached-discovery/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php index e4285e27..d2f64c0d 100644 --- a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php +++ b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php @@ -55,7 +55,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-custom-dependencies/server.php'; + return \dirname(__DIR__, 3).'/examples/custom-dependencies/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php index 8a62b2c6..87ce549c 100644 --- a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php +++ b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-discovery-calculator/server.php'; + return \dirname(__DIR__, 3).'/examples/discovery-calculator/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php index 97601d1a..c46eb753 100644 --- a/tests/Inspector/Stdio/StdioEnvVariablesTest.php +++ b/tests/Inspector/Stdio/StdioEnvVariablesTest.php @@ -50,6 +50,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-env-variables/server.php'; + return \dirname(__DIR__, 3).'/examples/env-variables/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php index 103d3e67..1cb8179b 100644 --- a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php +++ b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php @@ -77,6 +77,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-explicit-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/explicit-registration/server.php'; } } From 9be9e3881507499ebff6c559204e337e5e982a2c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 4 Nov 2025 22:44:53 +0100 Subject: [PATCH 53/66] ability to append loaders to server builder (#132) --- src/Server/Builder.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 90bf53a4..744e04b4 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -16,6 +16,7 @@ use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; @@ -130,6 +131,11 @@ final class Builder private ?ServerCapabilities $serverCapabilities = null; + /** + * @var LoaderInterface[] + */ + private array $loaders = []; + /** * Sets the server's identity. Required. */ @@ -356,6 +362,16 @@ public function addPrompt(\Closure|array|string $handler, ?string $name = null, return $this; } + /** + * @param LoaderInterface[] $loaders + */ + public function addLoaders(...$loaders): self + { + $this->loaders = [...$this->loaders, ...$loaders]; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -366,6 +382,7 @@ public function build(): Server $registry = new Registry($this->eventDispatcher, $logger); $loaders = [ + ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), ]; From 85549cb8355d5e5e0d04f6a439f083b7d2e8211a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 4 Nov 2025 22:45:41 +0100 Subject: [PATCH 54/66] session save return type (#133) --- src/Server/Session/Session.php | 4 ++-- src/Server/Session/SessionInterface.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 6bf3c306..e02fcc7c 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -51,9 +51,9 @@ public function getStore(): SessionStoreInterface return $this->store; } - public function save(): void + public function save(): bool { - $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); + return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); } public function get(string $key, mixed $default = null): mixed diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php index 9ee5e807..8f93a6e4 100644 --- a/src/Server/Session/SessionInterface.php +++ b/src/Server/Session/SessionInterface.php @@ -26,7 +26,7 @@ public function getId(): Uuid; /** * Save the session. */ - public function save(): void; + public function save(): bool; /** * Get a specific attribute from the session. From 09596c0382e700e2d83b78f890001b4023ef3540 Mon Sep 17 00:00:00 2001 From: ineersa Date: Fri, 7 Nov 2025 18:37:01 -0500 Subject: [PATCH 55/66] Ability to set custom protocol version (#117) --- src/Schema/Enum/ProtocolVersion.php | 24 ++++++ src/Schema/JsonRpc/MessageInterface.php | 4 +- src/Schema/Result/InitializeResult.php | 8 +- src/Server/Builder.php | 12 ++- src/Server/Configuration.php | 2 + .../Handler/Request/InitializeHandler.php | 2 + .../Handler/Request/InitializeHandlerTest.php | 76 +++++++++++++++++++ 7 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/Schema/Enum/ProtocolVersion.php create mode 100644 tests/Unit/Server/Handler/Request/InitializeHandlerTest.php diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php new file mode 100644 index 00000000..d2d44436 --- /dev/null +++ b/src/Schema/Enum/ProtocolVersion.php @@ -0,0 +1,24 @@ +protocolVersion ?? MessageInterface::PROTOCOL_VERSION; $data = [ - 'protocolVersion' => MessageInterface::PROTOCOL_VERSION, + 'protocolVersion' => $protocolVersion->value, 'capabilities' => $this->capabilities, 'serverInfo' => $this->serverInfo, ]; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 744e04b4..f4327167 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -20,6 +20,7 @@ use Mcp\Capability\Registry\ReferenceHandler; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\ToolAnnotations; @@ -63,6 +64,8 @@ final class Builder private ?string $instructions = null; + private ?ProtocolVersion $protocolVersion = null; + /** * @var array> */ @@ -288,6 +291,13 @@ public function setDiscovery( return $this; } + public function setProtocolVersion(?ProtocolVersion $protocolVersion): self + { + $this->protocolVersion = $protocolVersion; + + return $this; + } + /** * Manually registers a tool handler. * @@ -404,7 +414,7 @@ public function build(): Server $messageFactory = MessageFactory::make(); $capabilities = $registry->getCapabilities(); - $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php index ab629b58..2f0bd7f0 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -11,6 +11,7 @@ namespace Mcp\Server; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; @@ -34,6 +35,7 @@ public function __construct( public readonly ServerCapabilities $capabilities, public readonly int $paginationLimit = 50, public readonly ?string $instructions = null, + public readonly ?ProtocolVersion $protocolVersion = null, ) { } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 32eae194..d814d9dd 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -52,6 +52,8 @@ public function handle(Request $request, SessionInterface $session): Response $this->configuration->capabilities ?? new ServerCapabilities(), $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, + null, + $this->configuration?->protocolVersion, ), ); } diff --git a/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php new file mode 100644 index 00000000..36c36f14 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/InitializeHandlerTest.php @@ -0,0 +1,76 @@ +createMock(SessionInterface::class); + $session->expects($this->once()) + ->method('set') + ->with('client_info', [ + 'name' => 'client-app', + 'version' => '1.0.0', + ]); + + $request = InitializeRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 'request-1', + 'method' => InitializeRequest::getMethod(), + 'params' => [ + 'protocolVersion' => ProtocolVersion::V2024_11_05->value, + 'capabilities' => [], + 'clientInfo' => [ + 'name' => 'client-app', + 'version' => '1.0.0', + ], + ], + ]); + + $response = $handler->handle($request, $session); + + $this->assertInstanceOf(InitializeResult::class, $response->result); + + /** @var InitializeResult $result */ + $result = $response->result; + + $this->assertSame($customProtocolVersion, $result->protocolVersion); + $this->assertSame( + $customProtocolVersion->value, + $result->jsonSerialize()['protocolVersion'] + ); + } +} From d3a07912b05ef54099d25f73d90cbb794af5d03e Mon Sep 17 00:00:00 2001 From: Kaipi Yann Date: Sat, 8 Nov 2025 13:58:26 +0100 Subject: [PATCH 56/66] Roll out meta properties in attributes and more schema classes (#129) --- src/Capability/Attribute/McpPrompt.php | 6 ++-- src/Capability/Attribute/McpResource.php | 14 ++++---- .../Attribute/McpResourceTemplate.php | 12 ++++--- src/Capability/Attribute/McpTool.php | 8 +++-- src/Capability/Discovery/Discoverer.php | 13 +++++--- .../Registry/Loader/ArrayLoader.php | 16 ++++++--- src/Capability/Registry/ResourceReference.php | 21 +++++++----- .../Registry/ResourceTemplateReference.php | 21 +++++++----- src/Schema/Content/BlobResourceContents.php | 33 +++++++++++-------- src/Schema/Content/ResourceContents.php | 13 ++++++-- src/Schema/Content/TextResourceContents.php | 15 +++++---- src/Schema/Prompt.php | 16 +++++++-- src/Schema/Resource.php | 28 +++++++++++----- src/Schema/ResourceTemplate.php | 24 ++++++++++---- src/Schema/Tool.php | 22 +++++++++---- src/Server/Builder.php | 4 +++ 16 files changed, 178 insertions(+), 88 deletions(-) diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 73677e33..8488e608 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -21,12 +21,14 @@ final class McpPrompt { /** - * @param ?string $name overrides the prompt name (defaults to method name) - * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 873d485c..86b33078 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -23,12 +23,13 @@ final class McpResource { /** - * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. - * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. - * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. - * @param ?string $mimeType the MIME type, if known and constant for this resource - * @param ?int $size the size in bytes, if known and constant - * @param Annotations|null $annotations optional annotations describing the resource + * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. + * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. + * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param ?string $mimeType the MIME type, if known and constant for this resource + * @param ?int $size the size in bytes, if known and constant + * @param Annotations|null $annotations optional annotations describing the resource + * @param ?array $meta Optional metadata */ public function __construct( public string $uri, @@ -37,6 +38,7 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 9b8887f1..14e66c5f 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -23,11 +23,12 @@ final class McpResourceTemplate { /** - * @param string $uriTemplate the URI template string (RFC 6570) - * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. - * @param ?string $description Optional description. Defaults to class DocBlock summary. - * @param ?string $mimeType optional default MIME type for matching resources - * @param ?Annotations $annotations optional annotations describing the resource template + * @param string $uriTemplate the URI template string (RFC 6570) + * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. + * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $mimeType optional default MIME type for matching resources + * @param ?Annotations $annotations optional annotations describing the resource template + * @param ?array $meta Optional metadata */ public function __construct( public string $uriTemplate, @@ -35,6 +36,7 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index d4af3e6c..e5cab26b 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -20,14 +20,16 @@ class McpTool { /** - * @param string|null $name The name of the tool (defaults to the method name) - * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) - * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index e6cc328f..e8f32940 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -222,7 +222,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); - $tool = new Tool($name, $inputSchema, $description, $instance->annotations); + $meta = $instance->meta ?? null; + $tool = new Tool($name, $inputSchema, $description, $instance->annotations, $meta); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -234,8 +235,10 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $mimeType = $instance->mimeType; $size = $instance->size; $annotations = $instance->annotations; - $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); + $meta = $instance->meta; + $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size, $meta); $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); + ++$discoveredCount['resources']; break; @@ -253,7 +256,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $prompt = new Prompt($name, $description, $arguments); + $meta = $instance->meta ?? null; + $prompt = new Prompt($name, $description, $arguments, $meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; @@ -265,7 +269,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $mimeType = $instance->mimeType; $annotations = $instance->annotations; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations); + $meta = $instance->meta ?? null; + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 7420bc75..a826bf6d 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -45,6 +45,7 @@ final class ArrayLoader implements LoaderInterface * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * meta: ?array * }[] $tools * @param array{ * handler: Handler, @@ -54,6 +55,7 @@ final class ArrayLoader implements LoaderInterface * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * meta: ?array * }[] $resources * @param array{ * handler: Handler, @@ -62,11 +64,13 @@ final class ArrayLoader implements LoaderInterface * description: ?string, * mimeType: ?string, * annotations: ?Annotations, + * meta: ?array * }[] $resourceTemplates * @param array{ * handler: Handler, * name: ?string, * description: ?string, + * meta: ?array * }[] $prompts */ public function __construct( @@ -102,7 +106,7 @@ public function load(ReferenceRegistryInterface $registry): void $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); - $tool = new Tool($name, $inputSchema, $description, $data['annotations']); + $tool = new Tool($name, $inputSchema, $description, $data['annotations'], $data['meta'] ?? null); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); @@ -137,8 +141,9 @@ public function load(ReferenceRegistryInterface $registry): void $mimeType = $data['mimeType']; $size = $data['size']; $annotations = $data['annotations']; + $meta = $data['meta']; - $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); + $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size, $meta); $registry->registerResource($resource, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); @@ -172,8 +177,9 @@ public function load(ReferenceRegistryInterface $registry): void $uriTemplate = $data['uriTemplate']; $mimeType = $data['mimeType']; $annotations = $data['annotations']; + $meta = $data['meta']; - $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); + $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); @@ -224,8 +230,8 @@ public function load(ReferenceRegistryInterface $registry): void !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } - - $prompt = new Prompt($name, $description, $arguments); + $meta = $data['meta']; + $prompt = new Prompt($name, $description, $arguments, $meta); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index c93d5a14..d9b6a7e4 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -68,9 +68,11 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = return [$readResult->resource]; } + $meta = $this->schema->meta; + if (\is_array($readResult)) { if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]')]; + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; } $allAreResourceContents = true; @@ -118,14 +120,15 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - return [new TextResourceContents($uri, $mimeType, $readResult)]; + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; } if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { $result = BlobResourceContents::fromStream( $uri, $readResult, - $mimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream', + $meta ); @fclose($readResult); @@ -136,21 +139,21 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; } if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; } - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; } if (\is_array($readResult)) { @@ -159,7 +162,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = try { $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); } @@ -169,7 +172,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); } diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index b71a943a..88104c9d 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -101,9 +101,11 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = return [$readResult->resource]; } + $meta = $this->resourceTemplate->meta; + if (\is_array($readResult)) { if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]')]; + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; } $allAreResourceContents = true; @@ -151,14 +153,15 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - return [new TextResourceContents($uri, $mimeType, $readResult)]; + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; } if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { $result = BlobResourceContents::fromStream( $uri, $readResult, - $mimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream', + $meta ); @fclose($readResult); @@ -169,21 +172,21 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; } if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; } - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; } if (\is_array($readResult)) { @@ -192,7 +195,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = try { $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -202,7 +205,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index c8b07b05..0d6016d5 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -19,7 +19,8 @@ * @phpstan-type BlobResourceContentsData array{ * uri: string, * mimeType?: string|null, - * blob: string + * blob: string, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -27,16 +28,18 @@ class BlobResourceContents extends ResourceContents { /** - * @param string $uri the URI of the resource or sub-resource - * @param string|null $mimeType the MIME type of the resource or sub-resource - * @param string $blob a base64-encoded string representing the binary data of the item + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param string $blob a base64-encoded string representing the binary data of the item + * @param ?array $meta Optional metadata */ public function __construct( string $uri, ?string $mimeType, public readonly string $blob, + ?array $meta = null, ) { - parent::__construct($uri, $mimeType); + parent::__construct($uri, $mimeType, $meta); } /** @@ -51,25 +54,29 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.'); } - return new self($data['uri'], $data['mimeType'] ?? null, $data['blob']); + return new self($data['uri'], $data['mimeType'] ?? null, $data['blob'], $data['_meta'] ?? null); } /** - * @param resource $stream - */ - public static function fromStream(string $uri, $stream, string $mimeType): self + * @param resource $stream + * @param ?array $meta Optional metadata + * */ + public static function fromStream(string $uri, $stream, string $mimeType, ?array $meta = null): self { $blob = stream_get_contents($stream); - return new self($uri, $mimeType, base64_encode($blob)); + return new self($uri, $mimeType, base64_encode($blob), $meta); } - public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null): self + /** + * @param ?array $meta Optional metadata + * */ + public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $explicitMimeType = null, ?array $meta = null): self { $mimeType = $explicitMimeType ?? mime_content_type($file->getPathname()); $blob = file_get_contents($file->getPathname()); - return new self($uri, $mimeType, base64_encode($blob)); + return new self($uri, $mimeType, base64_encode($blob), $meta); } /** @@ -79,7 +86,7 @@ public function jsonSerialize(): array { return [ 'blob' => $this->blob, - ...$this->jsonSerialize(), + ...parent::jsonSerialize(), ]; } } diff --git a/src/Schema/Content/ResourceContents.php b/src/Schema/Content/ResourceContents.php index d4c7733b..ffd5599b 100644 --- a/src/Schema/Content/ResourceContents.php +++ b/src/Schema/Content/ResourceContents.php @@ -16,7 +16,8 @@ * * @phpstan-type ResourceContentsData = array{ * uri: string, - * mimeType?: string|null + * mimeType?: string|null, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -24,12 +25,14 @@ abstract class ResourceContents implements \JsonSerializable { /** - * @param string $uri the URI of the resource or sub-resource - * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param ?array $meta Optional metadata */ public function __construct( public readonly string $uri, public readonly ?string $mimeType = null, + public readonly ?array $meta = null, ) { } @@ -43,6 +46,10 @@ public function jsonSerialize(): array $data['mimeType'] = $this->mimeType; } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } + return $data; } } diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php index 04880f59..47ee31fd 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -19,7 +19,8 @@ * @phpstan-type TextResourceContentsData array{ * uri: string, * mimeType?: string|null, - * text: string + * text: string, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -27,16 +28,18 @@ class TextResourceContents extends ResourceContents { /** - * @param string $uri the URI of the resource or sub-resource - * @param string|null $mimeType the MIME type of the resource or sub-resource - * @param string $text The text of the item. This must only be set if the item can actually be represented as text (not binary data). + * @param string $uri the URI of the resource or sub-resource + * @param string|null $mimeType the MIME type of the resource or sub-resource + * @param string $text The text of the item. This must only be set if the item can actually be represented as text (not binary data). + * @param ?array $meta Optional metadata */ public function __construct( string $uri, ?string $mimeType, public readonly string $text, + ?array $meta = null, ) { - parent::__construct($uri, $mimeType); + parent::__construct($uri, $mimeType, $meta); } /** @@ -51,7 +54,7 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.'); } - return new self($data['uri'], $data['mimeType'] ?? null, $data['text']); + return new self($data['uri'], $data['mimeType'] ?? null, $data['text'], $data['_meta'] ?? null); } /** diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 96cffcd9..c61efcd1 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -22,6 +22,7 @@ * name: string, * description?: string, * arguments?: PromptArgumentData[], + * _meta?: array * } * * @author Kyrian Obikwelu @@ -32,11 +33,13 @@ class Prompt implements \JsonSerializable * @param string $name the name of the prompt or prompt template * @param string|null $description an optional description of what this prompt provides * @param PromptArgument[]|null $arguments A list of arguments for templating. Null if not a template. + * @param ?array $meta Optional metadata */ public function __construct( public readonly string $name, public readonly ?string $description = null, public readonly ?array $arguments = null, + public readonly ?array $meta = null, ) { if (null !== $this->arguments) { foreach ($this->arguments as $arg) { @@ -60,10 +63,15 @@ public static function fromArray(array $data): self $arguments = array_map(fn (array $argData) => PromptArgument::fromArray($argData), $data['arguments']); } + if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in Prompt data.'); + } + return new self( name: $data['name'], description: $data['description'] ?? null, - arguments: $arguments + arguments: $arguments, + meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -71,7 +79,8 @@ public static function fromArray(array $data): self * @return array{ * name: string, * description?: string, - * arguments?: array + * arguments?: array, + * _meta?: array * } */ public function jsonSerialize(): array @@ -83,6 +92,9 @@ public function jsonSerialize(): array if (null !== $this->arguments) { $data['arguments'] = $this->arguments; } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } return $data; } diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index ca6fa11a..36ac5938 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -25,6 +25,7 @@ * mimeType?: string|null, * annotations?: AnnotationsData|null, * size?: int|null, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -43,14 +44,15 @@ class Resource implements \JsonSerializable private const URI_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*$/'; /** - * @param string $uri the URI of this resource - * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. - * @param string|null $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - * @param string|null $mimeType the MIME type of this resource, if known - * @param Annotations|null $annotations optional annotations for the client - * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @param string $uri the URI of this resource + * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. + * @param string|null $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param string|null $mimeType the MIME type of this resource, if known + * @param Annotations|null $annotations optional annotations for the client + * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @param ?array $meta Optional metadata * - * This can be used by Hosts to display file sizes and estimate context window usage. + * This can be used by Hosts to display file sizes and estimate context window usage */ public function __construct( public readonly string $uri, @@ -59,6 +61,7 @@ public function __construct( public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, public readonly ?int $size = null, + public readonly ?array $meta = null, ) { if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); @@ -80,13 +83,18 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "name" in Resource data.'); } + if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in Resource data.'); + } + return new self( uri: $data['uri'], name: $data['name'], description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null + size: isset($data['size']) ? (int) $data['size'] : null, + meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -98,6 +106,7 @@ public static function fromArray(array $data): self * mimeType?: string, * annotations?: Annotations, * size?: int, + * _meta?: array * } */ public function jsonSerialize(): array @@ -118,6 +127,9 @@ public function jsonSerialize(): array if (null !== $this->size) { $data['size'] = $this->size; } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } return $data; } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 796b3e22..136b8b6e 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -24,6 +24,7 @@ * description?: string|null, * mimeType?: string|null, * annotations?: AnnotationsData|null, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -42,11 +43,12 @@ class ResourceTemplate implements \JsonSerializable private const URI_TEMPLATE_PATTERN = '/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*{[^{}]+}.*/'; /** - * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs - * @param string $name A human-readable name for the type of resource this template refers to. This can be used by clients to populate UI elements. - * @param string|null $description This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - * @param string|null $mimeType The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - * @param Annotations|null $annotations optional annotations for the client + * @param string $uriTemplate a URI template (according to RFC 6570) that can be used to construct resource URIs + * @param string $name A human-readable name for the type of resource this template refers to. This can be used by clients to populate UI elements. + * @param string|null $description This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param string|null $mimeType The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + * @param Annotations|null $annotations optional annotations for the client + * @param ?array $meta Optional metadata */ public function __construct( public readonly string $uriTemplate, @@ -54,6 +56,7 @@ public function __construct( public readonly ?string $description = null, public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, + public readonly ?array $meta = null, ) { if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); @@ -75,12 +78,17 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); } + if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in ResourceTemplate data.'); + } + return new self( uriTemplate: $data['uriTemplate'], name: $data['name'], description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -91,6 +99,7 @@ public static function fromArray(array $data): self * description?: string, * mimeType?: string, * annotations?: Annotations, + * _meta?: array * } */ public function jsonSerialize(): array @@ -108,6 +117,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } return $data; } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 29646efc..c3613074 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -28,6 +28,7 @@ * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * _meta?: array * } * * @author Kyrian Obikwelu @@ -35,18 +36,20 @@ class Tool implements \JsonSerializable { /** - * @param string $name the name of the tool - * @param string|null $description A human-readable description of the tool. - * This can be used by clients to improve the LLM's understanding of - * available tools. It can be thought of like a "hint" to the model. - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ToolAnnotations|null $annotations optional additional tool information + * @param string $name the name of the tool + * @param string|null $description A human-readable description of the tool. + * This can be used by clients to improve the LLM's understanding of + * available tools. It can be thought of like a "hint" to the model. + * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool + * @param ToolAnnotations|null $annotations optional additional tool information + * @param ?array $meta Optional metadata */ public function __construct( public readonly string $name, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + public readonly ?array $meta = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); @@ -75,7 +78,8 @@ public static function fromArray(array $data): self $data['name'], $data['inputSchema'], isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, - isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null + isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null ); } @@ -85,6 +89,7 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * _meta?: array * } */ public function jsonSerialize(): array @@ -99,6 +104,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } return $data; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index f4327167..4c633d0a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -82,6 +82,7 @@ final class Builder * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * meta: ?array * }[] */ private array $tools = []; @@ -95,6 +96,7 @@ final class Builder * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * meta: ?array * }[] */ private array $resources = []; @@ -107,6 +109,7 @@ final class Builder * description: ?string, * mimeType: ?string, * annotations: ?Annotations, + * meta: ?array * }[] */ private array $resourceTemplates = []; @@ -116,6 +119,7 @@ final class Builder * handler: Handler, * name: ?string, * description: ?string, + * meta: ?array * }[] */ private array $prompts = []; From 660f150361dd5b23c35a0baf18a8d3b574447d37 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 9 Nov 2025 13:36:21 +0100 Subject: [PATCH 57/66] Rename $_meta properties to $meta (#134) --- src/Schema/Result/InitializeResult.php | 8 ++++---- src/Schema/Result/ListRootsResult.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index f76a1db5..5c184d63 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -32,13 +32,13 @@ class InitializeResult implements ResultInterface * @param ServerCapabilities $capabilities the capabilities of the server * @param Implementation $serverInfo information about the server * @param string|null $instructions Instructions describing how to use the server and its features. This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. - * @param array|null $_meta optional _meta field + * @param array|null $meta optional _meta field */ public function __construct( public readonly ServerCapabilities $capabilities, public readonly Implementation $serverInfo, public readonly ?string $instructions = null, - public readonly ?array $_meta = null, + public readonly ?array $meta = null, public readonly ?ProtocolVersion $protocolVersion = null, ) { } @@ -93,8 +93,8 @@ public function jsonSerialize(): array if (null !== $this->instructions) { $data['instructions'] = $this->instructions; } - if (null !== $this->_meta) { - $data['_meta'] = $this->_meta; + if (null !== $this->meta) { + $data['_meta'] = $this->meta; } return $data; diff --git a/src/Schema/Result/ListRootsResult.php b/src/Schema/Result/ListRootsResult.php index ab6c5c94..3abe2e0e 100644 --- a/src/Schema/Result/ListRootsResult.php +++ b/src/Schema/Result/ListRootsResult.php @@ -25,11 +25,11 @@ class ListRootsResult implements ResultInterface { /** * @param Root[] $roots an array of root URIs - * @param ?array $_meta optional metadata about the result + * @param ?array $meta optional metadata about the result */ public function __construct( public readonly array $roots, - public readonly ?array $_meta = null, + public readonly ?array $meta = null, ) { } @@ -45,8 +45,8 @@ public function jsonSerialize(): array 'roots' => array_values($this->roots), ]; - if (null !== $this->_meta) { - $result['_meta'] = $this->_meta; + if (null !== $this->meta) { + $result['_meta'] = $this->meta; } return $result; From 5804ba97739555eb52c7827fe2c41c5fb391940a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 9 Nov 2025 14:53:36 +0100 Subject: [PATCH 58/66] Add support for icons and website url (#141) --- examples/schema-showcase/server.php | 9 +- src/Schema/Enum/ProtocolVersion.php | 5 +- src/Schema/Icon.php | 91 +++++++++++++++++++ src/Schema/Implementation.php | 37 +++++++- src/Schema/Prompt.php | 13 ++- src/Schema/Resource.php | 27 ++++-- src/Schema/Tool.php | 13 ++- src/Server/Builder.php | 16 +++- tests/Inspector/InspectorSnapshotTestCase.php | 2 +- tests/Unit/Schema/IconTest.php | 88 ++++++++++++++++++ 10 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 src/Schema/Icon.php create mode 100644 tests/Unit/Schema/IconTest.php diff --git a/examples/schema-showcase/server.php b/examples/schema-showcase/server.php index b908cebd..86c2bf77 100644 --- a/examples/schema-showcase/server.php +++ b/examples/schema-showcase/server.php @@ -13,11 +13,18 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); +use Mcp\Schema\Icon; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; $server = Server::builder() - ->setServerInfo('Schema Showcase', '1.0.0') + ->setServerInfo( + 'Schema Showcase', + '1.0.0', + 'A showcase server demonstrating MCP schema capabilities.', + [new Icon('https://www.php.net/images/logos/php-logo-white.svg', 'image/svg+xml', ['any'])], + 'https://github.com/modelcontextprotocol/php-sdk', + ) ->setContainer(container()) ->setLogger(logger()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) diff --git a/src/Schema/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php index d2d44436..b580e709 100644 --- a/src/Schema/Enum/ProtocolVersion.php +++ b/src/Schema/Enum/ProtocolVersion.php @@ -13,12 +13,13 @@ /** * Available protocol versions for MCP. + * + * @author Illia Vasylevskyi */ enum ProtocolVersion: string { case V2024_11_05 = '2024-11-05'; - case V2025_03_26 = '2025-03-26'; - case V2025_06_18 = '2025-06-18'; + case V2025_11_25 = '2025-11-25'; } diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php new file mode 100644 index 00000000..b3cdd722 --- /dev/null +++ b/src/Schema/Icon.php @@ -0,0 +1,91 @@ + + */ +class Icon implements \JsonSerializable +{ + /** + * @param string $src a standard URI pointing to an icon resource + * @param ?string $mimeType optional override if the server's MIME type is missing or generic + * @param ?string[] $sizes optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for + * scalable formats like SVG. + */ + public function __construct( + public readonly string $src, + public readonly ?string $mimeType = null, + public readonly ?array $sizes = null, + ) { + if (empty($src)) { + throw new InvalidArgumentException('Icon "src" must be a non-empty string.'); + } + if (!preg_match('#^(https?://|data:)#', $src)) { + throw new InvalidArgumentException('Icon "src" must be a valid URL or data URI.'); + } + + if (null !== $sizes) { + foreach ($sizes as $size) { + if (!\is_string($size)) { + throw new InvalidArgumentException('Each size in "sizes" must be a string.'); + } + if (!preg_match('/^(any|\d+x\d+)$/', $size)) { + throw new InvalidArgumentException(\sprintf('Invalid size format "%s" in "sizes". Expected "WxH" or "any".', $size)); + } + } + } + } + + /** + * @param IconData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['src']) || !\is_string($data['src'])) { + throw new InvalidArgumentException('Invalid or missing "src" in Icon data.'); + } + + return new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null); + } + + /** + * @return IconData + */ + public function jsonSerialize(): array + { + $data = [ + 'src' => $this->src, + ]; + + if (null !== $this->mimeType) { + $data['mimeType'] = $this->mimeType; + } + + if (null !== $this->sizes) { + $data['sizes'] = $this->sizes; + } + + return $data; + } +} diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 6fc51242..22d58a6d 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -16,14 +16,21 @@ /** * Describes the name and version of an MCP implementation. * + * @phpstan-import-type IconData from Icon + * * @author Kyrian Obikwelu */ class Implementation implements \JsonSerializable { + /** + * @param ?Icon[] $icons + */ public function __construct( public readonly string $name = 'app', public readonly string $version = 'dev', public readonly ?string $description = null, + public readonly ?array $icons = null, + public readonly ?string $websiteUrl = null, ) { } @@ -31,6 +38,9 @@ public function __construct( * @param array{ * name: string, * version: string, + * description?: string, + * icons?: IconData[], + * websiteUrl?: string, * } $data */ public static function fromArray(array $data): self @@ -42,13 +52,30 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "version" in Implementation data.'); } - return new self($data['name'], $data['version'], $data['description'] ?? null); + if (isset($data['icons'])) { + if (!\is_array($data['icons'])) { + throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.'); + } + + $data['icons'] = array_map(Icon::fromArray(...), $data['icons']); + } + + return new self( + $data['name'], + $data['version'], + $data['description'] ?? null, + $data['icons'] ?? null, + $data['websiteUrl'] ?? null, + ); } /** * @return array{ * name: string, * version: string, + * description?: string, + * icons?: Icon[], + * websiteUrl?: string, * } */ public function jsonSerialize(): array @@ -62,6 +89,14 @@ public function jsonSerialize(): array $data['description'] = $this->description; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + + if (null !== $this->websiteUrl) { + $data['websiteUrl'] = $this->websiteUrl; + } + return $data; } } diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index c61efcd1..0fe41586 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -17,11 +17,13 @@ * A prompt or prompt template that the server offers. * * @phpstan-import-type PromptArgumentData from PromptArgument + * @phpstan-import-type IconData from Icon * * @phpstan-type PromptData array{ * name: string, * description?: string, * arguments?: PromptArgumentData[], + * icons?: IconData[], * _meta?: array * } * @@ -31,14 +33,16 @@ class Prompt implements \JsonSerializable { /** * @param string $name the name of the prompt or prompt template - * @param string|null $description an optional description of what this prompt provides - * @param PromptArgument[]|null $arguments A list of arguments for templating. Null if not a template. + * @param ?string $description an optional description of what this prompt provides + * @param ?PromptArgument[] $arguments A list of arguments for templating. Null if not a template. + * @param ?Icon[] $icons optional icons representing the prompt * @param ?array $meta Optional metadata */ public function __construct( public readonly string $name, public readonly ?string $description = null, public readonly ?array $arguments = null, + public readonly ?array $icons = null, public readonly ?array $meta = null, ) { if (null !== $this->arguments) { @@ -71,6 +75,7 @@ public static function fromArray(array $data): self name: $data['name'], description: $data['description'] ?? null, arguments: $arguments, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -80,6 +85,7 @@ public static function fromArray(array $data): self * name: string, * description?: string, * arguments?: array, + * icons?: Icon[], * _meta?: array * } */ @@ -92,6 +98,9 @@ public function jsonSerialize(): array if (null !== $this->arguments) { $data['arguments'] = $this->arguments; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Schema/Resource.php b/src/Schema/Resource.php index 36ac5938..ac33ed4d 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -17,15 +17,17 @@ * A known resource that the server is capable of reading. * * @phpstan-import-type AnnotationsData from Annotations + * @phpstan-import-type IconData from Icon * * @phpstan-type ResourceData array{ * uri: string, * name: string, - * description?: string|null, - * mimeType?: string|null, - * annotations?: AnnotationsData|null, - * size?: int|null, - * _meta?: array + * description?: string, + * mimeType?: string, + * annotations?: AnnotationsData, + * size?: int, + * icons?: IconData[], + * _meta?: array, * } * * @author Kyrian Obikwelu @@ -46,10 +48,11 @@ class Resource implements \JsonSerializable /** * @param string $uri the URI of this resource * @param string $name A human-readable name for this resource. This can be used by clients to populate UI elements. - * @param string|null $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - * @param string|null $mimeType the MIME type of this resource, if known - * @param Annotations|null $annotations optional annotations for the client - * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @param ?string $description A description of what this resource represents. This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + * @param ?string $mimeType the MIME type of this resource, if known + * @param ?Annotations $annotations optional annotations for the client + * @param ?int $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * @param ?Icon[] $icons optional icons representing the resource * @param ?array $meta Optional metadata * * This can be used by Hosts to display file sizes and estimate context window usage @@ -61,6 +64,7 @@ public function __construct( public readonly ?string $mimeType = null, public readonly ?Annotations $annotations = null, public readonly ?int $size = null, + public readonly ?array $icons = null, public readonly ?array $meta = null, ) { if (!preg_match(self::RESOURCE_NAME_PATTERN, $name)) { @@ -94,6 +98,7 @@ public static function fromArray(array $data): self mimeType: $data['mimeType'] ?? null, annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, size: isset($data['size']) ? (int) $data['size'] : null, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -106,6 +111,7 @@ public static function fromArray(array $data): self * mimeType?: string, * annotations?: Annotations, * size?: int, + * icons?: Icon[], * _meta?: array * } */ @@ -127,6 +133,9 @@ public function jsonSerialize(): array if (null !== $this->size) { $data['size'] = $this->size; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index c3613074..3a4e8193 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -17,6 +17,7 @@ * Definition for a tool the client can call. * * @phpstan-import-type ToolAnnotationsData from ToolAnnotations + * @phpstan-import-type IconData from Icon * * @phpstan-type ToolInputSchema array{ * type: 'object', @@ -28,6 +29,7 @@ * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * icons?: IconData[], * _meta?: array * } * @@ -37,11 +39,12 @@ class Tool implements \JsonSerializable { /** * @param string $name the name of the tool - * @param string|null $description A human-readable description of the tool. + * @param ?string $description A human-readable description of the tool. * This can be used by clients to improve the LLM's understanding of * available tools. It can be thought of like a "hint" to the model. * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ToolAnnotations|null $annotations optional additional tool information + * @param ?ToolAnnotations $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool * @param ?array $meta Optional metadata */ public function __construct( @@ -49,6 +52,7 @@ public function __construct( public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + public readonly ?array $icons = null, public readonly ?array $meta = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { @@ -79,6 +83,7 @@ public static function fromArray(array $data): self $data['inputSchema'], isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null ); } @@ -89,6 +94,7 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], * _meta?: array * } */ @@ -104,6 +110,9 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } if (null !== $this->meta) { $data['_meta'] = $this->meta; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 4c633d0a..9cc92f6a 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -21,6 +21,7 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\ToolAnnotations; @@ -145,10 +146,17 @@ final class Builder /** * Sets the server's identity. Required. + * + * @param ?Icon[] $icons */ - public function setServerInfo(string $name, string $version, ?string $description = null): self - { - $this->serverInfo = new Implementation(trim($name), trim($version), $description); + public function setServerInfo( + string $name, + string $version, + ?string $description = null, + ?array $icons = null, + ?string $websiteUrl = null, + ): self { + $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); return $this; } @@ -295,7 +303,7 @@ public function setDiscovery( return $this; } - public function setProtocolVersion(?ProtocolVersion $protocolVersion): self + public function setProtocolVersion(ProtocolVersion $protocolVersion): self { $this->protocolVersion = $protocolVersion; diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index c8335308..b28a9c1b 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.16.8'; + private const INSPECTOR_VERSION = '0.17.2'; /** @param array $options */ #[DataProvider('provideMethods')] diff --git a/tests/Unit/Schema/IconTest.php b/tests/Unit/Schema/IconTest.php new file mode 100644 index 00000000..f3906176 --- /dev/null +++ b/tests/Unit/Schema/IconTest.php @@ -0,0 +1,88 @@ +assertSame('https://www.php.net/images/logos/php-logo-white.svg', $icon->src); + $this->assertSame('image/svg+xml', $icon->mimeType); + $this->assertSame('any', $icon->sizes[0]); + } + + public function testConstructorWithMultipleSizes() + { + $icon = new Icon('https://example.com/icon.png', 'image/png', ['48x48', '96x96']); + + $this->assertCount(2, $icon->sizes); + $this->assertSame(['48x48', '96x96'], $icon->sizes); + } + + public function testConstructorWithAnySizes() + { + $icon = new Icon('https://example.com/icon.svg', 'image/png', ['any']); + + $this->assertSame(['any'], $icon->sizes); + } + + public function testConstructorWithNullOptionalFields() + { + $icon = new Icon('https://example.com/icon.png'); + + $this->assertSame('https://example.com/icon.png', $icon->src); + $this->assertNull($icon->mimeType); + $this->assertNull($icon->sizes); + } + + public function testInvalidSizesFormatThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('https://example.com/icon.png', 'image/png', ['invalid-size']); + } + + public function testInvalidPixelSizesFormatThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('https://example.com/icon.png', 'image/png', ['180x48x48']); + } + + public function testEmptySrcThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('', 'image/png', ['48x48']); + } + + public function testInvalidSrcThrowsException() + { + $this->expectException(InvalidArgumentException::class); + + new Icon('not-a-url', 'image/png', ['48x48']); + } + + public function testValidDataUriSrc() + { + $dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + $icon = new Icon($dataUri, 'image/png', ['48x48']); + + $this->assertSame($dataUri, $icon->src); + } +} From 982706851a6fcccf492d72636b5de33568226bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E6=9C=88?= <102944161+lvluoyue@users.noreply.github.com> Date: Wed, 12 Nov 2025 04:42:04 +0800 Subject: [PATCH 59/66] Change visibility of `handleFiberTermination` and `flushOutgoingMessages` to protected (#143) --- src/Server/Transport/StreamableHttpTransport.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 297cad17..28ca547e 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -208,7 +208,7 @@ protected function createStreamedResponse(): ResponseInterface return $this->withCorsHeaders($response); } - private function handleFiberTermination(): void + protected function handleFiberTermination(): void { $finalResult = $this->sessionFiber->getReturn(); @@ -227,7 +227,7 @@ private function handleFiberTermination(): void $this->sessionFiber = null; } - private function flushOutgoingMessages(?Uuid $sessionId): void + protected function flushOutgoingMessages(?Uuid $sessionId): void { $messages = $this->getOutgoingMessages($sessionId); From a9cd8d550f8d22bb2d4450562bcea72109fee5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Poirier=20Th=C3=A9or=C3=AAt?= Date: Tue, 11 Nov 2025 15:42:50 -0500 Subject: [PATCH 60/66] [Capability] Discovery fix McpTool meta parameters in place of icons (#145) --- src/Capability/Discovery/Discoverer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index e8f32940..2ad632c2 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -223,7 +223,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); $meta = $instance->meta ?? null; - $tool = new Tool($name, $inputSchema, $description, $instance->annotations, $meta); + $tool = new Tool($name, $inputSchema, $description, $instance->annotations, meta: $meta); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; From 8daca8129e230774c940bd99cd1b0e81e4bb1cc4 Mon Sep 17 00:00:00 2001 From: Bujar Begisholli - C24 Date: Wed, 12 Nov 2025 10:36:31 +0100 Subject: [PATCH 61/66] [Server] Add meta parameter to addTool and addResource methods (#144) --- src/Server/Builder.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9cc92f6a..2e9991eb 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -315,6 +315,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self * * @param Handler $handler * @param array|null $inputSchema + * @param array|null $meta */ public function addTool( callable|array|string $handler, @@ -322,8 +323,9 @@ public function addTool( ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, + ?array $meta = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'meta'); return $this; } @@ -331,7 +333,8 @@ public function addTool( /** * Manually registers a resource handler. * - * @param Handler $handler + * @param Handler $handler + * @param array|null $meta */ public function addResource( \Closure|array|string $handler, @@ -341,8 +344,9 @@ public function addResource( ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null, + ?array $meta = null, ): self { - $this->resources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + $this->resources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations', 'meta'); return $this; } From 63ffeed0cc31c4fef77e14eb8b2f01599820085c Mon Sep 17 00:00:00 2001 From: Bujar Begisholli - C24 Date: Wed, 12 Nov 2025 11:34:17 +0100 Subject: [PATCH 62/66] [Server] Fix wrong parameter order when creating the tool from array (#147) Previously, the meta parameter was incorrectly passed to the icons position due to missing icons argument. Also adds icons support to Builder::addTool() for consistency with the Tool schema. --- src/Capability/Registry/Loader/ArrayLoader.php | 10 +++++++++- src/Server/Builder.php | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index a826bf6d..b784fd47 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -45,6 +45,7 @@ final class ArrayLoader implements LoaderInterface * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * icons: ?array, * meta: ?array * }[] $tools * @param array{ @@ -106,7 +107,14 @@ public function load(ReferenceRegistryInterface $registry): void $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); - $tool = new Tool($name, $inputSchema, $description, $data['annotations'], $data['meta'] ?? null); + $tool = new Tool( + name: $name, + inputSchema: $inputSchema, + description: $description, + annotations: $data['annotations'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, + ); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 2e9991eb..ac8ec578 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -83,6 +83,7 @@ final class Builder * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * icons: ?array, * meta: ?array * }[] */ @@ -315,6 +316,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self * * @param Handler $handler * @param array|null $inputSchema + * @param Icon[]|null $icons * @param array|null $meta */ public function addTool( @@ -323,9 +325,10 @@ public function addTool( ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, + ?array $icons = null, ?array $meta = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'meta'); + $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'icons', 'meta'); return $this; } From 7a8fbe9b20f78e185ea233425d87880e2c825269 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 13 Nov 2025 21:42:30 +0100 Subject: [PATCH 63/66] Add tool name validation for SEP-986 (#140) --- phpstan.dist.neon | 4 ++ src/Capability/Registry.php | 16 ++++-- src/Capability/Tool/NameValidator.php | 20 +++++++ tests/Inspector/InspectorSnapshotTestCase.php | 1 - .../Unit/Capability/Registry/RegistryTest.php | 8 +-- .../Capability/Tool/NameValidatorTest.php | 56 +++++++++++++++++++ 6 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 src/Capability/Tool/NameValidator.php create mode 100644 tests/Unit/Capability/Tool/NameValidatorTest.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 17f4c3b0..900f12fd 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -15,6 +15,10 @@ parameters: ignoreErrors: - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + - + identifier: missingType.iterableValue + path: tests/ + # These errors should actually be fixed, but are ignored for now - identifier: missingType.iterableValue diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 3a13c323..381af919 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -18,6 +18,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\Tool\NameValidator; use Mcp\Event\PromptListChangedEvent; use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; @@ -71,6 +72,7 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), + private readonly NameValidator $nameValidator = new NameValidator(), ) { } @@ -100,12 +102,18 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered tool "%s" as it conflicts with a manually registered one.', $toolName), ); return; } + if (!$this->nameValidator->isValid($toolName)) { + $this->logger->warning( + \sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $toolName), + ); + } + $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); @@ -118,7 +126,7 @@ public function registerResource(Resource $resource, callable|array|string $hand if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri), ); return; @@ -140,7 +148,7 @@ public function registerResourceTemplate( if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate), ); return; @@ -167,7 +175,7 @@ public function registerPrompt( if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName), ); return; diff --git a/src/Capability/Tool/NameValidator.php b/src/Capability/Tool/NameValidator.php new file mode 100644 index 00000000..fe9bb084 --- /dev/null +++ b/src/Capability/Tool/NameValidator.php @@ -0,0 +1,20 @@ +> */ public static function provideMethods(): array { return [ diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index 33cb967e..4f3c4ddd 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -140,7 +140,7 @@ public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered tool 'test_tool' as it conflicts with a manually registered one."); + ->with('Ignoring discovered tool "test_tool" as it conflicts with a manually registered one.'); $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); @@ -181,7 +181,7 @@ public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered resource 'test://resource' as it conflicts with a manually registered one."); + ->with('Ignoring discovered resource "test://resource" as it conflicts with a manually registered one.'); $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); @@ -210,7 +210,7 @@ public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered template 'test://{id}' as it conflicts with a manually registered one."); + ->with('Ignoring discovered template "test://{id}" as it conflicts with a manually registered one.'); $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); @@ -239,7 +239,7 @@ public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered prompt 'test_prompt' as it conflicts with a manually registered one."); + ->with('Ignoring discovered prompt "test_prompt" as it conflicts with a manually registered one.'); $this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false); diff --git a/tests/Unit/Capability/Tool/NameValidatorTest.php b/tests/Unit/Capability/Tool/NameValidatorTest.php new file mode 100644 index 00000000..269be890 --- /dev/null +++ b/tests/Unit/Capability/Tool/NameValidatorTest.php @@ -0,0 +1,56 @@ +assertTrue((new NameValidator())->isValid($name)); + } + + public static function provideValidNames(): array + { + return [ + ['my_tool'], + ['MyTool123'], + ['my.tool'], + ['my-tool'], + ['my/tool'], + ['my_tool-01.02'], + ['my_long_toolname_that_is_exactly_sixty_four_characters_long_1234'], + ]; + } + + #[DataProvider('provideInvalidNames')] + public function testInvalidNames(string $name): void + { + $this->assertFalse((new NameValidator())->isValid($name)); + } + + public static function provideInvalidNames(): array + { + return [ + [''], + ['my tool'], + ['my@tool'], + ['my!tool'], + ['my_tool#1'], + ['this_tool_name_is_way_too_long_because_it_exceeds_the_sixty_four_character_limit_set_by_the_validator'], + ]; + } +} From 056c7b6103a779c4a958d56dc937f21ef997f571 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 13 Nov 2025 22:39:18 +0100 Subject: [PATCH 64/66] [Server] Add support for icons on explicit builder registration and attributes (#142) * Add support for icons on explicit builder registration and attributes * Update docs/server-builder.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/mcp-elements.md | 7 +++ docs/server-builder.md | 51 ++++++++++++++++++- examples/discovery-calculator/McpElements.php | 7 ++- src/Capability/Attribute/McpPrompt.php | 4 ++ src/Capability/Attribute/McpResource.php | 3 ++ src/Capability/Attribute/McpTool.php | 3 ++ src/Capability/Discovery/Discoverer.php | 28 ++++++---- .../Registry/Loader/ArrayLoader.php | 47 +++++++++-------- src/Server/Builder.php | 41 ++++++++++++--- tests/Inspector/InspectorSnapshotTestCase.php | 3 +- ...iscoveryCalculatorTest-resources_list.json | 9 ++++ ...dioDiscoveryCalculatorTest-tools_list.json | 9 ++++ 12 files changed, 173 insertions(+), 39 deletions(-) diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 1bb2690d..1846a7dd 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -72,6 +72,8 @@ class Calculator - **`name`** (optional): Tool identifier. Defaults to method name if not provided. - **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. - **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. **Priority for name/description**: Attribute parameters → DocBlock content → Method name @@ -217,6 +219,9 @@ class ConfigProvider - **`description`** (optional): Resource description. Defaults to docblock summary if not provided. - **`mimeType`** (optional): MIME type of the resource content. - **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. **Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). **Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. @@ -399,6 +404,8 @@ class PromptGenerator - **`name`** (optional): Prompt identifier. Defaults to method name if not provided. - **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. ### Prompt Return Values diff --git a/docs/server-builder.md b/docs/server-builder.md index ac131374..5b00f902 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -52,14 +52,25 @@ final `Server` instance ready for use. Set the server's identity with name, version, and optional description: ```php +use Mcp\Schema\Icon; +use Mcp\Server; + $server = Server::builder() - ->setServerInfo('Calculator Server', '1.2.0', 'Advanced mathematical calculations'); + ->setServerInfo( + name: 'Calculator Server', + version: '1.2.0', + description: 'Advanced mathematical calculations', + icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], + websiteUrl: 'https://example.com' + '); ``` **Parameters:** - `$name` (string): The server name - `$version` (string): Version string (semantic versioning recommended) - `$description` (string|null): Optional description +- `$icons` (Icon[]|null): Optional array of server icons +- `$websiteUrl` (string|null): Optional server website URL ### Pagination Limit @@ -263,6 +274,16 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The tool handler +- `name` (string|null): Optional tool name +- `description` (string|null): Optional tool description +- `annotations` (ToolAnnotations|null): Optional annotations for the tool +- `inputSchema` (array|null): Optional input schema for the tool +- `icons` (Icon[]|null): Optional array of icons for the tool +- `meta` (array|null): Optional metadata for the tool + ### Manual Resource Registration Register static resources: @@ -278,6 +299,18 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The resource handler +- `uri` (string): The resource URI +- `name` (string|null): Optional resource name +- `description` (string|null): Optional resource description +- `mimeType` (string|null): Optional MIME type of the resource +- `size` (int|null): Optional size of the resource in bytes +- `annotations` (Annotations|null): Optional annotations for the resource +- `icons` (Icon[]|null): Optional array of icons for the resource +- `meta` (array|null): Optional metadata for the resource + ### Manual Resource Template Registration Register dynamic resources with URI templates: @@ -293,6 +326,15 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The resource template handler +- `uriTemplate` (string): The resource URI template +- `name` (string|null): Optional resource template name +- `description` (string|null): Optional resource template description +- `mimeType` (string|null): Optional MIME type of the resource +- `annotations` (Annotations|null): Optional annotations for the resource template + ### Manual Prompt Registration Register prompt generators: @@ -306,6 +348,13 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The prompt handler +- `name` (string|null): Optional prompt name +- `description` (string|null): Optional prompt description +- `icons` (Icon[]|null): Optional array of icons for the prompt + **Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from the handler's method name and docblock. diff --git a/examples/discovery-calculator/McpElements.php b/examples/discovery-calculator/McpElements.php index 6276de57..972534d1 100644 --- a/examples/discovery-calculator/McpElements.php +++ b/examples/discovery-calculator/McpElements.php @@ -14,6 +14,7 @@ use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; +use Mcp\Schema\Icon; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -47,7 +48,10 @@ public function __construct( * * @return float the result of the calculation */ - #[McpTool(name: 'calculate')] + #[McpTool( + name: 'calculate', + icons: [new Icon('https://www.svgrepo.com/show/530644/calculator.svg', 'image/svg+xml', ['any'])], + )] public function calculate(float $a, float $b, string $operation): float { $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); @@ -92,6 +96,7 @@ public function calculate(float $a, float $b, string $operation): float name: 'calculator_config', description: 'Current settings for the calculator tool (precision, allow_negative).', mimeType: 'application/json', + icons: [new Icon('https://www.svgrepo.com/show/529867/settings.svg', 'image/svg+xml', ['any'])], )] public function getConfiguration(): array { diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 8488e608..0737c18d 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -11,6 +11,8 @@ namespace Mcp\Capability\Attribute; +use Mcp\Schema\Icon; + /** * Marks a PHP method as an MCP Prompt generator. * The method should return the prompt messages, potentially using arguments for templating. @@ -23,11 +25,13 @@ final class McpPrompt /** * @param ?string $name overrides the prompt name (defaults to method name) * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?Icon[] $icons Optional list of icon URLs representing the prompt * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?array $icons = null, public ?array $meta = null, ) { } diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 86b33078..2dcf85e6 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -12,6 +12,7 @@ namespace Mcp\Capability\Attribute; use Mcp\Schema\Annotations; +use Mcp\Schema\Icon; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. @@ -29,6 +30,7 @@ final class McpResource * @param ?string $mimeType the MIME type, if known and constant for this resource * @param ?int $size the size in bytes, if known and constant * @param Annotations|null $annotations optional annotations describing the resource + * @param ?Icon[] $icons Optional list of icon URLs representing the resource * @param ?array $meta Optional metadata */ public function __construct( @@ -38,6 +40,7 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, + public ?array $icons = null, public ?array $meta = null, ) { } diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index e5cab26b..85dbc225 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Attribute; +use Mcp\Schema\Icon; use Mcp\Schema\ToolAnnotations; /** @@ -23,12 +24,14 @@ class McpTool * @param string|null $name The name of the tool (defaults to the method name) * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param ?Icon[] $icons Optional list of icon URLs representing the tool * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, + public ?array $icons = null, public ?array $meta = null, ) { } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 2ad632c2..88ec4117 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -222,8 +222,14 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); - $meta = $instance->meta ?? null; - $tool = new Tool($name, $inputSchema, $description, $instance->annotations, meta: $meta); + $tool = new Tool( + $name, + $inputSchema, + $description, + $instance->annotations, + $instance->icons, + $instance->meta, + ); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -232,11 +238,16 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; - $mimeType = $instance->mimeType; - $size = $instance->size; - $annotations = $instance->annotations; - $meta = $instance->meta; - $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size, $meta); + $resource = new Resource( + $instance->uri, + $name, + $description, + $instance->mimeType, + $instance->annotations, + $instance->size, + $instance->icons, + $instance->meta, + ); $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); ++$discoveredCount['resources']; @@ -256,8 +267,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $meta = $instance->meta ?? null; - $prompt = new Prompt($name, $description, $arguments, $meta); + $prompt = new Prompt($name, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index b784fd47..03b2f852 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -22,6 +22,7 @@ use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\ConfigurationException; use Mcp\Schema\Annotations; +use Mcp\Schema\Icon; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; use Mcp\Schema\Resource; @@ -45,7 +46,7 @@ final class ArrayLoader implements LoaderInterface * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, - * icons: ?array, + * icons: ?Icon[], * meta: ?array * }[] $tools * @param array{ @@ -56,6 +57,7 @@ final class ArrayLoader implements LoaderInterface * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * icons: ?Icon[], * meta: ?array * }[] $resources * @param array{ @@ -71,14 +73,15 @@ final class ArrayLoader implements LoaderInterface * handler: Handler, * name: ?string, * description: ?string, + * icons: ?Icon[], * meta: ?array * }[] $prompts */ public function __construct( - private array $tools = [], - private array $resources = [], - private array $resourceTemplates = [], - private array $prompts = [], + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], private LoggerInterface $logger = new NullLogger(), ) { } @@ -145,13 +148,16 @@ public function load(ReferenceRegistryInterface $registry): void $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; } - $uri = $data['uri']; - $mimeType = $data['mimeType']; - $size = $data['size']; - $annotations = $data['annotations']; - $meta = $data['meta']; - - $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size, $meta); + $resource = new Resource( + $data['uri'], + $name, + $description, + $data['mimeType'], + $data['annotations'], + $data['size'], + $data['icons'], + $data['meta'], + ); $registry->registerResource($resource, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); @@ -182,12 +188,14 @@ public function load(ReferenceRegistryInterface $registry): void $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; } - $uriTemplate = $data['uriTemplate']; - $mimeType = $data['mimeType']; - $annotations = $data['annotations']; - $meta = $data['meta']; - - $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations, $meta); + $template = new ResourceTemplate( + $data['uriTemplate'], + $name, + $description, + $data['mimeType'], + $data['annotations'], + $data['meta'], + ); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); @@ -238,8 +246,7 @@ public function load(ReferenceRegistryInterface $registry): void !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } - $meta = $data['meta']; - $prompt = new Prompt($name, $description, $arguments, $meta); + $prompt = new Prompt($name, $description, $arguments, $data['icons'], $data['meta']); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index ac8ec578..a0ab63e9 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -83,7 +83,7 @@ final class Builder * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, - * icons: ?array, + * icons: ?Icon[], * meta: ?array * }[] */ @@ -98,6 +98,7 @@ final class Builder * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * icons: ?Icon[], * meta: ?array * }[] */ @@ -121,6 +122,7 @@ final class Builder * handler: Handler, * name: ?string, * description: ?string, + * icons: ?Icon[], * meta: ?array * }[] */ @@ -316,7 +318,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self * * @param Handler $handler * @param array|null $inputSchema - * @param Icon[]|null $icons + * @param ?Icon[] $icons * @param array|null $meta */ public function addTool( @@ -328,7 +330,15 @@ public function addTool( ?array $icons = null, ?array $meta = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'icons', 'meta'); + $this->tools[] = compact( + 'handler', + 'name', + 'description', + 'annotations', + 'inputSchema', + 'icons', + 'meta', + ); return $this; } @@ -337,6 +347,7 @@ public function addTool( * Manually registers a resource handler. * * @param Handler $handler + * @param ?Icon[] $icons * @param array|null $meta */ public function addResource( @@ -347,9 +358,20 @@ public function addResource( ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null, + ?array $icons = null, ?array $meta = null, ): self { - $this->resources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations', 'meta'); + $this->resources[] = compact( + 'handler', + 'uri', + 'name', + 'description', + 'mimeType', + 'size', + 'annotations', + 'icons', + 'meta', + ); return $this; } @@ -383,10 +405,15 @@ public function addResourceTemplate( * Manually registers a prompt handler. * * @param Handler $handler + * @param ?Icon[] $icons */ - public function addPrompt(\Closure|array|string $handler, ?string $name = null, ?string $description = null): self - { - $this->prompts[] = compact('handler', 'name', 'description'); + public function addPrompt( + \Closure|array|string $handler, + ?string $name = null, + ?string $description = null, + ?array $icons = null, + ): self { + $this->prompts[] = compact('handler', 'name', 'description', 'icons'); return $this; } diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index 690f2abe..292670ae 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -109,7 +109,8 @@ public function testOutputMatchesSnapshot( $expected = file_get_contents($snapshotFile); - $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput); + $message = \sprintf('Output does not match snapshot "%s".', $snapshotFile); + $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput, $message); } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json index a40f3489..e72548b5 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json @@ -4,6 +4,15 @@ "name": "calculator_config", "uri": "config://calculator/settings", "description": "Current settings for the calculator tool (precision, allow_negative).", + "icons": [ + { + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ], + "src": "https://www.svgrepo.com/show/529867/settings.svg" + } + ], "mimeType": "application/json" } ] diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json index d4a4531a..5f184117 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json @@ -3,6 +3,15 @@ { "name": "calculate", "description": "Performs a calculation based on the operation.", + "icons": [ + { + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ], + "src": "https://www.svgrepo.com/show/530644/calculator.svg" + } + ], "inputSchema": { "type": "object", "properties": { From 7a3b473c405411aca8be14c0c6e12d2493752d61 Mon Sep 17 00:00:00 2001 From: Bujar Begisholli - C24 Date: Thu, 13 Nov 2025 22:43:40 +0100 Subject: [PATCH 65/66] Add _meta to CallToolResult (#148) --- src/Schema/Result/CallToolResult.php | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index da3be4f6..4f31e034 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -38,14 +38,16 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param Content[] $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). - * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param array|null $meta Optional metadata */ public function __construct( public readonly array $content, public readonly bool $isError = false, public readonly ?array $structuredContent = null, + public readonly ?array $meta = null, ) { foreach ($this->content as $item) { if (!$item instanceof Content) { @@ -57,27 +59,30 @@ public function __construct( /** * Create a new CallToolResult with success status. * - * @param Content[] $content The content of the tool result + * @param Content[] $content The content of the tool result + * @param array|null $meta Optional metadata */ - public static function success(array $content): self + public static function success(array $content, ?array $meta = null): self { - return new self($content, false); + return new self($content, false, null, $meta); } /** * Create a new CallToolResult with error status. * - * @param Content[] $content The content of the tool result + * @param Content[] $content The content of the tool result + * @param array|null $meta Optional metadata */ - public static function error(array $content): self + public static function error(array $content, ?array $meta = null): self { - return new self($content, true); + return new self($content, true, null, $meta); } /** * @param array{ * content: array, * isError?: bool, + * _meta?: array, * } $data */ public static function fromArray(array $data): self @@ -98,13 +103,20 @@ public static function fromArray(array $data): self }; } - return new self($contents, $data['isError'] ?? false); + return new self( + $contents, + $data['isError'] ?? false, + $data['structuredContent'] ?? null, + $data['_meta'] ?? null + ); } /** * @return array{ * content: array, * isError: bool, + * structuredContent?: array, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -118,6 +130,10 @@ public function jsonSerialize(): array $result['structuredContent'] = $this->structuredContent; } + if ($this->meta) { + $result['_meta'] = $this->meta; + } + return $result; } } From 7a58ab3f5ae03a085c37c7eb8b7678f197832b25 Mon Sep 17 00:00:00 2001 From: Bujar Begisholli - C24 Date: Fri, 14 Nov 2025 17:11:38 +0100 Subject: [PATCH 66/66] Add meta to addPrompt and addResourceTemplate methods and null coalesce nullable properties (#149) --- .../Registry/Loader/ArrayLoader.php | 36 +++++++++++-------- src/Server/Builder.php | 13 ++++--- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 03b2f852..8fcfd675 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -149,14 +149,14 @@ public function load(ReferenceRegistryInterface $registry): void } $resource = new Resource( - $data['uri'], - $name, - $description, - $data['mimeType'], - $data['annotations'], - $data['size'], - $data['icons'], - $data['meta'], + uri: $data['uri'], + name: $name, + description: $description, + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + size: $data['size'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, ); $registry->registerResource($resource, $data['handler'], true); @@ -189,12 +189,12 @@ public function load(ReferenceRegistryInterface $registry): void } $template = new ResourceTemplate( - $data['uriTemplate'], - $name, - $description, - $data['mimeType'], - $data['annotations'], - $data['meta'], + uriTemplate: $data['uriTemplate'], + name: $name, + description: $description, + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + meta: $data['meta'] ?? null, ); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); @@ -246,7 +246,13 @@ public function load(ReferenceRegistryInterface $registry): void !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } - $prompt = new Prompt($name, $description, $arguments, $data['icons'], $data['meta']); + $prompt = new Prompt( + name: $name, + description: $description, + arguments: $arguments, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null + ); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); diff --git a/src/Server/Builder.php b/src/Server/Builder.php index a0ab63e9..b3aad3be 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -379,7 +379,8 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param Handler $handler + * @param Handler $handler + * @param array|null $meta */ public function addResourceTemplate( \Closure|array|string $handler, @@ -388,6 +389,7 @@ public function addResourceTemplate( ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null, + ?array $meta = null, ): self { $this->resourceTemplates[] = compact( 'handler', @@ -396,6 +398,7 @@ public function addResourceTemplate( 'description', 'mimeType', 'annotations', + 'meta', ); return $this; @@ -404,16 +407,18 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param Handler $handler - * @param ?Icon[] $icons + * @param Handler $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addPrompt( \Closure|array|string $handler, ?string $name = null, ?string $description = null, ?array $icons = null, + ?array $meta = null, ): self { - $this->prompts[] = compact('handler', 'name', 'description', 'icons'); + $this->prompts[] = compact('handler', 'name', 'description', 'icons', 'meta'); return $this; }