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 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 3c7c26e2..8f118b26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .phpunit.cache .php-cs-fixer.cache composer.lock +coverage vendor examples/**/dev.log +examples/**/cache +examples/**/sessions 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/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/README.md b/README.md index 84db63ea..5a94dae3 100644 --- a/README.md +++ b/README.md @@ -1,49 +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). - -## 🚧 Roadmap - -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 -- [ ] 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 +Until the first major release, this SDK is considered [experimental](https://symfony.com/doc/current/contributing/code/experimental.html). + +## Roadmap + +**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 @@ -51,19 +29,13 @@ Examples working 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 -## ⚡ Quick Start: Stdio Server with Discovery +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 @@ -97,54 +102,184 @@ require_once __DIR__ . '/vendor/autoload.php'; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; -Server::make() - ->withServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') - ->withDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport()); +$server = Server::builder() + ->setServerInfo('Calculator Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); + +$transport = new StdioTransport(); + +$server->run($transport); ``` -**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 { /* ... */ } +``` -Your AI assistant can now call: -- `add_numbers` - Add two integers +### Manual Registration + +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->run($transport); +``` + +**HTTP Transport** (Web-based communication): +```php +$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); +$response = $server->run($transport); +// 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\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() + ->setSession(ttl: 7200) // 2 hours + ->build(); + +// Override with file-based storage +$server = Server::builder() + ->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(); +``` + +### 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 +- [Client Communication](docs/client-communication.md) - Communicating back to the client from server-side + +**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/composer.json b/composer.json index 4d94c1a2..7f25eee7 100644 --- a/composer.json +++ b/composer.json @@ -1,60 +1,75 @@ { - "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/container": "^2.0", - "psr/event-dispatcher": "^1.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" + { + "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\\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", + "php-http/discovery": "^1.20", + "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 || ^8.0", + "symfony/uid": "^6.4 || ^7.3 || ^8.0" + }, + "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 || ^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" + }, + "autoload": { + "psr-4": { + "Mcp\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "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/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false } + } } 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 new file mode 100644 index 00000000..e004636e --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,279 @@ +# 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) +- [Examples](#examples) + +## 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 + +The bootstrapping of the example will choose the used transport based on the SAPI you use. + +### STDIO Transport + +The STDIO transport will use standard input/output for communication: + +```bash +# Interactive testing with MCP Inspector +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/discovery-calculator/server.php + +# Or configure the script path in your MCP client +# Path: php examples/discovery-calculator/server.php +``` + +### HTTP Transport + +The Streamable HTTP transport will be chosen if running examples with a web servers: + +```bash +# Start the server +php -S localhost:8000 examples/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":{}}}' +``` + +## Examples + +### Discovery Calculator + +**File**: `examples/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/discovery-calculator/server.php + +# Or configure in MCP client: php examples/discovery-calculator/server.php +``` + +### Explicit Registration + +**File**: `examples/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/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/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/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) +``` + +### Client Communication + +**File**: `examples/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 + +### Discovery User Profile + +**File**: `examples/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/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/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/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/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..1846a7dd --- /dev/null +++ b/docs/mcp-elements.md @@ -0,0 +1,768 @@ +# 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. +- **`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 + +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 + +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 ToolCallException('Division by zero is not allowed'); + } + + return $a / $b; +} + +#[McpTool] +public function processFile(string $filename): string +{ + if (!file_exists($filename)) { + throw new ToolCallException("File not found: {$filename}"); + } + + return file_get_contents($filename); +} +``` + +**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 + +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. +- **`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. + +### 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 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 ResourceReadException("File not found: {$path}"); + } + + if (!is_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 +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. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. + +### 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')]) + ]; +} +``` + +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 +- **`assistant`**: Assistant responses/system + +#### Error Handling + +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 PromptGetException( + "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) + ); + } + + return [ + ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] + ]; +} +``` + +**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 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..5b00f902 --- /dev/null +++ b/docs/server-builder.md @@ -0,0 +1,584 @@ +# 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 Message Handlers](#custom-message-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 +use Mcp\Schema\Icon; +use Mcp\Server; + +$server = Server::builder() + ->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 + +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 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() + ->setSession(ttl: 7200) // 2 hours + ->build(); + +// Override with file-based storage +$server = Server::builder() + ->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:** + +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 + ); +``` + +#### 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: + +```php +$server = Server::builder() + ->addResource( + handler: [Config::class, 'getSettings'], + uri: 'config://app/settings', + name: 'app_config', + description: 'Application configuration', + mimeType: 'application/json' + ); +``` + +#### 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: + +```php +$server = Server::builder() + ->addResourceTemplate( + handler: [UserService::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'User profile by ID', + mimeType: 'application/json' + ); +``` + +#### 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: + +```php +$server = Server::builder() + ->addPrompt( + handler: [PromptService::class, 'generatePrompt'], + name: 'custom_prompt', + description: 'A custom prompt generator' + ); +``` + +#### 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. + +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 Message Handlers + +**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. + +> **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() + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ + new CustomCallToolHandler(), + new CustomGetPromptHandler(), + ]) + ->build(); +``` + +Request handlers implement `RequestHandlerInterface`: + +```php +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 RequestHandlerInterface +{ + public function supports(Request $request): bool; + + public function handle(Request $request, SessionInterface $session): Response|Error; +} +``` + +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) + +### Notification Handlers + +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 + +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 | +| `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 | +| `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..290fd49c --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,384 @@ +# 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(); + +$result = $server->run($transport); // Blocks for STDIO, returns a 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(); + +$status = $server->run($transport); + +exit($status); // 0 on clean shutdown, non-zero if STDIN errored +``` + +### 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; + +// PSR-17 factories are automatically discovered +$transport = new StreamableHttpTransport( + 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`** (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 + +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); +``` + +### 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 +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 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 Laminas\HttpHandlerRunner\Emitter\SapiEmitter; + +$psr17Factory = new Psr17Factory(); +$request = $psr17Factory->createServerRequestFromGlobals(); + +$server = Server::builder() + ->setServerInfo('HTTP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions + ->build(); + +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); + +(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 Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +class McpController +{ + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response + { + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); + $httpFoundationFactory = new HttpFoundationFactory(); + $psrRequest = $psrHttpFactory->createRequest($request); + + // 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); + } +} +``` + +#### 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; + +class McpController +{ + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface + { + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + + // Process MCP request and return PSR-7 response + // Laravel automatically handles PSR-7 responses + return $server->run($transport); + } +} + +// 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 Slim\Factory\AppFactory; +use Mcp\Server; +use Mcp\Server\Transport\StreamableHttpTransport; + +$app = AppFactory::create(); + +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() + ->setServerInfo('My MCP Server', '1.0.0') + ->setDiscovery(__DIR__, ['.']) + ->build(); + + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); +}); +``` + +### 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. diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php deleted file mode 100644 index b54b6e98..00000000 --- a/examples/01-discovery-stdio-calculator/server.php +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env php -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__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); - -logger()->info('Server listener stopped gracefully.'); diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php deleted file mode 100644 index cea4001c..00000000 --- a/examples/03-manual-registration-stdio/server.php +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env php -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') - ->build() - ->connect(new StdioTransport(logger: logger())); - -logger()->info('Server listener stopped gracefully.'); diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php deleted file mode 100644 index 4f86c409..00000000 --- a/examples/04-combined-registration-http/server.php +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env php -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( - [ManualHandlers::class, 'getPriorityConfigManual'], - 'config://priority', - 'priority_config_manual', - ) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined')); - -logger()->info('Server listener stopped gracefully.'); diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php deleted file mode 100644 index a9fe46d2..00000000 --- a/examples/06-custom-dependencies-stdio/server.php +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env php -info('Starting MCP Custom Dependencies (Stdio) Server...'); - -$container = container(); - -$taskRepo = new Services\InMemoryTaskRepository(logger()); -$container->set(Services\TaskRepositoryInterface::class, $taskRepo); - -$statsService = new Services\SystemStatsService($taskRepo); -$container->set(Services\StatsServiceInterface::class, $statsService); - -Server::make() - ->withServerInfo('Task Manager Server', '1.0.0') - ->withLogger(logger()) - ->withContainer($container) - ->withDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); - -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 deleted file mode 100644 index bef31e56..00000000 --- a/examples/07-complex-tool-schema-http/server.php +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env php -info('Starting MCP Complex Schema HTTP Server...'); - -Server::make() - ->withServerInfo('Event Scheduler Server', '1.0.0') - ->withLogger(logger()) - ->withContainer(container()) - ->withDiscovery(__DIR__, ['.']) - ->build() - ->connect(new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler')); - -logger()->info('Server listener stopped gracefully.'); diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php deleted file mode 100644 index df348a5a..00000000 --- a/examples/08-schema-showcase-streamable/server.php +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env php -info('Starting MCP Schema Showcase Server...'); - -Server::make() - ->withServerInfo('Schema Showcase', '1.0.0') - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); - -logger()->info('Server listener stopped gracefully.'); 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 f7a67423..00000000 --- a/examples/09-standalone-cli/index.php +++ /dev/null @@ -1,37 +0,0 @@ -connect($transport); 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 0eb9010a..00000000 --- a/examples/09-standalone-cli/src/ExampleTool.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -class ExampleTool implements MetadataInterface, ToolExecutorInterface -{ - 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/README.md b/examples/README.md index eb7a63fb..27874d71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,12 +2,16 @@ 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. +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 -php examples/01-discovery-stdio-calculator/server.php +# For using the STDIO transport: +php examples/discovery-calculator/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. @@ -15,7 +19,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/discovery-calculator/server.php ``` ## Debugging @@ -26,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/01-discovery-stdio-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 ca332791..8c0508ab 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -9,21 +9,49 @@ * 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; 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); }); +/** + * @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 { @@ -42,11 +70,11 @@ 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); + } else { + fwrite(\STDERR, $logMessage); } - - fwrite(\STDERR, $logMessage); } }; } diff --git a/examples/cached-discovery/CachedCalculatorElements.php b/examples/cached-discovery/CachedCalculatorElements.php new file mode 100644 index 00000000..ef67b0ff --- /dev/null +++ b/examples/cached-discovery/CachedCalculatorElements.php @@ -0,0 +1,54 @@ +info('Starting MCP Cached Discovery Calculator Server...'); + +$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(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/client-communication/ClientAwareService.php b/examples/client-communication/ClientAwareService.php new file mode 100644 index 00000000..3733614e --- /dev/null +++ b/examples/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/client-communication/server.php b/examples/client-communication/server.php new file mode 100644 index 00000000..7d586bad --- /dev/null +++ b/examples/client-communication/server.php @@ -0,0 +1,62 @@ +#!/usr/bin/env php +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( + 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(); + +$result = $server->run(transport()); + +shutdown($result); diff --git a/examples/04-combined-registration-http/DiscoveredElements.php b/examples/combined-registration/DiscoveredElements.php similarity index 92% rename from examples/04-combined-registration-http/DiscoveredElements.php rename to examples/combined-registration/DiscoveredElements.php index aacaf2a9..f7142466 100644 --- a/examples/04-combined-registration-http/DiscoveredElements.php +++ b/examples/combined-registration/DiscoveredElements.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedHttpExample; +namespace Mcp\Example\CombinedRegistration; 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/combined-registration/ManualHandlers.php similarity index 89% rename from examples/04-combined-registration-http/ManualHandlers.php rename to examples/combined-registration/ManualHandlers.php index ebac86e0..65a86bc6 100644 --- a/examples/04-combined-registration-http/ManualHandlers.php +++ b/examples/combined-registration/ManualHandlers.php @@ -9,14 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\CombinedHttpExample; +namespace Mcp\Example\CombinedRegistration; use Psr\Log\LoggerInterface; -class ManualHandlers +final class ManualHandlers { public function __construct( - private LoggerInterface $logger, + private readonly LoggerInterface $logger, ) { } diff --git a/examples/combined-registration/server.php b/examples/combined-registration/server.php new file mode 100644 index 00000000..02f26a4e --- /dev/null +++ b/examples/combined-registration/server.php @@ -0,0 +1,36 @@ +#!/usr/bin/env php +setServerInfo('Combined HTTP Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->addTool([ManualHandlers::class, 'manualGreeter']) + ->addResource( + [ManualHandlers::class, 'getPriorityConfigManual'], + 'config://priority', + 'priority_config_manual', + ) + ->build(); + +$response = $server->run(transport()); + +shutdown($response); diff --git a/examples/07-complex-tool-schema-http/McpEventScheduler.php b/examples/complex-tool-schema/McpEventScheduler.php similarity index 76% rename from examples/07-complex-tool-schema-http/McpEventScheduler.php rename to examples/complex-tool-schema/McpEventScheduler.php index 0b29cdeb..253ff5cb 100644 --- a/examples/07-complex-tool-schema-http/McpEventScheduler.php +++ b/examples/complex-tool-schema/McpEventScheduler.php @@ -9,17 +9,17 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ComplexSchemaHttpExample; +namespace Mcp\Example\ComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\ComplexSchemaHttpExample\Model\EventPriority; -use Mcp\ComplexSchemaHttpExample\Model\EventType; +use Mcp\Example\ComplexToolSchema\Model\EventPriority; +use Mcp\Example\ComplexToolSchema\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/07-complex-tool-schema-http/Model/EventPriority.php b/examples/complex-tool-schema/Model/EventPriority.php similarity index 86% rename from examples/07-complex-tool-schema-http/Model/EventPriority.php rename to examples/complex-tool-schema/Model/EventPriority.php index 1ec29543..e46a69da 100644 --- a/examples/07-complex-tool-schema-http/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\ComplexSchemaHttpExample\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/07-complex-tool-schema-http/Model/EventType.php b/examples/complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/07-complex-tool-schema-http/Model/EventType.php rename to examples/complex-tool-schema/Model/EventType.php index 2965c634..eaf0f431 100644 --- a/examples/07-complex-tool-schema-http/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\ComplexSchemaHttpExample\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventType: string { diff --git a/examples/complex-tool-schema/server.php b/examples/complex-tool-schema/server.php new file mode 100644 index 00000000..92f80b61 --- /dev/null +++ b/examples/complex-tool-schema/server.php @@ -0,0 +1,29 @@ +#!/usr/bin/env php +setServerInfo('Event Scheduler Server', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +$response = $server->run(transport()); + +shutdown($response); diff --git a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php b/examples/custom-dependencies/McpTaskHandlers.php similarity index 77% rename from examples/06-custom-dependencies-stdio/McpTaskHandlers.php rename to examples/custom-dependencies/McpTaskHandlers.php index ea32adb6..00127a78 100644 --- a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php +++ b/examples/custom-dependencies/McpTaskHandlers.php @@ -9,20 +9,23 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample; +namespace Mcp\Example\CustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\DependenciesStdioExample\Services\StatsServiceInterface; -use Mcp\DependenciesStdioExample\Services\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\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/custom-dependencies/Service/InMemoryTaskRepository.php similarity index 83% rename from examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php rename to examples/custom-dependencies/Service/InMemoryTaskRepository.php index a6c5315c..dbf3b6ab 100644 --- a/examples/06-custom-dependencies-stdio/Service/InMemoryTaskRepository.php +++ b/examples/custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,21 +9,24 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; 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/custom-dependencies/Service/StatsServiceInterface.php similarity index 77% rename from examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php rename to examples/custom-dependencies/Service/StatsServiceInterface.php index 2c94c002..079f7e23 100644 --- a/examples/06-custom-dependencies-stdio/Service/StatsServiceInterface.php +++ b/examples/custom-dependencies/Service/StatsServiceInterface.php @@ -9,9 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; interface StatsServiceInterface { + /** + * @return array + */ public function getSystemStats(): array; } diff --git a/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php b/examples/custom-dependencies/Service/SystemStatsService.php similarity index 72% rename from examples/06-custom-dependencies-stdio/Service/SystemStatsService.php rename to examples/custom-dependencies/Service/SystemStatsService.php index 845e3486..5cd44880 100644 --- a/examples/06-custom-dependencies-stdio/Service/SystemStatsService.php +++ b/examples/custom-dependencies/Service/SystemStatsService.php @@ -9,15 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\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/custom-dependencies/Service/TaskRepositoryInterface.php similarity index 65% rename from examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php rename to examples/custom-dependencies/Service/TaskRepositoryInterface.php index 7ae1c8a4..7216634c 100644 --- a/examples/06-custom-dependencies-stdio/Service/TaskRepositoryInterface.php +++ b/examples/custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,14 +9,26 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\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/custom-dependencies/server.php b/examples/custom-dependencies/server.php new file mode 100644 index 00000000..cd450e51 --- /dev/null +++ b/examples/custom-dependencies/server.php @@ -0,0 +1,45 @@ +#!/usr/bin/env php +info('Starting MCP Custom Dependencies Server...'); + +$container = container(); + +$taskRepo = new InMemoryTaskRepository(logger()); +$container->set(TaskRepositoryInterface::class, $taskRepo); + +$statsService = new SystemStatsService($taskRepo); +$container->set(StatsServiceInterface::class, $statsService); + +$server = Server::builder() + ->setServerInfo('Task Manager Server', '1.0.0') + ->setContainer($container) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) + ->setDiscovery(__DIR__) + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); 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 new file mode 100644 index 00000000..62f41df9 --- /dev/null +++ b/examples/custom-method-handlers/server.php @@ -0,0 +1,70 @@ +#!/usr/bin/env php +info('Starting MCP Custom Method Handlers 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 ListToolsRequestHandler($toolDefinitions); +$callToolHandler = new CallToolRequestHandler($toolDefinitions); +$capabilities = new ServerCapabilities(tools: true, resources: false, prompts: false); + +$server = Server::builder() + ->setServerInfo('Custom Handlers Server', '1.0.0') + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) + ->setCapabilities($capabilities) + ->addRequestHandlers([$listToolsHandler, $callToolHandler]) + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/01-discovery-stdio-calculator/McpElements.php b/examples/discovery-calculator/McpElements.php similarity index 86% rename from examples/01-discovery-stdio-calculator/McpElements.php rename to examples/discovery-calculator/McpElements.php index 8f05b96c..972534d1 100644 --- a/examples/01-discovery-stdio-calculator/McpElements.php +++ b/examples/discovery-calculator/McpElements.php @@ -9,17 +9,19 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCalculatorExample; +namespace Mcp\Example\DiscoveryCalculator; 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; /** * @phpstan-type Config array{precision: int, allow_negative: bool} */ -class McpElements +final class McpElements { /** * @var Config @@ -44,10 +46,13 @@ 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 + #[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)); @@ -65,16 +70,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']); @@ -91,6 +96,7 @@ public function calculate(float $a, float $b, string $operation): float|string 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/examples/discovery-calculator/server.php b/examples/discovery-calculator/server.php new file mode 100644 index 00000000..c6d75e4d --- /dev/null +++ b/examples/discovery-calculator/server.php @@ -0,0 +1,34 @@ +#!/usr/bin/env php +info('Starting MCP Calculator Server...'); + +$server = Server::builder() + ->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(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/discovery-userprofile/McpElements.php similarity index 80% rename from examples/02-discovery-http-userprofile/McpElements.php rename to examples/discovery-userprofile/McpElements.php index 600f6ecc..8418f09a 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/discovery-userprofile/McpElements.php @@ -9,18 +9,27 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpUserProfileExample; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpResourceTemplate; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\PromptGetException; +use Mcp\Exception\ResourceReadException; 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,9 +37,9 @@ class McpElements ]; public function __construct( - private LoggerInterface $logger, + private readonly LoggerInterface $logger, ) { - $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); + $this->logger->debug('DiscoveryUserProfile McpElements instantiated.'); } /** @@ -38,9 +47,9 @@ 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 + * @throws ResourceReadException if the user is not found */ #[McpResourceTemplate( uriTemplate: 'user://{userId}/profile', @@ -54,8 +63,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 ResourceReadException("User not found for ID: {$userId}"); } return $this->users[$userId]; @@ -64,7 +72,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 +94,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 +114,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,9 +129,9 @@ 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 + * @throws PromptGetException if user not found */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( @@ -130,7 +141,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 PromptGetException("User not found for bio prompt: {$userId}"); } $user = $this->users[$userId]; diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/discovery-userprofile/UserIdCompletionProvider.php similarity index 50% rename from examples/02-discovery-http-userprofile/UserIdCompletionProvider.php rename to examples/discovery-userprofile/UserIdCompletionProvider.php index c11fb609..69dfe4f0 100644 --- a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php +++ b/examples/discovery-userprofile/UserIdCompletionProvider.php @@ -9,16 +9,16 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpUserProfileExample; +namespace Mcp\Example\DiscoveryUserProfile; -use Mcp\Capability\Prompt\Completion\ProviderInterface; +use Mcp\Capability\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/discovery-userprofile/server.php similarity index 74% rename from examples/02-discovery-http-userprofile/server.php rename to examples/discovery-userprofile/server.php index 2a76580c..48033716 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/discovery-userprofile/server.php @@ -13,22 +13,16 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Capability\Registry\Container; use Mcp\Server; -use Psr\Log\LoggerInterface; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP HTTP User Profile Server...'); - -// --- Setup DI Container for DI in McpElements class --- -$container = new Container(); -$container->set(LoggerInterface::class, logger()); - -Server::make() - ->withServerInfo('HTTP User Profiles', '1.0.0') - ->withLogger(logger()) - ->withContainer($container) - ->withDiscovery(__DIR__, ['.']) - ->withTool( +$server = Server::builder() + ->setServerInfo('HTTP User Profiles', '1.0.0') + ->setLogger(logger()) + ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->addTool( function (float $a, float $b, string $operation = 'add'): array { $result = match ($operation) { 'add' => $a + $b, @@ -47,11 +41,11 @@ 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); - $uptime = time() - $_SERVER['REQUEST_TIME_FLOAT'] ?? time(); + $uptime = time() - ($_SERVER['REQUEST_TIME_FLOAT'] ?? time()); $serverSoftware = $_SERVER['SERVER_SOFTWARE'] ?? 'CLI'; return [ @@ -70,7 +64,8 @@ function (): array { description: 'Current system status and runtime information', mimeType: 'application/json' ) - ->build() - ->connect(new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp')); + ->build(); + +$response = $server->run(transport()); -logger()->info('Server listener stopped gracefully.'); +shutdown($response); diff --git a/examples/05-stdio-env-variables/EnvToolHandler.php b/examples/env-variables/EnvToolHandler.php similarity index 91% rename from examples/05-stdio-env-variables/EnvToolHandler.php rename to examples/env-variables/EnvToolHandler.php index 002f7cf0..f7cad817 100644 --- a/examples/05-stdio-env-variables/EnvToolHandler.php +++ b/examples/env-variables/EnvToolHandler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioEnvVariables; +namespace Mcp\Example\EnvVariables; 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/05-stdio-env-variables/server.php b/examples/env-variables/server.php similarity index 73% rename from examples/05-stdio-env-variables/server.php rename to examples/env-variables/server.php index 134a1ae2..48c43825 100644 --- a/examples/05-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,13 +46,16 @@ | */ -logger()->info('Starting MCP Stdio Environment Variable Example Server...'); +logger()->info('Starting MCP Environment Variable Example Server...'); -Server::make() - ->withServerInfo('Env Var Server', '1.0.0') - ->withLogger(logger()) - ->withDiscovery(__DIR__, ['.']) - ->build() - ->connect(new StdioTransport(logger: logger())); +$server = Server::builder() + ->setServerInfo('Env Var Server', '1.0.0') + ->setLogger(logger()) + ->setDiscovery(__DIR__) + ->build(); -logger()->info('Server listener stopped gracefully.'); +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/03-manual-registration-stdio/SimpleHandlers.php b/examples/explicit-registration/SimpleHandlers.php similarity index 91% rename from examples/03-manual-registration-stdio/SimpleHandlers.php rename to examples/explicit-registration/SimpleHandlers.php index d646486f..0fe385c1 100644 --- a/examples/03-manual-registration-stdio/SimpleHandlers.php +++ b/examples/explicit-registration/SimpleHandlers.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\ManualStdioExample; +namespace Mcp\Example\ExplicitRegistration; 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/explicit-registration/server.php b/examples/explicit-registration/server.php new file mode 100644 index 00000000..5a61feef --- /dev/null +++ b/examples/explicit-registration/server.php @@ -0,0 +1,47 @@ +#!/usr/bin/env php +info('Starting MCP Manual Registration Server...'); + +$server = Server::builder() + ->setServerInfo('Explicit Registration 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') + ->setCapabilities(new ServerCapabilities( + tools: true, + toolsListChanged: false, + resources: true, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: true, + promptsListChanged: false, + logging: false, + completions: false, + )) + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server listener stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/schema-showcase/SchemaShowcaseElements.php similarity index 91% rename from examples/08-schema-showcase-streamable/SchemaShowcaseElements.php rename to examples/schema-showcase/SchemaShowcaseElements.php index 3521e2e3..6c7a4b93 100644 --- a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php +++ b/examples/schema-showcase/SchemaShowcaseElements.php @@ -9,16 +9,24 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\SchemaShowcaseExample; +namespace Mcp\Example\SchemaShowcase; 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/schema-showcase/server.php b/examples/schema-showcase/server.php new file mode 100644 index 00000000..86c2bf77 --- /dev/null +++ b/examples/schema-showcase/server.php @@ -0,0 +1,36 @@ +#!/usr/bin/env php +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')) + ->setDiscovery(__DIR__) + ->build(); + +$response = $server->run(transport()); + +shutdown($response); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c550bc03..f5901b28 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,697 +1,7 @@ parameters: ignoreErrors: - - - message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpUserProfileExample\\McpServerException\.$#' - identifier: class.notFound - 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: '#^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\.$#' - 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: '#^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\.$#' - 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: '#^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\.$#' - 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: '#^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\\CallToolHandler 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\\GetPromptHandler constructor expects Mcp\\Capability\\Registry, 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\\ListPromptsHandler constructor expects Mcp\\Capability\\Registry, 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, 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 - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResource\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerResourceTemplate\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.php - - - - message: '#^Parameter \$handler of method Mcp\\Capability\\Registry\:\:registerTool\(\) has invalid type Mcp\\Capability\\CallableArray\.$#' - identifier: class.notFound - count: 1 - path: src/Capability/Registry.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 - 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 - 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\:\: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\:\: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\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Server/ServerBuilder.php - - - - message: '#^Method Mcp\\Server\\ServerBuilder\:\:withDiscovery\(\) 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\.$#' - 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\.$#' - 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\.$#' - 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\.$#' - 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\.$#' - 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\.$#' - identifier: missingType.iterableValue - 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 - 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\:\:\$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\.$#' - 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\.$#' - 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\.$#' - 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\.$#' - 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 - 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 diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 108b384b..900f12fd 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,12 +9,16 @@ 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: - 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/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/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/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 73677e33..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. @@ -21,12 +23,16 @@ 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 ?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 873d485c..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. @@ -23,12 +24,14 @@ 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 ?Icon[] $icons Optional list of icon URLs representing the resource + * @param ?array $meta Optional metadata */ public function __construct( public string $uri, @@ -37,6 +40,8 @@ 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/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..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; /** @@ -20,14 +21,18 @@ 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 ?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/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/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 4d9651cb..88ec4117 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -16,10 +16,13 @@ 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\Registry; +use Mcp\Capability\Completion\EnumCompletionProvider; +use Mcp\Capability\Completion\ListCompletionProvider; +use Mcp\Capability\Completion\ProviderInterface; +use Mcp\Capability\Registry\PromptReference; +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; @@ -44,7 +47,6 @@ class Discoverer { public function __construct( - private readonly Registry $registry, private readonly LoggerInterface $logger = new NullLogger(), private ?DocBlockParser $docBlockParser = null, private ?SchemaGenerator $schemaGenerator = null, @@ -54,13 +56,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 +72,11 @@ public function discover(string $basePath, array $directories, array $excludeDir 'resourceTemplates' => 0, ]; + $tools = []; + $resources = []; + $prompts = []; + $resourceTemplates = []; + try { $finder = new Finder(); $absolutePaths = []; @@ -86,7 +93,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'base_path' => $basePath, ]); - return; + return new DiscoveryState(); } $finder->files() @@ -95,10 +102,10 @@ 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', [ + $this->logger->error('Error during file finding process for MCP discovery'.json_encode($e->getTrace(), \JSON_PRETTY_PRINT), [ 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); @@ -112,25 +119,24 @@ public function discover(string $basePath, array $directories, array $excludeDir 'prompts' => $discoveredCount['prompts'], 'resourceTemplates' => $discoveredCount['resourceTemplates'], ]); + + return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); } /** * 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) { - $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; } @@ -150,7 +156,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,17 +176,17 @@ 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; } } } } } 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(), @@ -192,11 +198,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(); @@ -212,8 +222,15 @@ 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); - $this->registry->registerTool($tool, [$className, $methodName]); + $tool = new Tool( + $name, + $inputSchema, + $description, + $instance->annotations, + $instance->icons, + $instance->meta, + ); + $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -221,11 +238,18 @@ 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; - $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); - $this->registry->registerResource($resource, [$className, $methodName]); + $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']; break; @@ -243,9 +267,9 @@ 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); + $prompt = new Prompt($name, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders); + $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; break; @@ -255,9 +279,10 @@ 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); - $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders); + $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; break; } @@ -303,34 +328,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; } @@ -401,7 +426,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]; 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/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/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/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/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/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/Registry.php b/src/Capability/Registry.php index f0db6549..d0813412 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -11,34 +11,35 @@ namespace Mcp\Capability; -use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Discovery\DiscoveryState; use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ReferenceHandler; 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; use Mcp\Event\ToolListChangedEvent; -use Mcp\Exception\InvalidArgumentException; -use Mcp\Schema\Content\PromptMessage; -use Mcp\Schema\Content\ResourceContents; +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; use Mcp\Schema\ResourceTemplate; -use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** - * @phpstan-import-type CallableArray from ElementReference + * Registry implementation that manages MCP element registration and access. * * @author Kyrian Obikwelu */ -class Registry +final class Registry implements RegistryInterface { /** * @var array @@ -61,60 +62,45 @@ 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(), + private readonly NameValidator $nameValidator = new NameValidator(), ) { } - public function getCapabilities(): ServerCapabilities - { - if (!$this->hasElements()) { - $this->logger->info('No capabilities registered on server.'); - } - - return new ServerCapabilities( - tools: true, // [] !== $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, - 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( + \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()); } - /** - * @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( + \sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri), + ); return; } @@ -124,10 +110,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 +120,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( + \sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate), + ); 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 +147,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( + \sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName), + ); return; } @@ -172,20 +159,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,116 +193,260 @@ public function clear(): void } } - public function handleCallTool(string $name, array $arguments): array + public function hasTools(): bool { - $reference = $this->getTool($name); + return [] !== $this->tools; + } + + public function getTools(?int $limit = null, ?string $cursor = null): Page + { + $tools = []; + foreach ($this->tools as $toolReference) { + $tools[$toolReference->tool->name] = $toolReference->tool; + } - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Tool "%s" is not registered.', $name)); + if (null === $limit) { + return new Page($tools, null); } - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) + $paginatedTools = $this->paginateResults($tools, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($tools), + $cursor, + $limit ); + + return new Page($paginatedTools, $nextCursor); } - 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); } - /** - * @return ResourceContents[] - */ - public function handleReadResource(string $uri): array + public function hasResources(): bool + { + return [] !== $this->resources; + } + + public function getResources(?int $limit = null, ?string $cursor = null): Page { - $reference = $this->getResource($uri); + $resources = []; + foreach ($this->resources as $resourceReference) { + $resources[$resourceReference->schema->uri] = $resourceReference->schema; + } - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Resource "%s" is not registered.', $uri)); + if (null === $limit) { + return new Page($resources, null); } - return $reference->formatResult( - $this->referenceHandler->handle($reference, ['uri' => $uri]), - $uri, + $paginatedResources = $this->paginateResults($resources, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($resources), + $cursor, + $limit ); + + return new Page($paginatedResources, $nextCursor); } - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null - { + public function getResource( + string $uri, + bool $includeTemplates = true, + ): 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 hasResourceTemplates(): bool { - return $this->resourceTemplates[$uriTemplate] ?? null; + return [] !== $this->resourceTemplates; } - /** - * @return PromptMessage[] - */ - public function handleGetPrompt(string $name, ?array $arguments): array + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page + { + $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 getResourceTemplate(string $uriTemplate): ResourceTemplateReference + { + return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate); + } + + public function hasPrompts(): bool + { + return [] !== $this->prompts; + } + + public function getPrompts(?int $limit = null, ?string $cursor = null): Page { - $reference = $this->getPrompt($name); + $prompts = []; + foreach ($this->prompts as $promptReference) { + $prompts[$promptReference->prompt->name] = $promptReference->prompt; + } - if (null === $reference) { - throw new InvalidArgumentException(\sprintf('Prompt "%s" is not registered.', $name)); + if (null === $limit) { + return new Page($prompts, null); } - return $reference->formatResult( - $this->referenceHandler->handle($reference, $arguments) + $paginatedPrompts = $this->paginateResults($prompts, $limit, $cursor); + + $nextCursor = $this->calculateNextCursor( + \count($prompts), + $cursor, + $limit ); + + return new Page($paginatedPrompts, $nextCursor); } - 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); } /** - * @return array + * Get the current discovery state (only discovered elements, not manual ones). */ - public function getTools(): array + public function getDiscoveryState(): DiscoveryState { - return array_map(fn (ToolReference $tool) => $tool->tool, $this->tools); + 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), + ); } /** - * @return array + * Set discovery state, replacing all discovered elements. + * Manual elements are preserved. */ - public function getResources(): array + public function setDiscoveryState(DiscoveryState $state): void { - return array_map(fn (ResourceReference $resource) => $resource->schema, $this->resources); + // 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()); + } + } } /** - * @return array + * 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 */ - public function getPrompts(): array + private function calculateNextCursor(int $totalItems, ?string $currentCursor, int $limit): ?string { - return array_map(fn (PromptReference $prompt) => $prompt->prompt, $this->prompts); + $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; } - /** @return array */ - public function getResourceTemplates(): array + /** + * 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 { - return array_map(fn ($template) => $template->resourceTemplate, $this->resourceTemplates); + $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/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php new file mode 100644 index 00000000..fef17263 --- /dev/null +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -0,0 +1,326 @@ + + * + * @phpstan-import-type Handler from ElementReference + */ +final class ArrayLoader implements LoaderInterface +{ + /** + * @param array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * annotations: ?ToolAnnotations, + * icons: ?Icon[], + * meta: ?array + * }[] $tools + * @param array{ + * handler: Handler, + * uri: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * size: int|null, + * annotations: ?Annotations, + * icons: ?Icon[], + * meta: ?array + * }[] $resources + * @param array{ + * handler: Handler, + * uriTemplate: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * annotations: ?Annotations, + * meta: ?array + * }[] $resourceTemplates + * @param array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * icons: ?Icon[], + * meta: ?array + * }[] $prompts + */ + public function __construct( + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function load(RegistryInterface $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: $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']); + $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; + } + + $resource = new Resource( + 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); + + $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; + } + + $template = new ResourceTemplate( + 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); + + $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: $name, + description: $description, + arguments: $arguments, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null + ); + $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..6129dfaa --- /dev/null +++ b/src/Capability/Registry/Loader/DiscoveryLoader.php @@ -0,0 +1,51 @@ + + */ +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(RegistryInterface $registry): void + { + // This now encapsulates the discovery process + $discoverer = new Discoverer($this->logger); + + $cachedDiscoverer = $this->cache + ? new CachedDiscoverer($discoverer, $this->cache, $this->logger) + : $discoverer; + + $discoveryState = $cachedDiscoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); + + $registry->setDiscoveryState($discoveryState); + } +} diff --git a/src/Capability/Tool/ToolCollectionInterface.php b/src/Capability/Registry/Loader/LoaderInterface.php similarity index 55% rename from src/Capability/Tool/ToolCollectionInterface.php rename to src/Capability/Registry/Loader/LoaderInterface.php index 1c71eea1..a7ad87ce 100644 --- a/src/Capability/Tool/ToolCollectionInterface.php +++ b/src/Capability/Registry/Loader/LoaderInterface.php @@ -9,15 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Tool; +namespace Mcp\Capability\Registry\Loader; + +use Mcp\Capability\RegistryInterface; /** - * @author Tobias Nyholm + * @author Antoine Bluchet */ -interface ToolCollectionInterface +interface LoaderInterface { - /** - * @return MetadataInterface[] - */ - public function getMetadata(): array; + public function load(RegistryInterface $registry): void; } 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/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index d4af3169..7ce8c737 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,12 +13,15 @@ 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; /** * @author Kyrian Obikwelu */ -class ReferenceHandler +final class ReferenceHandler implements ReferenceHandlerInterface { public function __construct( private readonly ?ContainerInterface $container = null, @@ -30,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); } @@ -48,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); @@ -58,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); @@ -89,6 +103,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 { @@ -118,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); @@ -131,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/Capability/Registry/ReferenceHandlerInterface.php b/src/Capability/Registry/ReferenceHandlerInterface.php new file mode 100644 index 00000000..c4e52f10 --- /dev/null +++ b/src/Capability/Registry/ReferenceHandlerInterface.php @@ -0,0 +1,37 @@ + + */ +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 InvalidArgumentException if the handler is invalid + * @throws RegistryException if execution fails + */ + public function handle(ElementReference $reference, array $arguments): mixed; +} 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 3ef1d6cb..88104c9d 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -17,8 +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; /** * @phpstan-import-type Handler from ElementReference @@ -32,11 +30,6 @@ class ResourceTemplateReference extends ElementReference */ private array $variableNames; - /** - * @var array - */ - private array $uriVariables; - private string $uriTemplateRegex; /** @@ -54,47 +47,6 @@ public function __construct( $this->compileTemplate(); } - /** - * 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]); - - $result = $this->handle($container, $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 */ @@ -105,40 +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]; - } - } - - $this->uriVariables = $variables; - - return true; - } - - return false; + return 1 === preg_match($this->uriTemplateRegex, $uri); } - private function compileTemplate(): void + /** @return array */ + public function extractVariables(string $uri): array { - $this->variableNames = []; - $regexParts = []; + $matches = []; - $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + preg_match($this->uriTemplateRegex, $uri, $matches); - 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).'$#'; + return array_filter($matches, fn ($key) => \in_array($key, $this->variableNames), \ARRAY_FILTER_USE_KEY); } /** @@ -162,7 +91,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]; @@ -172,9 +101,11 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp 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; @@ -222,14 +153,15 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp 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); @@ -240,21 +172,21 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp 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)) { @@ -263,7 +195,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp 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()}"); } @@ -273,7 +205,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp $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()}"); } @@ -282,6 +214,26 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp 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 { diff --git a/src/Capability/RegistryInterface.php b/src/Capability/RegistryInterface.php new file mode 100644 index 00000000..67295681 --- /dev/null +++ b/src/Capability/RegistryInterface.php @@ -0,0 +1,160 @@ + + * @author Christopher Hertel + */ +interface RegistryInterface +{ + /** + * 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; + + /** + * 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; + + /** + * @return bool true if any tools are registered + */ + public function hasTools(): bool; + + /** + * Gets all registered tools. + */ + public function getTools(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets a tool reference by name. + * + * @throws ToolNotFoundException + */ + public function getTool(string $name): ToolReference; + + /** + * @return bool true if any resources are registered + */ + public function hasResources(): bool; + + /** + * Gets all registered resources. + */ + public function getResources(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets a resource reference by URI (includes template matching if enabled). + * + * @throws ResourceNotFoundException + */ + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference; + + /** + * @return bool true if any resource templates are registered + */ + public function hasResourceTemplates(): bool; + + /** + * Gets all registered resource templates. + */ + public function getResourceTemplates(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets a resource template reference by URI template. + * + * @throws ResourceNotFoundException + */ + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference; + + /** + * @return bool true if any prompts are registered + */ + public function hasPrompts(): bool; + + /** + * Gets all registered prompts. + */ + public function getPrompts(?int $limit = null, ?string $cursor = null): Page; + + /** + * Gets a prompt reference by name. + * + * @throws PromptNotFoundException + */ + public function getPrompt(string $name): PromptReference; +} 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/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/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/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/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/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/IdentifierInterface.php b/src/Capability/Tool/NameValidator.php similarity index 65% rename from src/Capability/Tool/IdentifierInterface.php rename to src/Capability/Tool/NameValidator.php index 0ad3f8c9..fe9bb084 100644 --- a/src/Capability/Tool/IdentifierInterface.php +++ b/src/Capability/Tool/NameValidator.php @@ -11,10 +11,10 @@ namespace Mcp\Capability\Tool; -/** - * @author Tobias Nyholm - */ -interface IdentifierInterface +final class NameValidator { - public function getName(): string; + public function isValid(string $name): bool + { + return 1 === preg_match('/^[a-zA-Z0-9._\/-]{1,64}$/', $name); + } } diff --git a/src/Capability/Tool/ToolExecutorInterface.php b/src/Capability/Tool/ToolExecutorInterface.php deleted file mode 100644 index c72b134a..00000000 --- a/src/Capability/Tool/ToolExecutorInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -interface ToolExecutorInterface -{ - /** - * @throws ToolExecutionException 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 deleted file mode 100644 index 7baeee67..00000000 --- a/src/Capability/ToolChain.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ -class ToolChain implements ToolExecutorInterface, 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 ToolExecutorInterface && $request->name === $item->getName()) { - try { - return $item->call($request); - } catch (\Throwable $e) { - throw new ToolExecutionException($request, $e); - } - } - } - - throw new ToolNotFoundException($request); - } -} diff --git a/src/Server/TransportInterface.php b/src/Exception/ClientException.php similarity index 50% rename from src/Server/TransportInterface.php rename to src/Exception/ClientException.php index 49963a70..f77394ff 100644 --- a/src/Server/TransportInterface.php +++ b/src/Exception/ClientException.php @@ -1,5 +1,7 @@ */ -interface TransportInterface +class ClientException extends Exception { - public function initialize(): void; - - public function isConnected(): bool; - - public function receive(): \Generator; - - public function send(string $data): void; + public function __construct( + private readonly Error $error, + ) { + parent::__construct($error->message); + } - public function close(): void; + public function getError(): Error + { + return $this->error; + } } 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/Capability/Prompt/IdentifierInterface.php b/src/Exception/ToolCallException.php similarity index 74% rename from src/Capability/Prompt/IdentifierInterface.php rename to src/Exception/ToolCallException.php index 8fea7919..01ba9f45 100644 --- a/src/Capability/Prompt/IdentifierInterface.php +++ b/src/Exception/ToolCallException.php @@ -9,12 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Prompt; +namespace Mcp\Exception; /** * @author Tobias Nyholm */ -interface IdentifierInterface +final class ToolCallException extends \RuntimeException implements ExceptionInterface { - public function getName(): string; } diff --git a/src/Exception/ToolExecutionException.php b/src/Exception/ToolExecutionException.php deleted file mode 100644 index f2df9366..00000000 --- a/src/Exception/ToolExecutionException.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ -final class ToolExecutionException 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); - } -} 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/JsonRpc/Handler.php b/src/JsonRpc/Handler.php deleted file mode 100644 index 3cff864f..00000000 --- a/src/JsonRpc/Handler.php +++ /dev/null @@ -1,180 +0,0 @@ - - */ -class Handler -{ - /** - * @var array - */ - private readonly array $methodHandlers; - - /** - * @param iterable $methodHandlers - */ - public function __construct( - private readonly MessageFactory $messageFactory, - iterable $methodHandlers, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - $this->methodHandlers = $methodHandlers instanceof \Traversable ? iterator_to_array($methodHandlers) : $methodHandlers; - } - - public static function make( - Registry $registry, - Implementation $implementation, - LoggerInterface $logger = new NullLogger(), - ): self { - return new self( - MessageFactory::make(), - [ - 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), - ], - $logger, - ); - } - - /** - * @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 - { - $this->logger->info('Received message to process.', ['message' => $input]); - - try { - $messages = $this->messageFactory->create($input); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - - yield $this->encodeResponse(Error::forParseError($e->getMessage())); - - return; - } - - 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)); - continue; - } - - $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ - 'method' => $message->getMethod(), - ]); - - try { - yield $this->encodeResponse($this->handle($message)); - } catch (\DomainException) { - yield null; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning(\sprintf('Failed to create response: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::forMethodNotFound($e->getMessage())); - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::forInvalidParams($e->getMessage())); - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - yield $this->encodeResponse(Error::forInternalError($e->getMessage())); - } - } - } - - /** - * @throws \JsonException When JSON encoding fails - */ - 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]); - - 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); - } - - /** - * 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): 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)) { - $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 ($handled) { - return null; - } - - throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); - } -} 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/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/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/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/Enum/ProtocolVersion.php b/src/Schema/Enum/ProtocolVersion.php new file mode 100644 index 00000000..b580e709 --- /dev/null +++ b/src/Schema/Enum/ProtocolVersion.php @@ -0,0 +1,25 @@ + + */ +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/Capability/Resource/IdentifierInterface.php b/src/Schema/Enum/SamplingContext.php similarity index 61% rename from src/Capability/Resource/IdentifierInterface.php rename to src/Schema/Enum/SamplingContext.php index 0daa39e8..4c1f1851 100644 --- a/src/Capability/Resource/IdentifierInterface.php +++ b/src/Schema/Enum/SamplingContext.php @@ -9,12 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Capability\Resource; +namespace Mcp\Schema\Enum; -/** - * @author Tobias Nyholm - */ -interface IdentifierInterface +enum SamplingContext: string { - public function getUri(): string; + case NONE = 'none'; + case THIS_SERVER = 'thisServer'; + case ALL_SERVERS = 'allServers'; } 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/JsonRpc/Error.php b/src/Schema/JsonRpc/Error.php index 64cb8455..d5273eb1 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 @@ -100,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/Schema/JsonRpc/MessageInterface.php b/src/Schema/JsonRpc/MessageInterface.php index 1660a0d2..6e4d1c1e 100644 --- a/src/Schema/JsonRpc/MessageInterface.php +++ b/src/Schema/JsonRpc/MessageInterface.php @@ -11,6 +11,8 @@ namespace Mcp\Schema\JsonRpc; +use Mcp\Schema\Enum\ProtocolVersion; + /** * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. * @@ -19,5 +21,5 @@ interface MessageInterface extends \JsonSerializable { public const JSONRPC_VERSION = '2.0'; - public const PROTOCOL_VERSION = '2025-06-18'; + public const PROTOCOL_VERSION = ProtocolVersion::V2025_06_18; } diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index a4d0c0f4..cb8ed836 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.'); @@ -59,7 +59,13 @@ public static function fromArray(array $data): self $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; @@ -68,13 +74,40 @@ 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 { 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 */ @@ -97,7 +130,7 @@ public function jsonSerialize(): array } /** - * @return array|null + * @return array|null */ abstract protected function getParams(): ?array; } diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 6e5ae2c6..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 { @@ -56,6 +61,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']); } @@ -64,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/Page.php b/src/Schema/Page.php new file mode 100644 index 00000000..bad46e9c --- /dev/null +++ b/src/Schema/Page.php @@ -0,0 +1,35 @@ + + */ +final class Page extends \ArrayObject +{ + /** + * @param array $references Items can be Tool, Prompt, ResourceTemplate, or Resource + */ + 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); + } +} diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 96cffcd9..0fe41586 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -17,11 +17,14 @@ * 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 * } * * @author Kyrian Obikwelu @@ -30,13 +33,17 @@ 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) { foreach ($this->arguments as $arg) { @@ -60,10 +67,16 @@ 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, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -71,7 +84,9 @@ public static function fromArray(array $data): self * @return array{ * name: string, * description?: string, - * arguments?: array + * arguments?: array, + * icons?: Icon[], + * _meta?: array * } */ public function jsonSerialize(): array @@ -83,6 +98,12 @@ 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; + } return $data; } 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..99aae118 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Content\SamplingMessage; +use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\ModelPreferences; @@ -23,35 +24,39 @@ * * @author Kyrian Obikwelu */ -class CreateSamplingMessageRequest extends Request +final class CreateSamplingMessageRequest extends Request { /** * @param SamplingMessage[] $messages the messages to send to the model * @param int $maxTokens The maximum number of tokens to sample, as requested by the server. * The client MAY choose to sample fewer tokens than requested. - * @param ModelPreferences|null $preferences The server's preferences for which model to select. The client MAY + * @param ?ModelPreferences $preferences The server's preferences for which model to select. The client MAY * ignore these preferences. - * @param string|null $systemPrompt An optional system prompt the server wants to use for sampling. The + * @param ?string $systemPrompt An optional system prompt the server wants to use for sampling. The * client MAY modify or omit this prompt. - * @param string|null $includeContext A request to include context from one or more MCP servers (including + * @param ?SamplingContext $includeContext A request to include context from one or more MCP servers (including * the caller), to be attached to the prompt. The client MAY ignore this request. - * - * Allowed values: "none", "thisServer", "allServers" - * @param float|null $temperature The temperature to use for sampling. The client MAY ignore this request. - * @param string[]|null $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. + * 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 @@ -59,7 +64,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 +91,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, @@ -102,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/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/src/Schema/Resource.php b/src/Schema/Resource.php index ca6fa11a..ac33ed4d 100644 --- a/src/Schema/Resource.php +++ b/src/Schema/Resource.php @@ -17,14 +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, + * description?: string, + * mimeType?: string, + * annotations?: AnnotationsData, + * size?: int, + * icons?: IconData[], + * _meta?: array, * } * * @author Kyrian Obikwelu @@ -43,14 +46,16 @@ 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 $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. + * This can be used by Hosts to display file sizes and estimate context window usage */ public function __construct( public readonly string $uri, @@ -59,6 +64,8 @@ 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)) { throw new InvalidArgumentException('Invalid resource name: must contain only alphanumeric characters, underscores, and hyphens.'); @@ -80,13 +87,19 @@ 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, + icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + meta: isset($data['_meta']) ? $data['_meta'] : null ); } @@ -98,6 +111,8 @@ public static function fromArray(array $data): self * mimeType?: string, * annotations?: Annotations, * size?: int, + * icons?: Icon[], + * _meta?: array * } */ public function jsonSerialize(): array @@ -118,6 +133,12 @@ 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; + } 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/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index 4345f81b..4f31e034 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,12 +38,16 @@ 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). + * @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) { @@ -61,27 +59,30 @@ 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 + * @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 array $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, + * content: array, * isError?: bool, + * _meta?: array, * } $data */ public static function fromArray(array $data): self @@ -102,20 +103,37 @@ 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, + * content: array, * isError: bool, + * structuredContent?: array, + * _meta?: array, * } */ public function jsonSerialize(): array { - return [ + $result = [ 'content' => $this->content, 'isError' => $this->isError, ]; + + if ($this->structuredContent) { + $result['structuredContent'] = $this->structuredContent; + } + + if ($this->meta) { + $result['_meta'] = $this->meta; + } + + return $result; } } 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/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/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index 9b0087ec..5c184d63 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -12,6 +12,7 @@ namespace Mcp\Schema\Result; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\MessageInterface; use Mcp\Schema\JsonRpc\Response; @@ -31,13 +32,14 @@ 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, ) { } @@ -66,7 +68,8 @@ public static function fromArray(array $data): self ServerCapabilities::fromArray($data['capabilities']), Implementation::fromArray($data['serverInfo']), $data['instructions'] ?? null, - $data['_meta'] ?? null + $data['_meta'] ?? null, + ProtocolVersion::tryFrom($data['protocolVersion']), ); } @@ -81,16 +84,17 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { + $protocolVersion = $this->protocolVersion ?? MessageInterface::PROTOCOL_VERSION; $data = [ - 'protocolVersion' => MessageInterface::PROTOCOL_VERSION, + 'protocolVersion' => $protocolVersion->value, 'capabilities' => $this->capabilities, 'serverInfo' => $this->serverInfo, ]; 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; 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/Schema/Tool.php b/src/Schema/Tool.php index 29646efc..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,8 @@ * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * icons?: IconData[], + * _meta?: array * } * * @author Kyrian Obikwelu @@ -35,18 +38,22 @@ 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 $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 $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool + * @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 $icons = null, + 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 +82,9 @@ 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['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null ); } @@ -85,6 +94,8 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], + * _meta?: array * } */ public function jsonSerialize(): array @@ -99,6 +110,12 @@ 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; + } return $data; } diff --git a/src/Server.php b/src/Server.php index fc81382d..8657610a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,62 +11,48 @@ namespace Mcp; -use Mcp\JsonRpc\Handler; -use Mcp\Server\ServerBuilder; -use Mcp\Server\TransportInterface; +use Mcp\Server\Builder; +use Mcp\Server\Protocol; +use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** * @author Christopher Hertel + * @author Kyrian Obikwelu */ final class Server { public function __construct( - private readonly Handler $jsonRpcHandler, + private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), ) { } - public static function make(): ServerBuilder + public static function builder(): Builder { - return new ServerBuilder(); + return new Builder(); } - public function connect(TransportInterface $transport): void + /** + * @template TResult + * + * @param TransportInterface $transport + * + * @return TResult + */ + public function run(TransportInterface $transport): mixed { $transport->initialize(); - $this->logger->info('Transport initialized.', [ - 'transport' => $transport::class, - ]); - while ($transport->isConnected()) { - foreach ($transport->receive() as $message) { - if (null === $message) { - continue; - } + $this->protocol->connect($transport); - try { - foreach ($this->jsonRpcHandler->process($message) as $response) { - if (null === $response) { - continue; - } + $this->logger->info('Running server...'); - $transport->send($response); - } - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message' => $message, - 'exception' => $e, - ]); - continue; - } - } - - usleep(1000); + try { + return $transport->listen(); + } finally { + $transport->close(); } - - $transport->close(); - $this->logger->info('Transport closed'); } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php new file mode 100644 index 00000000..1d1f8d07 --- /dev/null +++ b/src/Server/Builder.php @@ -0,0 +1,515 @@ + + */ +final class Builder +{ + private ?Implementation $serverInfo = null; + + private RegistryInterface $registry; + + private ?LoggerInterface $logger = null; + + private ?CacheInterface $discoveryCache = null; + + private ?EventDispatcherInterface $eventDispatcher = null; + + private ?ContainerInterface $container = null; + + private ?SessionFactoryInterface $sessionFactory = null; + + private ?SessionStoreInterface $sessionStore = null; + + private int $sessionTtl = 3600; + + private int $paginationLimit = 50; + + private ?string $instructions = null; + + private ?ProtocolVersion $protocolVersion = null; + + /** + * @var array> + */ + private array $requestHandlers = []; + + /** + * @var array + */ + private array $notificationHandlers = []; + + /** + * @var array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * annotations: ?ToolAnnotations, + * icons: ?Icon[], + * meta: ?array + * }[] + */ + private array $tools = []; + + /** + * @var array{ + * handler: Handler, + * uri: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * size: int|null, + * annotations: ?Annotations, + * icons: ?Icon[], + * meta: ?array + * }[] + */ + private array $resources = []; + + /** + * @var array{ + * handler: Handler, + * uriTemplate: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * annotations: ?Annotations, + * meta: ?array + * }[] + */ + private array $resourceTemplates = []; + + /** + * @var array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * icons: ?Icon[], + * meta: ?array + * }[] + */ + private array $prompts = []; + + private ?string $discoveryBasePath = null; + + /** + * @var string[] + */ + private array $discoveryScanDirs = []; + + /** + * @var array|string[] + */ + private array $discoveryExcludeDirs = []; + + private ?ServerCapabilities $serverCapabilities = null; + + /** + * @var LoaderInterface[] + */ + private array $loaders = []; + + /** + * Sets the server's identity. Required. + * + * @param ?Icon[] $icons + */ + 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; + } + + /** + * Configures the server's pagination limit. + */ + public function setPaginationLimit(int $paginationLimit): self + { + $this->paginationLimit = $paginationLimit; + + return $this; + } + + /** + * Configures the 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. + */ + public function setInstructions(?string $instructions): self + { + $this->instructions = $instructions; + + return $this; + } + + /** + * Explicitly set server capabilities. If set, this overrides automatic detection. + */ + public function setCapabilities(ServerCapabilities $serverCapabilities): self + { + $this->serverCapabilities = $serverCapabilities; + + return $this; + } + + /** + * Register a single custom method handler. + * + * @param RequestHandlerInterface $handler + */ + public function addRequestHandler(RequestHandlerInterface $handler): self + { + $this->requestHandlers[] = $handler; + + return $this; + } + + /** + * Register multiple custom method handlers. + * + * @param iterable> $handlers + */ + public function addRequestHandlers(iterable $handlers): self + { + foreach ($handlers as $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; + } + + public function setRegistry(RegistryInterface $registry): self + { + $this->registry = $registry; + + return $this; + } + + /** + * Provides a PSR-3 logger instance. Defaults to NullLogger. + */ + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): self + { + $this->eventDispatcher = $eventDispatcher; + + return $this; + } + + /** + * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. + * Defaults to a basic internal container. + */ + public function setContainer(ContainerInterface $container): self + { + $this->container = $container; + + 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; + } + + /** + * @param string[] $scanDirs + * @param string[] $excludeDirs + */ + public function setDiscovery( + string $basePath, + array $scanDirs = ['.', 'src'], + array $excludeDirs = [], + ?CacheInterface $cache = null, + ): self { + $this->discoveryBasePath = $basePath; + $this->discoveryScanDirs = $scanDirs; + $this->discoveryExcludeDirs = $excludeDirs; + $this->discoveryCache = $cache; + + return $this; + } + + public function setProtocolVersion(ProtocolVersion $protocolVersion): self + { + $this->protocolVersion = $protocolVersion; + + return $this; + } + + /** + * Manually registers a tool handler. + * + * @param Handler $handler + * @param array|null $inputSchema + * @param ?Icon[] $icons + * @param array|null $meta + */ + public function addTool( + callable|array|string $handler, + ?string $name = null, + ?string $description = null, + ?ToolAnnotations $annotations = null, + ?array $inputSchema = null, + ?array $icons = null, + ?array $meta = null, + ): self { + $this->tools[] = compact( + 'handler', + 'name', + 'description', + 'annotations', + 'inputSchema', + 'icons', + 'meta', + ); + + return $this; + } + + /** + * Manually registers a resource handler. + * + * @param Handler $handler + * @param ?Icon[] $icons + * @param array|null $meta + */ + public function addResource( + \Closure|array|string $handler, + string $uri, + ?string $name = null, + ?string $description = null, + ?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', + 'icons', + 'meta', + ); + + return $this; + } + + /** + * Manually registers a resource template handler. + * + * @param Handler $handler + * @param array|null $meta + */ + public function addResourceTemplate( + \Closure|array|string $handler, + string $uriTemplate, + ?string $name = null, + ?string $description = null, + ?string $mimeType = null, + ?Annotations $annotations = null, + ?array $meta = null, + ): self { + $this->resourceTemplates[] = compact( + 'handler', + 'uriTemplate', + 'name', + 'description', + 'mimeType', + 'annotations', + 'meta', + ); + + return $this; + } + + /** + * Manually registers a prompt handler. + * + * @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', 'meta'); + + return $this; + } + + /** + * @param LoaderInterface[] $loaders + */ + public function addLoaders(...$loaders): self + { + $this->loaders = [...$this->loaders, ...$loaders]; + + return $this; + } + + /** + * Builds the fully configured Server instance. + */ + public function build(): Server + { + $logger = $this->logger ?? new NullLogger(); + $container = $this->container ?? new Container(); + $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); + + $loaders = [ + ...$this->loaders, + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), + ]; + + if (null !== $this->discoveryBasePath) { + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $logger, $this->discoveryCache); + } + + foreach ($loaders as $loader) { + $loader->load($registry); + } + + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + $messageFactory = MessageFactory::make(); + + $capabilities = $this->serverCapabilities ?? new ServerCapabilities( + tools: $registry->hasTools(), + toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + resources: $registry->hasResources() || $registry->hasResourceTemplates(), + resourcesSubscribe: false, + resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + prompts: $registry->hasPrompts(), + promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, + logging: false, + completions: true, + ); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); + $referenceHandler = new ReferenceHandler($container); + + $requestHandlers = array_merge($this->requestHandlers, [ + 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), + ]); + + $notificationHandlers = array_merge($this->notificationHandlers, [ + new Handler\Notification\InitializedHandler(), + ]); + + $protocol = new Protocol( + requestHandlers: $requestHandlers, + notificationHandlers: $notificationHandlers, + messageFactory: $messageFactory, + sessionFactory: $sessionFactory, + sessionStore: $sessionStore, + logger: $logger, + ); + + return new Server($protocol, $logger); + } +} 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 new file mode 100644 index 00000000..8179aee2 --- /dev/null +++ b/src/Server/ClientGateway.php @@ -0,0 +1,187 @@ +notify(new ProgressNotification("Starting analysis...")); + * + * // Request LLM sampling from client + * $response = $client->request(new SamplingRequest($text)); + * + * return $response->content->text; + * } + * ``` + * + * @phpstan-type SampleOptions array{ + * preferences?: ModelPreferences, + * systemPrompt?: string, + * temperature?: float, + * includeContext?: SamplingContext, + * stopSequences?: string[], + * metadata?: array, + * } + * + * @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)); + } + + /** + * Convenience method for LLM sampling requests. + * + * @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.) + * + * @return CreateSamplingMessageResult The sampling response + * + * @throws ClientException if the client request results in an error message + */ + public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult + { + $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 (\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)]; + } + + $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, + ); + + $response = $this->request($request, $timeout); + + if ($response instanceof Error) { + throw new ClientException($response); + } + + return CreateSamplingMessageResult::fromArray($response->result); + } + + /** + * 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 + */ + private 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; + } +} diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php index f5c4fabb..2f0bd7f0 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -11,13 +11,14 @@ namespace Mcp\Server; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; /** * 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 */ @@ -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/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php new file mode 100644 index 00000000..08dec76a --- /dev/null +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -0,0 +1,34 @@ + + */ +final class InitializedHandler implements NotificationHandlerInterface +{ + public function supports(Notification $notification): bool + { + return $notification instanceof InitializedNotification; + } + + public function handle(Notification $message, SessionInterface $session): void + { + \assert($message instanceof InitializedNotification); + + $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 new file mode 100644 index 00000000..e0430802 --- /dev/null +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -0,0 +1,99 @@ + + * + * @author Christopher Hertel + * @author Tobias Nyholm + */ +final class CallToolHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + 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); + + $toolName = $request->name; + $arguments = $request->arguments ?? []; + + $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); + + try { + $reference = $this->registry->getTool($toolName); + + $arguments['_session'] = $session; + + $result = $this->referenceHandler->handle($reference, $arguments); + + 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(), $result); + } catch (ToolCallException $e) { + $this->logger->error(\sprintf('Error while executing tool "%s": "%s".', $toolName, $e->getMessage()), [ + 'tool' => $toolName, + 'arguments' => $arguments, + ]); + + $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, + 'exception' => $e->getMessage(), + ]); + + 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 new file mode 100644 index 00000000..29c41fb7 --- /dev/null +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -0,0 +1,93 @@ + + * + * @author Kyrian Obikwelu + */ +final class CompletionCompleteHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly ?ContainerInterface $container = null, + ) { + } + + 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); + + $name = $request->argument['name'] ?? ''; + $value = $request->argument['value'] ?? ''; + + try { + $reference = match (true) { + $request->ref instanceof PromptReference => $this->registry->getPrompt($request->ref->name), + $request->ref instanceof ResourceReference => $this->registry->getResource($request->ref->uri), + }; + + $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()); + } + $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); + } + + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $request->getId()); + } + + $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 (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 new file mode 100644 index 00000000..274b8422 --- /dev/null +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -0,0 +1,80 @@ + + * + * @author Tobias Nyholm + */ +final class GetPromptHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + 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); + + $promptName = $request->name; + $arguments = $request->arguments ?? []; + + try { + $reference = $this->registry->getPrompt($promptName); + + $arguments['_session'] = $session; + + $result = $this->referenceHandler->handle($reference, $arguments); + + $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 Error::forResourceNotFound($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + + 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 new file mode 100644 index 00000000..d814d9dd --- /dev/null +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -0,0 +1,60 @@ + + * + * @author Christopher Hertel + */ +final class InitializeHandler implements RequestHandlerInterface +{ + public function __construct( + public readonly ?Configuration $configuration = null, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof InitializeRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof InitializeRequest); + + $session->set('client_info', $request->clientInfo->jsonSerialize()); + + return new Response( + $request->getId(), + new InitializeResult( + $this->configuration->capabilities ?? new ServerCapabilities(), + $this->configuration->serverInfo ?? new Implementation(), + $this->configuration?->instructions, + null, + $this->configuration?->protocolVersion, + ), + ); + } +} diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php new file mode 100644 index 00000000..ee287560 --- /dev/null +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -0,0 +1,56 @@ + + * + * @author Tobias Nyholm + */ +final class ListPromptsHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListPromptsRequest; + } + + /** + * @return Response + * + * @throws InvalidCursorException + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListPromptsRequest); + + $page = $this->registry->getPrompts($this->pageSize, $request->cursor); + + return new Response( + $request->getId(), + new ListPromptsResult($page->references, $page->nextCursor), + ); + } +} diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php new file mode 100644 index 00000000..5360f75e --- /dev/null +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -0,0 +1,56 @@ + + * + * @author Christopher Hertel + */ +final class ListResourceTemplatesHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListResourceTemplatesRequest; + } + + /** + * @return Response + * + * @throws InvalidCursorException + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListResourceTemplatesRequest); + + $page = $this->registry->getResourceTemplates($this->pageSize, $request->cursor); + + return new Response( + $request->getId(), + new ListResourceTemplatesResult($page->references, $page->nextCursor), + ); + } +} diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php new file mode 100644 index 00000000..d1d68307 --- /dev/null +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -0,0 +1,56 @@ + + * + * @author Tobias Nyholm + */ +final class ListResourcesHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListResourcesRequest; + } + + /** + * @return Response + * + * @throws InvalidCursorException + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListResourcesRequest); + + $page = $this->registry->getResources($this->pageSize, $request->cursor); + + return new Response( + $request->getId(), + new ListResourcesResult($page->references, $page->nextCursor), + ); + } +} diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php new file mode 100644 index 00000000..007bdd50 --- /dev/null +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -0,0 +1,57 @@ + + * + * @author Christopher Hertel + * @author Tobias Nyholm + */ +final class ListToolsHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListToolsRequest; + } + + /** + * @return Response + * + * @throws InvalidCursorException When the cursor is invalid + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListToolsRequest); + + $page = $this->registry->getTools($this->pageSize, $request->cursor); + + return new Response( + $request->getId(), + new ListToolsResult($page->references, $page->nextCursor), + ); + } +} diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php new file mode 100644 index 00000000..507680fa --- /dev/null +++ b/src/Server/Handler/Request/PingHandler.php @@ -0,0 +1,41 @@ + + * + * @author Christopher Hertel + */ +final class PingHandler implements RequestHandlerInterface +{ + public function supports(Request $request): bool + { + return $request instanceof PingRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof PingRequest); + + return new Response($request->getId(), new EmptyResult()); + } +} diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php new file mode 100644 index 00000000..f955f4b1 --- /dev/null +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -0,0 +1,92 @@ + + * + * @author Tobias Nyholm + */ +final class ReadResourceHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $referenceProvider, + private readonly ReferenceHandlerInterface $referenceHandler, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + 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); + + $uri = $request->uri; + + $this->logger->debug('Reading resource', ['uri' => $uri]); + + try { + $reference = $this->referenceProvider->getResource($uri); + + $arguments = [ + 'uri' => $uri, + '_session' => $session, + ]; + + 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); + } + + 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 Error::forResourceNotFound($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $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/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php new file mode 100644 index 00000000..d81c0795 --- /dev/null +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -0,0 +1,32 @@ + + */ +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/MethodHandlerInterface.php b/src/Server/MethodHandlerInterface.php deleted file mode 100644 index 7f949bb1..00000000 --- a/src/Server/MethodHandlerInterface.php +++ /dev/null @@ -1,31 +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): 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 @@ + - */ -final class InitializedHandler implements MethodHandlerInterface -{ - public function supports(HasMethodInterface $message): bool - { - return $message instanceof InitializedNotification; - } - - public function handle(InitializedNotification|HasMethodInterface $message): Response|Error|null - { - return null; - } -} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php new file mode 100644 index 00000000..c3b42f58 --- /dev/null +++ b/src/Server/Protocol.php @@ -0,0 +1,598 @@ + + * @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 + */ + 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(), + ) { + } + + /** + * @return TransportInterface + */ + public function getTransport(): TransportInterface + { + return $this->transport; + } + + /** + * 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->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]); + } + + /** + * 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, null); + + 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); + } + + 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) { + if (!$handler->supports($request)) { + continue; + } + + $handlerFound = true; + + try { + /** @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); + } 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); + } + + break; + } + + if (!$handlerFound) { + $error = Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $request::getMethod()), $request->getId()); + $this->sendResponse($error, $session); + } + } + + /** + * @param Response>|Error $response + */ + private function handleResponse(Response|Error $response, SessionInterface $session): void + { + $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 + { + $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]); + } + } + } + + /** + * Sends a request to the client and returns the request ID. + */ + public function sendRequest(Request $request, int $timeout, SessionInterface $session): int + { + $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; + } + + /** + * Queues a notification for later delivery. + */ + public function sendNotification(Notification $notification, SessionInterface $session): void + { + $this->logger->info('Queueing server notification to client', [ + 'method' => $notification::getMethod(), + ]); + + $this->queueOutgoing($notification, ['type' => 'notification'], $session); + } + + /** + * 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); + } + + $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 message to JSON.', [ + 'exception' => $e, + ]); + + return; + } + + $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); + $queue[] = [ + 'message' => $encoded, + 'context' => $context, + ]; + $session->set(self::SESSION_OUTGOING_QUEUE, $queue); + } + + /** + * 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; + } + + $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; + } + } + + /** + * Get pending requests for a session. + * + * @return array The pending requests + */ + public function getPendingRequests(Uuid $sessionId): array + { + $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(); + } + } + + /** + * @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, null); + + 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, null); + + return null; + } + + $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, null, ['status_code' => 400]); + + return null; + } + + if (!$this->sessionStore->exists($sessionId)) { + $error = Error::forInvalidRequest('Session not found or has expired.'); + $this->sendResponse($error, null, ['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/RequestHandler/CallToolHandler.php b/src/Server/RequestHandler/CallToolHandler.php deleted file mode 100644 index a4dfebd0..00000000 --- a/src/Server/RequestHandler/CallToolHandler.php +++ /dev/null @@ -1,59 +0,0 @@ - - * @author Tobias Nyholm - */ -final class CallToolHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof CallToolRequest; - } - - public function handle(CallToolRequest|HasMethodInterface $message): Response|Error - { - \assert($message instanceof CallToolRequest); - - try { - $content = $this->registry->handleCallTool($message->name, $message->arguments); - } catch (ExceptionInterface $exception) { - $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)); - } -} diff --git a/src/Server/RequestHandler/GetPromptHandler.php b/src/Server/RequestHandler/GetPromptHandler.php deleted file mode 100644 index c74044d8..00000000 --- a/src/Server/RequestHandler/GetPromptHandler.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -final class GetPromptHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof GetPromptRequest; - } - - public function handle(GetPromptRequest|HasMethodInterface $message): Response|Error - { - \assert($message instanceof GetPromptRequest); - - try { - $messages = $this->registry->handleGetPrompt($message->name, $message->arguments); - } catch (ExceptionInterface) { - return Error::forInternalError('Error while handling prompt', $message->getId()); - } - - return new Response($message->getId(), new GetPromptResult($messages)); - } -} diff --git a/src/Server/RequestHandler/InitializeHandler.php b/src/Server/RequestHandler/InitializeHandler.php deleted file mode 100644 index 11d9b0ab..00000000 --- a/src/Server/RequestHandler/InitializeHandler.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ -final class InitializeHandler implements MethodHandlerInterface -{ - public function __construct( - public readonly ?ServerCapabilities $capabilities = new ServerCapabilities(), - public readonly ?Implementation $serverInfo = new Implementation(), - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof InitializeRequest; - } - - public function handle(InitializeRequest|HasMethodInterface $message): Response - { - \assert($message instanceof InitializeRequest); - - 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 deleted file mode 100644 index 942550a0..00000000 --- a/src/Server/RequestHandler/ListPromptsHandler.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -final class ListPromptsHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof ListPromptsRequest; - } - - public function handle(ListPromptsRequest|HasMethodInterface $message): Response - { - \assert($message instanceof ListPromptsRequest); - - $cursor = null; - $prompts = $this->registry->getPrompts($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($prompts) === $this->pageSize) ? $cursor : null; - - return new Response( - $message->getId(), - new ListPromptsResult($prompts, $nextCursor), - ); - } -} diff --git a/src/Server/RequestHandler/ListResourcesHandler.php b/src/Server/RequestHandler/ListResourcesHandler.php deleted file mode 100644 index 75804d84..00000000 --- a/src/Server/RequestHandler/ListResourcesHandler.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -final class ListResourcesHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof ListResourcesRequest; - } - - public function handle(ListResourcesRequest|HasMethodInterface $message): Response - { - \assert($message instanceof ListResourcesRequest); - - $cursor = null; - $resources = $this->registry->getResources($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($resources) === $this->pageSize) ? $cursor : null; - - return new Response( - $message->getId(), - new ListResourcesResult($resources, $nextCursor), - ); - } -} diff --git a/src/Server/RequestHandler/ListToolsHandler.php b/src/Server/RequestHandler/ListToolsHandler.php deleted file mode 100644 index ef35fa8d..00000000 --- a/src/Server/RequestHandler/ListToolsHandler.php +++ /dev/null @@ -1,51 +0,0 @@ - - * @author Tobias Nyholm - */ -final class ListToolsHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - private readonly int $pageSize = 20, - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof ListToolsRequest; - } - - public function handle(ListToolsRequest|HasMethodInterface $message): Response - { - \assert($message instanceof ListToolsRequest); - - $cursor = null; - $tools = $this->registry->getTools($this->pageSize, $message->cursor); - $nextCursor = (null !== $cursor && \count($tools) === $this->pageSize) ? $cursor : null; - - return new Response( - $message->getId(), - new ListToolsResult($tools, $nextCursor), - ); - } -} diff --git a/src/Server/RequestHandler/PingHandler.php b/src/Server/RequestHandler/PingHandler.php deleted file mode 100644 index 2cf8ec91..00000000 --- a/src/Server/RequestHandler/PingHandler.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ -final class PingHandler implements MethodHandlerInterface -{ - public function supports(HasMethodInterface $message): bool - { - return $message instanceof PingRequest; - } - - public function handle(PingRequest|HasMethodInterface $message): Response - { - \assert($message instanceof PingRequest); - - return new Response($message->getId(), new EmptyResult()); - } -} diff --git a/src/Server/RequestHandler/ReadResourceHandler.php b/src/Server/RequestHandler/ReadResourceHandler.php deleted file mode 100644 index 40746a6e..00000000 --- a/src/Server/RequestHandler/ReadResourceHandler.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ -final class ReadResourceHandler implements MethodHandlerInterface -{ - public function __construct( - private readonly Registry $registry, - ) { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof ReadResourceRequest; - } - - public function handle(ReadResourceRequest|HasMethodInterface $message): Response|Error - { - \assert($message instanceof ReadResourceRequest); - - try { - $contents = $this->registry->handleReadResource($message->uri); - } 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)); - } -} diff --git a/src/Server/ServerBuilder.php b/src/Server/ServerBuilder.php deleted file mode 100644 index f8867567..00000000 --- a/src/Server/ServerBuilder.php +++ /dev/null @@ -1,424 +0,0 @@ - - */ -final class ServerBuilder -{ - private ?Implementation $serverInfo = null; - - private ?LoggerInterface $logger = null; - - private ?CacheInterface $cache = null; - - private ?EventDispatcherInterface $eventDispatcher = null; - - private ?ContainerInterface $container = null; - - private ?int $paginationLimit = 50; - - private ?string $instructions = null; - - /** @var array< - * array{handler: array|string|Closure, - * name: string|null, - * description: string|null, - * annotations: ToolAnnotations|null} - * > */ - private array $manualTools = []; - - /** @var array< - * array{handler: array|string|Closure, - * uri: string, - * name: string|null, - * description: string|null, - * mimeType: string|null, - * size: int|null, - * annotations: Annotations|null} - * > */ - private array $manualResources = []; - - /** @var array< - * array{handler: array|string|Closure, - * uriTemplate: string, - * name: string|null, - * description: string|null, - * mimeType: string|null, - * annotations: Annotations|null} - * > */ - private array $manualResourceTemplates = []; - /** @var array< - * array{handler: array|string|Closure, - * name: string|null, - * description: string|null} - * > */ - private array $manualPrompts = []; - private ?string $discoveryBasePath = null; - /** - * @var array|string[] - */ - private array $discoveryScanDirs = []; - private array $discoveryExcludeDirs = []; - - /** - * Sets the server's identity. Required. - */ - public function withServerInfo(string $name, string $version, ?string $description = null): self - { - $this->serverInfo = new Implementation(trim($name), trim($version), $description); - - return $this; - } - - /** - * Configures the server's pagination limit. - */ - public function withPaginationLimit(int $paginationLimit): self - { - $this->paginationLimit = $paginationLimit; - - return $this; - } - - /** - * Configures the 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. - */ - public function withInstructions(?string $instructions): self - { - $this->instructions = $instructions; - - return $this; - } - - /** - * Provides a PSR-3 logger instance. Defaults to NullLogger. - */ - public function withLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - return $this; - } - - public function withEventDispatcher(EventDispatcherInterface $eventDispatcher): self - { - $this->eventDispatcher = $eventDispatcher; - - return $this; - } - - /** - * 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 - { - $this->container = $container; - - return $this; - } - - public function withDiscovery( - string $basePath, - array $scanDirs = ['.', 'src'], - array $excludeDirs = [], - ): self { - $this->discoveryBasePath = $basePath; - $this->discoveryScanDirs = $scanDirs; - $this->discoveryExcludeDirs = $excludeDirs; - - return $this; - } - - /** - * 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 - { - $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); - - return $this; - } - - /** - * 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 - { - $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); - - return $this; - } - - /** - * 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'); - - return $this; - } - - /** - * Manually registers a prompt handler. - */ - public function withPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self - { - $this->manualPrompts[] = compact('handler', 'name', 'description'); - - return $this; - } - - /** - * Builds the fully configured Server instance. - */ - public function build(): Server - { - $logger = $this->logger ?? new NullLogger(); - - $container = $this->container ?? new Container(); - $registry = new Registry(new ReferenceHandler($container), $this->eventDispatcher, $logger); - - $this->registerManualElements($registry, $logger); - - if (null !== $this->discoveryBasePath) { - $discovery = new Discoverer($registry, $logger); - $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); - } - - return new Server( - Handler::make($registry, $this->serverInfo, $logger), - $logger, - ); - } - - /** - * Helper to perform the actual registration based on stored data. - * Moved into the builder. - */ - private function registerManualElements(Registry $registry, LoggerInterface $logger = new NullLogger()): void - { - if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { - return; - } - - $docBlockParser = new DocBlockParser(logger: $logger); - $schemaGenerator = new SchemaGenerator($docBlockParser); - - // Register Tools - foreach ($this->manualTools 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 = $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]); - throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Resources - foreach ($this->manualResources 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 = $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]); - throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Templates - foreach ($this->manualResourceTemplates 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 = $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]); - throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Prompts - foreach ($this->manualPrompts 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 = $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]); - throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - $logger->debug('Manual element registration complete.'); - } - - 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/Server/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php new file mode 100644 index 00000000..0a7b7cd4 --- /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 $id): string|false + { + $path = $this->pathFor($id); + + 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 $id, string $data): bool + { + $path = $this->pathFor($id); + + $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 $id): bool + { + $path = $this->pathFor($id); + + 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..9f8077c6 --- /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 $id): string|false + { + $session = $this->store[$id->toRfc4122()] ?? ''; + if ('' === $session) { + return false; + } + + $currentTimestamp = $this->clock->now()->getTimestamp(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$id->toRfc4122()]); + + return false; + } + + return $session['data']; + } + + public function write(Uuid $id, string $data): bool + { + $this->store[$id->toRfc4122()] = [ + 'data' => $data, + 'timestamp' => $this->clock->now()->getTimestamp(), + ]; + + return true; + } + + public function destroy(Uuid $id): bool + { + if (isset($this->store[$id->toRfc4122()])) { + unset($this->store[$id->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/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; + } +} diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php new file mode 100644 index 00000000..e02fcc7c --- /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(): bool + { + return $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..8f93a6e4 --- /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(): bool; + + /** + * 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/BaseTransport.php b/src/Server/Transport/BaseTransport.php new file mode 100644 index 00000000..e5e495e4 --- /dev/null +++ b/src/Server/Transport/BaseTransport.php @@ -0,0 +1,139 @@ + + */ +abstract class BaseTransport +{ + use ManagesTransportCallbacks; + + protected ?Uuid $sessionId = null; + + /** + * @var McpFiber|null + */ + protected ?\Fiber $sessionFiber = null; + + protected LoggerInterface $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + + 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 015c70c5..934eeb34 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -11,43 +11,65 @@ namespace Mcp\Server\Transport; -use Mcp\Server\TransportInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Tobias Nyholm */ -class InMemoryTransport implements TransportInterface +class InMemoryTransport extends BaseTransport implements TransportInterface { - private bool $connected = true; - /** * @param list $messages */ public function __construct( private readonly array $messages = [], + ?LoggerInterface $logger = null, ) { + parent::__construct($logger); } - public function initialize(): void + public function onMessage(callable $listener): void { + $this->messageListener = $listener; } - public function isConnected(): bool + public function send(string $data, array $context): void { - return $this->connected; + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } } - public function receive(): \Generator + /** + * @return null + */ + public function listen(): mixed { - yield from $this->messages; - $this->connected = false; + $this->logger->info('InMemoryTransport is processing messages...'); + + foreach ($this->messages as $message) { + $this->handleMessage($message, $this->sessionId); + } + + $this->logger->info('InMemoryTransport finished processing.'); + $this->handleSessionEnd($this->sessionId); + + $this->sessionId = null; + + return null; } - public function send(string $data): void + public function setSessionId(?Uuid $sessionId): void { + $this->sessionId = $sessionId; } public function close(): void { + $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/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..cb994b94 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,17 +11,16 @@ namespace Mcp\Server\Transport; -use Mcp\Server\TransportInterface; +use Mcp\Schema\JsonRpc\Error; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; /** - * Heavily inspired by https://jolicode.com/blog/mcp-the-open-protocol-that-turns-llm-chatbots-into-intelligent-agents. - */ -class StdioTransport implements TransportInterface + * @implements TransportInterface + * + * @author Kyrian Obikwelu + * */ +class StdioTransport extends BaseTransport implements TransportInterface { - private string $buffer = ''; - /** * @param resource $input * @param resource $output @@ -29,47 +28,139 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly LoggerInterface $logger = new NullLogger(), + ?LoggerInterface $logger = null, ) { + 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 isConnected(): bool + public function listen(): int { - return true; + $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 receive(): \Generator + protected function processInput(): void { $line = fgets($this->input); + if (false === $line) { + usleep(50000); // 50ms - $this->logger->debug('Received message on StdioTransport.', [ - 'line' => $line, - ]); + return; + } + + $trimmedLine = trim($line); + if (!empty($trimmedLine)) { + $this->handleMessage($trimmedLine, $this->sessionId); + } + } + + private function processFiber(): void + { + if (null === $this->sessionFiber) { + return; + } + + if ($this->sessionFiber->isTerminated()) { + $this->handleFiberTermination(); + + return; + } + + if (!$this->sessionFiber->isSuspended()) { + return; + } + + $pendingRequests = $this->getPendingRequests($this->sessionId); + + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); - if (false === $line) { return; } - $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; + 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 send(string $data): void + private function handleFiberTermination(): void { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + $finalResult = $this->sessionFiber->getReturn(); - fwrite($this->output, $data.\PHP_EOL); + 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; + } + + private function flushOutgoingMessages(): void + { + $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 new file mode 100644 index 00000000..32a20586 --- /dev/null +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -0,0 +1,255 @@ + + * + * @author Kyrian Obikwelu + * */ +class StreamableHttpTransport extends BaseTransport implements TransportInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + private ?string $immediateResponse = null; + private ?int $immediateStatusCode = null; + + /** @var array */ + private array $corsHeaders; + + /** + * @param array $corsHeaders + */ + public function __construct( + private readonly ServerRequestInterface $request, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $corsHeaders = [], + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + $sessionIdString = $this->request->getHeaderLine('Mcp-Session-Id'); + $this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null; + + $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 send(string $data, array $context): void + { + $this->immediateResponse = $data; + $this->immediateStatusCode = $context['status_code'] ?? 200; + } + + public function listen(): ResponseInterface + { + return match ($this->request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'POST' => $this->handlePostRequest(), + 'DELETE' => $this->handleDeleteRequest(), + default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), + }; + } + + protected function handleOptionsRequest(): ResponseInterface + { + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + protected function handlePostRequest(): ResponseInterface + { + $body = $this->request->getBody()->getContents(); + $this->handleMessage($body, $this->sessionId); + + 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 (null !== $this->sessionFiber) { + $this->logger->info('Fiber suspended, handling via SSE.'); + + return $this->createStreamedResponse(); + } + + return $this->createJsonResponse(); + } + + protected function handleDeleteRequest(): ResponseInterface + { + if (!$this->sessionId) { + return $this->createErrorResponse(Error::forInvalidRequest('Mcp-Session-Id header is required.'), 400); + } + + $this->handleSessionEnd($this->sessionId); + + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } + + protected function createJsonResponse(): ResponseInterface + { + $outgoingMessages = $this->getOutgoingMessages($this->sessionId); + + if (empty($outgoingMessages)) { + return $this->withCorsHeaders($this->responseFactory->createResponse(202)); + } + + $messages = array_column($outgoingMessages, 'message'); + $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; + + $response = $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($responseBody)); + + if ($this->sessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); + } + + return $this->withCorsHeaders($response); + } + + protected function createStreamedResponse(): ResponseInterface + { + $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 handleFiberTermination(): void + { + $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]); + } + } + + $this->sessionFiber = null; + } + + protected function flushOutgoingMessages(?Uuid $sessionId): void + { + $messages = $this->getOutgoingMessages($sessionId); + + foreach ($messages as $message) { + echo "event: message\n"; + echo "data: {$message['message']}\n\n"; + @ob_flush(); + flush(); + } + } + + 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); + } + + protected function withCorsHeaders(ResponseInterface $response): ResponseInterface + { + foreach ($this->corsHeaders as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $response; + } +} diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php new file mode 100644 index 00000000..5a874a76 --- /dev/null +++ b/src/Server/Transport/TransportInterface.php @@ -0,0 +1,132 @@ +|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 + */ +interface TransportInterface +{ + /** + * Initializes the transport. + */ + public function initialize(): 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 TResult the result of the transport's execution, if any + */ + public function listen(): mixed; + + /** + * Send a message to the client immediately (bypassing session queue). + * + * 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' + * - 'status_code': int (HTTP status code for errors) + */ + public function send(string $data, array $context): void; + + /** + * 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. + * + * 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; + + /** + * Set a provider function to retrieve all queued outgoing messages. + * + * The transport calls this to retrieve all queued messages for a session. + * + * @param callable(Uuid $sessionId): array}> $provider + */ + 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/Capability/Discovery/DiscoveryTest.php b/tests/Capability/Discovery/DiscoveryTest.php deleted file mode 100644 index c6ab3e8a..00000000 --- a/tests/Capability/Discovery/DiscoveryTest.php +++ /dev/null @@ -1,182 +0,0 @@ -registry = new Registry(); - $this->discoverer = new Discoverer($this->registry); - } - - public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() - { - $this->discoverer->discover(__DIR__, ['Fixtures']); - - $tools = $this->registry->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->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); - - $invokableStatusRes = $this->registry->getResource('invokable://config/status'); - $this->assertInstanceOf(ResourceReference::class, $invokableStatusRes); - $this->assertFalse($invokableStatusRes->isManual); - $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler); - - $prompts = $this->registry->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']); - - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertInstanceOf(PromptReference::class, $simplePrompt); - $this->assertFalse($simplePrompt->isManual); - - $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); - $this->assertInstanceOf(PromptReference::class, $invokableGreeter); - $this->assertFalse($invokableGreeter->isManual); - $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler); - - $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentCreatorPrompt); - $this->assertFalse($contentCreatorPrompt->isManual); - $this->assertCount(3, $contentCreatorPrompt->completionProviders); - - $templates = $this->registry->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); - } - - public function testDoesNotDiscoverElementsFromExcludedDirectories() - { - $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('hidden_subdir_tool')); - - $this->registry->clear(); - - $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); - $this->assertNull($this->registry->getTool('hidden_subdir_tool')); - } - - public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() - { - $this->discoverer->discover(__DIR__, ['EmptyDir']); - $this->assertEmpty($this->registry->getTools()); - } - - public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() - { - $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); - - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name); - $this->assertNull($simplePrompt->prompt->description); - - $invokableCalc = $this->registry->getTool('InvokableCalculator'); - $this->assertEquals('InvokableCalculator', $invokableCalc->tool->name); - $this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description); - } - - public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes() - { - $this->discoverer->discover(__DIR__, ['Fixtures']); - - $contentPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentPrompt); - $this->assertCount(3, $contentPrompt->completionProviders); - - $typeProvider = $contentPrompt->completionProviders['type']; - $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); - - $statusProvider = $contentPrompt->completionProviders['status']; - $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); - - $priorityProvider = $contentPrompt->completionProviders['priority']; - $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); - - $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $contentTemplate); - $this->assertCount(1, $contentTemplate->completionProviders); - - $categoryProvider = $contentTemplate->completionProviders['category']; - $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); - } -} diff --git a/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php b/tests/Capability/Prompt/Completion/EnumCompletionProviderTest.php deleted file mode 100644 index 7d568f3c..00000000 --- a/tests/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/Capability/Prompt/Completion/ListCompletionProviderTest.php b/tests/Capability/Prompt/Completion/ListCompletionProviderTest.php deleted file mode 100644 index b5def78d..00000000 --- a/tests/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/Example/InspectorSnapshotTestCase.php b/tests/Example/InspectorSnapshotTestCase.php deleted file mode 100644 index fae80d51..00000000 --- a/tests/Example/InspectorSnapshotTestCase.php +++ /dev/null @@ -1,68 +0,0 @@ -getServerScript(), $method) - )->mustRun(); - - $output = $process->getOutput(); - $snapshotFile = $this->getSnapshotFilePath($method); - - if (!file_exists($snapshotFile)) { - file_put_contents($snapshotFile, $output.\PHP_EOL); - $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); - } - - $expected = file_get_contents($snapshotFile); - - $this->assertJsonStringEqualsJsonString($expected, $output); - } - - /** - * List of methods to test. - * - * @return array - */ - abstract public static function provideMethods(): array; - - abstract protected function getServerScript(): string; - - /** - * @return array - */ - protected static function provideListMethods(): array - { - return [ - 'Prompt Listing' => ['method' => 'prompts/list'], - 'Resource Listing' => ['method' => 'resources/list'], - // 'Resource Template Listing' => ['method' => 'resources/templates/list'], - 'Tool Listing' => ['method' => 'tools/list'], - ]; - } - - private function getSnapshotFilePath(string $method): string - { - $className = substr(static::class, strrpos(static::class, '\\') + 1); - - return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).'.json'; - } -} diff --git a/tests/Example/StdioCalculatorExampleTest.php b/tests/Example/StdioCalculatorExampleTest.php deleted file mode 100644 index e9012888..00000000 --- a/tests/Example/StdioCalculatorExampleTest.php +++ /dev/null @@ -1,30 +0,0 @@ -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/client-communication/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php new file mode 100644 index 00000000..36f133cd --- /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/combined-registration/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php new file mode 100644 index 00000000..6c104e28 --- /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/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..b0ffef81 --- /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/discovery-userprofile/server.php'; + } +} diff --git a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php new file mode 100644 index 00000000..5db629e4 --- /dev/null +++ b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php @@ -0,0 +1,100 @@ +startServer(); + } + + protected function tearDown(): void + { + $this->stopServer(); + } + + 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..9ed61ae1 --- /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/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/Example/snapshots/StdioCalculatorExampleTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json similarity index 95% rename from tests/Example/snapshots/StdioCalculatorExampleTest-prompts_list.json rename to tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json index 911451f2..7292222c 100644 --- a/tests/Example/snapshots/StdioCalculatorExampleTest-prompts_list.json +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json @@ -1,4 +1,3 @@ { "prompts": [] } - 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/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} 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/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json new file mode 100644 index 00000000..d02ef58d --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json @@ -0,0 +1,3 @@ +{ + "resources": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} 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/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json new file mode 100644 index 00000000..d02ef58d --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json @@ -0,0 +1,3 @@ +{ + "resources": [] +} diff --git a/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} 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 new file mode 100644 index 00000000..292670ae --- /dev/null +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -0,0 +1,137 @@ + $options */ + #[DataProvider('provideMethods')] + public function testOutputMatchesSnapshot( + string $method, + array $options = [], + ?string $testName = null, + ): void { + $inspector = \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION); + + $args = [ + 'npx', + $inspector, + '--cli', + ...$this->getServerConnectionArgs(), + '--transport', + $this->getTransport(), + '--method', + $method, + ]; + + // Options for tools/call + if (isset($options['toolName'])) { + $args[] = '--tool-name'; + $args[] = $options['toolName']; + + foreach ($options['toolArgs'] ?? [] as $key => $value) { + $args[] = '--tool-arg'; + 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 (isset($options['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(command: $args)) + ->mustRun() + ->getOutput(); + + $snapshotFile = $this->getSnapshotFilePath($method, $testName); + + $normalizedOutput = $this->normalizeTestOutput($output, $testName); + + if (!file_exists($snapshotFile)) { + file_put_contents($snapshotFile, $normalizedOutput.\PHP_EOL); + $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); + } + + $expected = file_get_contents($snapshotFile); + + $message = \sprintf('Output does not match snapshot "%s".', $snapshotFile); + $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput, $message); + } + + protected function normalizeTestOutput(string $output, ?string $testName = null): string + { + return $output; + } + + public static function provideMethods(): array + { + return [ + 'Prompt Listing' => ['method' => 'prompts/list'], + 'Resource Listing' => ['method' => 'resources/list'], + 'Resource Template Listing' => ['method' => 'resources/templates/list'], + 'Tool Listing' => ['method' => 'tools/list'], + ]; + } + + abstract protected function getSnapshotFilePath(string $method, ?string $testName = null): string; + + /** @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..438a3f8e --- /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/cached-discovery/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php new file mode 100644 index 00000000..d2f64c0d --- /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/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..87ce549c --- /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/discovery-calculator/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php new file mode 100644 index 00000000..c46eb753 --- /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/env-variables/server.php'; + } +} diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php new file mode 100644 index 00000000..1cb8179b --- /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/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/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} 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/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} 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/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json new file mode 100644 index 00000000..60848ab1 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json @@ -0,0 +1,76 @@ +{ + "tools": [ + { + "name": "add_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + }, + { + "name": "multiply_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + }, + { + "name": "divide_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + }, + { + "name": "power", + "inputSchema": { + "type": "object", + "properties": { + "base": { + "type": "integer" + }, + "exponent": { + "type": "integer" + } + }, + "required": [ + "base", + "exponent" + ] + } + } + ] +} 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/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json new file mode 100644 index 00000000..0b80ce0f --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json @@ -0,0 +1,10 @@ +{ + "resources": [ + { + "name": "system_stats", + "uri": "stats://system/overview", + "description": "Provides current system statistics.", + "mimeType": "application/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/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json new file mode 100644 index 00000000..247b27fc --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json @@ -0,0 +1,57 @@ +{ + "tools": [ + { + "name": "add_task", + "description": "Adds a new task for a given user.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user" + }, + "description": { + "type": "string", + "description": "the task description" + } + }, + "required": [ + "userId", + "description" + ] + } + }, + { + "name": "list_user_tasks", + "description": "Lists pending tasks for a specific user.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "the ID of the user" + } + }, + "required": [ + "userId" + ] + } + }, + { + "name": "complete_task", + "description": "Marks a task as complete.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "integer", + "description": "the ID of the task to complete" + } + }, + "required": [ + "taskId" + ] + } + } + ] +} 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/Example/snapshots/StdioCalculatorExampleTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json similarity index 54% rename from tests/Example/snapshots/StdioCalculatorExampleTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json index e6402959..e72548b5 100644 --- a/tests/Example/snapshots/StdioCalculatorExampleTest-resources_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json @@ -4,8 +4,16 @@ "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-resources_read-read_config.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json new file mode 100644 index 00000000..c15d9a8e --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.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_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/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json new file mode 100644 index 00000000..a73c8b94 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "19.8" + } + ], + "isError": false +} 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/Example/snapshots/StdioCalculatorExampleTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json similarity index 84% rename from tests/Example/snapshots/StdioCalculatorExampleTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json index 2812c849..5f184117 100644 --- a/tests/Example/snapshots/StdioCalculatorExampleTest-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": { @@ -48,4 +57,3 @@ } ] } - 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/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json new file mode 100644 index 00000000..32141675 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json @@ -0,0 +1,20 @@ +{ + "tools": [ + { + "name": "process_data_by_mode", + "description": "Performs an action that can be modified by an environment variable.", + "inputSchema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "some input data" + } + }, + "required": [ + "input" + ] + } + } + ] +} 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/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json new file mode 100644 index 00000000..ea0beb35 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json @@ -0,0 +1,15 @@ +{ + "prompts": [ + { + "name": "personalized_greeting", + "description": "A manually registered prompt template.", + "arguments": [ + { + "name": "userName", + "description": "the name of the user", + "required": true + } + ] + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json new file mode 100644 index 00000000..3faa772c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json @@ -0,0 +1,10 @@ +{ + "resources": [ + { + "name": "application_version", + "uri": "app://version", + "description": "A manually registered resource providing app version.", + "mimeType": "text/plain" + } + ] +} 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/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json new file mode 100644 index 00000000..65883802 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-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/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/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json new file mode 100644 index 00000000..a5ecc534 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json @@ -0,0 +1,20 @@ +{ + "tools": [ + { + "name": "echo_text", + "description": "A manually registered tool to echo input.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "the text to echo" + } + }, + "required": [ + "text" + ] + } + } + ] +} diff --git a/tests/JsonRpc/HandlerTest.php b/tests/JsonRpc/HandlerTest.php deleted file mode 100644 index a2fdeec9..00000000 --- a/tests/JsonRpc/HandlerTest.php +++ /dev/null @@ -1,84 +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'); - - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}' - ); - 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'); - - $jsonRpc = new Handler(MessageFactory::make(), [$handlerA, $handlerB, $handlerC]); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' - ); - iterator_to_array($result); - } -} diff --git a/tests/JsonRpc/MessageFactoryTest.php b/tests/JsonRpc/MessageFactoryTest.php deleted file mode 100644 index 9f43aad6..00000000 --- a/tests/JsonRpc/MessageFactoryTest.php +++ /dev/null @@ -1,95 +0,0 @@ -factory = new MessageFactory([ - CancelledNotification::class, - InitializedNotification::class, - GetPromptRequest::class, - ]); - } - - public function testCreateRequest() - { - $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; - - $result = $this->first($this->factory->create($json)); - - $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() - { - $json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}'; - - $result = $this->first($this->factory->create($json)); - - $this->assertInstanceOf(CancelledNotification::class, $result); - $this->assertSame('notifications/cancelled', $result::getMethod()); - $this->assertSame(12345, $result->requestId); - } - - public function testInvalidJson() - { - $this->expectException(\JsonException::class); - - $this->first($this->factory->create('invalid json')); - } - - public function testMissingMethod() - { - $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()); - } - - public function testBatchMissingMethod() - { - $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}]'); - - $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()); - - $result = array_shift($results); - $this->assertInstanceOf(InitializedNotification::class, $result); - } - - /** - * @param iterable $items - */ - private function first(iterable $items): mixed - { - foreach ($items as $item) { - return $item; - } - - return null; - } -} diff --git a/tests/ServerTest.php b/tests/ServerTest.php deleted file mode 100644 index 2c2129e0..00000000 --- a/tests/ServerTest.php +++ /dev/null @@ -1,46 +0,0 @@ -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')), ['success']); - - $transport = $this->getMockBuilder(InMemoryTransport::class) - ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send']) - ->getMock(); - $transport->expects($this->once())->method('send')->with('success'); - - $server = new Server($handler, $logger); - $server->connect($transport); - } -} diff --git a/tests/Capability/Attribute/CompletionProviderFixture.php b/tests/Unit/Capability/Attribute/CompletionProviderFixture.php similarity index 87% rename from tests/Capability/Attribute/CompletionProviderFixture.php rename to tests/Unit/Capability/Attribute/CompletionProviderFixture.php index 61096d29..6c623cf7 100644 --- a/tests/Capability/Attribute/CompletionProviderFixture.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderFixture.php @@ -9,9 +9,9 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +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/Capability/Attribute/CompletionProviderTest.php b/tests/Unit/Capability/Attribute/CompletionProviderTest.php similarity index 96% rename from tests/Capability/Attribute/CompletionProviderTest.php rename to tests/Unit/Capability/Attribute/CompletionProviderTest.php index 40b70a00..19a78750 100644 --- a/tests/Capability/Attribute/CompletionProviderTest.php +++ b/tests/Unit/Capability/Attribute/CompletionProviderTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +namespace Mcp\Tests\Unit\Capability\Attribute; use Mcp\Capability\Attribute\CompletionProvider; -use Mcp\Tests\Fixtures\Enum\StatusEnum; +use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; use PHPUnit\Framework\TestCase; class CompletionProviderTest extends TestCase diff --git a/tests/Capability/Attribute/McpPromptTest.php b/tests/Unit/Capability/Attribute/McpPromptTest.php similarity index 96% rename from tests/Capability/Attribute/McpPromptTest.php rename to tests/Unit/Capability/Attribute/McpPromptTest.php index a954e00a..d74cbcfe 100644 --- a/tests/Capability/Attribute/McpPromptTest.php +++ b/tests/Unit/Capability/Attribute/McpPromptTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +namespace Mcp\Tests\Unit\Capability\Attribute; use Mcp\Capability\Attribute\McpPrompt; use PHPUnit\Framework\TestCase; diff --git a/tests/Capability/Attribute/McpResourceTemplateTest.php b/tests/Unit/Capability/Attribute/McpResourceTemplateTest.php similarity index 97% rename from tests/Capability/Attribute/McpResourceTemplateTest.php rename to tests/Unit/Capability/Attribute/McpResourceTemplateTest.php index 25d468a7..3bbccb88 100644 --- a/tests/Capability/Attribute/McpResourceTemplateTest.php +++ b/tests/Unit/Capability/Attribute/McpResourceTemplateTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +namespace Mcp\Tests\Unit\Capability\Attribute; use Mcp\Capability\Attribute\McpResourceTemplate; use PHPUnit\Framework\TestCase; diff --git a/tests/Capability/Attribute/McpResourceTest.php b/tests/Unit/Capability/Attribute/McpResourceTest.php similarity index 97% rename from tests/Capability/Attribute/McpResourceTest.php rename to tests/Unit/Capability/Attribute/McpResourceTest.php index b63efeb8..423a4122 100644 --- a/tests/Capability/Attribute/McpResourceTest.php +++ b/tests/Unit/Capability/Attribute/McpResourceTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +namespace Mcp\Tests\Unit\Capability\Attribute; use Mcp\Capability\Attribute\McpResource; use PHPUnit\Framework\TestCase; diff --git a/tests/Capability/Attribute/McpToolTest.php b/tests/Unit/Capability/Attribute/McpToolTest.php similarity index 96% rename from tests/Capability/Attribute/McpToolTest.php rename to tests/Unit/Capability/Attribute/McpToolTest.php index 39550bd7..e2814af7 100644 --- a/tests/Capability/Attribute/McpToolTest.php +++ b/tests/Unit/Capability/Attribute/McpToolTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Attribute; +namespace Mcp\Tests\Unit\Capability\Attribute; use Mcp\Capability\Attribute\McpTool; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php new file mode 100644 index 00000000..75ffd88c --- /dev/null +++ b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php @@ -0,0 +1,94 @@ +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 + { + $discoverer = new Discoverer(); + + $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 + { + $discoverer = new Discoverer(); + + $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); + } +} diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php new file mode 100644 index 00000000..2208a915 --- /dev/null +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -0,0 +1,163 @@ +discoverer = new Discoverer(); + } + + public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + + $tools = $discovery->getTools(); + $this->assertCount(4, $tools); + + $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); + + $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); + + $this->assertArrayHasKey('invokable://config/status', $resources); + $this->assertFalse($resources['invokable://config/status']->isManual); + $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); + + $prompts = $discovery->getPrompts(); + $this->assertCount(4, $prompts); + + $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']); + + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts); + $this->assertFalse($prompts['simpleQuestionPrompt']->isManual); + + $this->assertArrayHasKey('InvokableGreeterPrompt', $prompts); + $this->assertFalse($prompts['InvokableGreeterPrompt']->isManual); + $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); + + $this->assertArrayHasKey('content_creator', $prompts); + $this->assertFalse($prompts['content_creator']->isManual); + $this->assertCount(3, $prompts['content_creator']->completionProviders); + + $templates = $discovery->getResourceTemplates(); + $this->assertCount(4, $templates); + + $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() + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools()); + + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); + $this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools()); + } + + public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() + { + $discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']); + + $this->assertTrue($discovery->isEmpty()); + } + + public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + + $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); + + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts = $discovery->getPrompts()); + $this->assertEquals('simpleQuestionPrompt', $prompts['simpleQuestionPrompt']->prompt->name); + $this->assertNull($prompts['simpleQuestionPrompt']->prompt->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() + { + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + + $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); + $this->assertCount(3, $prompts['content_creator']->completionProviders); + + $typeProvider = $prompts['content_creator']->completionProviders['type']; + $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); + + $statusProvider = $prompts['content_creator']->completionProviders['status']; + $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); + + $priorityProvider = $prompts['content_creator']->completionProviders['priority']; + $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); + + $this->assertArrayHasKey('content://{category}/{slug}', $templates = $discovery->getResourceTemplates()); + $this->assertCount(1, $templates['content://{category}/{slug}']->completionProviders); + + $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; + $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); + } +} diff --git a/tests/Capability/Discovery/DocBlockParserTest.php b/tests/Unit/Capability/Discovery/DocBlockParserTest.php similarity index 99% rename from tests/Capability/Discovery/DocBlockParserTest.php rename to tests/Unit/Capability/Discovery/DocBlockParserTest.php index 612ea4c4..b8501b28 100644 --- a/tests/Capability/Discovery/DocBlockParserTest.php +++ b/tests/Unit/Capability/Discovery/DocBlockParserTest.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 phpDocumentor\Reflection\DocBlock; diff --git a/tests/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php similarity index 93% rename from tests/Capability/Discovery/DocBlockTestFixture.php rename to tests/Unit/Capability/Discovery/DocBlockTestFixture.php index 2d237b42..a218ad63 100644 --- a/tests/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -9,7 +9,9 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery; +namespace Mcp\Tests\Unit\Capability\Discovery; + +use Mcp\Tests\Capability\Discovery\Missing; /** * A stub class for testing DocBlock parsing. @@ -73,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/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php similarity index 91% rename from tests/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php rename to tests/Unit/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php index 770c1a17..b4a8ac6d 100644 --- a/tests/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverablePromptHandler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; -use Mcp\Tests\Capability\Attribute\CompletionProviderFixture; +use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; class DiscoverablePromptHandler { diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php similarity index 95% rename from tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php rename to tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php index 1b8cf6d1..1dfd9368 100644 --- a/tests/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableResourceHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpResource; use Mcp\Schema\Annotations; diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php similarity index 92% rename from tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php rename to tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php index 2e6f19e7..f5d266b9 100644 --- a/tests/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableTemplateHandler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpResourceTemplate; -use Mcp\Tests\Capability\Attribute\CompletionProviderFixture; +use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; class DiscoverableTemplateHandler { diff --git a/tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php similarity index 94% rename from tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php rename to tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php index b6ab10e3..1c6bc394 100644 --- a/tests/Capability/Discovery/Fixtures/DiscoverableToolHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/DiscoverableToolHandler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpTool; use Mcp\Schema\ToolAnnotations; -use Mcp\Tests\Fixtures\Enum\BackedStringEnum; +use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum; class DiscoverableToolHandler { diff --git a/tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php similarity index 91% rename from tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php rename to tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php index b980befb..5ac67ddb 100644 --- a/tests/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php +++ b/tests/Unit/Capability/Discovery/Fixtures/EnhancedCompletionHandler.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; use Mcp\Capability\Attribute\McpResourceTemplate; -use Mcp\Tests\Fixtures\Enum\PriorityEnum; -use Mcp\Tests\Fixtures\Enum\StatusEnum; +use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum; +use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum; class EnhancedCompletionHandler { diff --git a/tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php similarity index 90% rename from tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php rename to tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php index 16cd278e..ab94e742 100644 --- a/tests/Capability/Discovery/Fixtures/InvocablePromptFixture.php +++ b/tests/Unit/Capability/Discovery/Fixtures/InvocablePromptFixture.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpPrompt; diff --git a/tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php similarity index 90% rename from tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php rename to tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php index 87e3b865..8af2d214 100644 --- a/tests/Capability/Discovery/Fixtures/InvocableResourceFixture.php +++ b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceFixture.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpResource; diff --git a/tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php similarity index 91% rename from tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php rename to tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php index 9a7d0c10..9f95e2e8 100644 --- a/tests/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php +++ b/tests/Unit/Capability/Discovery/Fixtures/InvocableResourceTemplateFixture.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpResourceTemplate; diff --git a/tests/Capability/Discovery/Fixtures/InvocableToolFixture.php b/tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php similarity index 90% rename from tests/Capability/Discovery/Fixtures/InvocableToolFixture.php rename to tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php index 54f1b342..7fa86568 100644 --- a/tests/Capability/Discovery/Fixtures/InvocableToolFixture.php +++ b/tests/Unit/Capability/Discovery/Fixtures/InvocableToolFixture.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; use Mcp\Capability\Attribute\McpTool; diff --git a/tests/Capability/Discovery/Fixtures/NonDiscoverableClass.php b/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php similarity index 90% rename from tests/Capability/Discovery/Fixtures/NonDiscoverableClass.php rename to tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php index 8274059e..21b84c64 100644 --- a/tests/Capability/Discovery/Fixtures/NonDiscoverableClass.php +++ b/tests/Unit/Capability/Discovery/Fixtures/NonDiscoverableClass.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures; class NonDiscoverableClass { diff --git a/tests/Capability/Discovery/Fixtures/SubDir/HiddenTool.php b/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php similarity index 86% rename from tests/Capability/Discovery/Fixtures/SubDir/HiddenTool.php rename to tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php index 49b3a3ae..c519b0bc 100644 --- a/tests/Capability/Discovery/Fixtures/SubDir/HiddenTool.php +++ b/tests/Unit/Capability/Discovery/Fixtures/SubDir/HiddenTool.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Tests\Capability\Discovery\Fixtures\SubDir; +namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures\SubDir; use Mcp\Capability\Attribute\McpTool; diff --git a/tests/Capability/Discovery/HandlerResolverTest.php b/tests/Unit/Capability/Discovery/HandlerResolverTest.php similarity index 97% rename from tests/Capability/Discovery/HandlerResolverTest.php rename to tests/Unit/Capability/Discovery/HandlerResolverTest.php index dbc22989..18347d3f 100644 --- a/tests/Capability/Discovery/HandlerResolverTest.php +++ b/tests/Unit/Capability/Discovery/HandlerResolverTest.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\HandlerResolver; use Mcp\Exception\InvalidArgumentException; @@ -94,7 +94,7 @@ public function testThrowsForNonExistentClassInStringHandler() public function testThrowsForNonInvokableClassStringHandler() { $this->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/Unit/Capability/RegistryTest.php b/tests/Unit/Capability/RegistryTest.php new file mode 100644 index 00000000..d97ccf41 --- /dev/null +++ b/tests/Unit/Capability/RegistryTest.php @@ -0,0 +1,574 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new Registry(null, $this->logger); + } + + public function testHasserReturnFalseForEmptyRegistry(): void + { + $this->assertFalse($this->registry->hasTools()); + $this->assertFalse($this->registry->hasResources()); + $this->assertFalse($this->registry->hasResourceTemplates()); + $this->assertFalse($this->registry->hasPrompts()); + } + + public function testHasToolsReturnsTrueWhenToolIsRegistered(): void + { + $tool = $this->createValidTool('test_tool'); + $this->registry->registerTool($tool, fn () => 'result'); + + $this->assertTrue($this->registry->hasTools()); + } + + 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->references); + $this->assertArrayHasKey('tool2', $tools->references); + $this->assertInstanceOf(Tool::class, $tools->references['tool1']); + $this->assertInstanceOf(Tool::class, $tools->references['tool2']); + } + + 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 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'); + + $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'); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $toolRef = $this->registry->getTool('test_tool'); + $this->assertTrue($toolRef->isManual); + } + + public function testGetToolThrowsExceptionForUnregisteredTool(): void + { + $this->expectException(ToolNotFoundException::class); + $this->expectExceptionMessage('Tool not found: "non_existent_tool".'); + + $this->registry->getTool('non_existent_tool'); + } + + public function testHasResourceReturnsTrueWhenResourceIsRegistered(): void + { + $resource = $this->createValidResource('test://resource'); + $this->registry->registerResource($resource, fn () => 'content'); + + $this->assertTrue($this->registry->hasResources()); + } + + 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->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 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 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'); + + $resourceRef = $this->registry->getResource('test://resource'); + $this->assertTrue($resourceRef->isManual); + } + + public function testGetResourceThrowsExceptionForUnregisteredResource(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://non_existent".'); + + $this->registry->getResource('test://non_existent'); + } + + public function testHasResourceTemplatesReturnsTrueWhenResourceTemplateIsRegistered(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $this->registry->registerResourceTemplate($template, fn () => 'content'); + + $this->assertTrue($this->registry->hasResourceTemplates()); + } + + 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->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 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 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 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 testGetResourceWithIncludeTemplatesFalseThrowsException(): void + { + $template = $this->createValidResourceTemplate('test://{id}'); + $handler = fn (string $id) => "content for {$id}"; + + $this->registry->registerResourceTemplate($template, $handler); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://123".'); + + $this->registry->getResource('test://123', false); + } + + 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'); + + $templateRef = $this->registry->getResourceTemplate('test://{id}'); + $this->assertTrue($templateRef->isManual); + } + + 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); + } + + public function testGetResourceTemplateThrowsExceptionForUnregisteredTemplate(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://{non_existent}".'); + + $this->registry->getResourceTemplate('test://{non_existent}'); + } + + public function testHasPromptsReturnsTrueWhenPromptIsRegistered(): void + { + $prompt = $this->createValidPrompt('test_prompt'); + $this->registry->registerPrompt($prompt, fn () => []); + + $this->assertTrue($this->registry->hasPrompts()); + } + + 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->references); + $this->assertArrayHasKey('prompt2', $prompts->references); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt1']); + $this->assertInstanceOf(Prompt::class, $prompts->references['prompt2']); + } + + 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 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'); + + $promptRef = $this->registry->getPrompt('test_prompt'); + $this->assertTrue($promptRef->isManual); + } + + public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void + { + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found: "non_existent_prompt".'); + + $this->registry->getPrompt('non_existent_prompt'); + } + + 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'); + $this->registry->registerResource($manualResource, fn () => 'manual', true); + $this->registry->registerResource($discoveredResource, fn () => 'discovered'); + $this->registry->registerPrompt($manualPrompt, fn () => [], [], true); + $this->registry->registerPrompt($discoveredPrompt, fn () => []); + $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); + $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered'); + + // 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(); + + // 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 + { + $manualTool = $this->createValidTool('manual_tool'); + $this->registry->registerTool($manualTool, fn () => 'manual', true); + + $this->logger + ->expects($this->never()) + ->method('debug'); + + $this->registry->clear(); + + $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'); + $this->registry->registerTool($tool2, fn () => 'second'); + + // 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/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'], + ]; + } +} 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/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php new file mode 100644 index 00000000..d38aabeb --- /dev/null +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -0,0 +1,403 @@ +factory = new MessageFactory([ + CancelledNotification::class, + InitializedNotification::class, + GetPromptRequest::class, + PingRequest::class, + ]); + } + + public function testCreateRequestWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; + + $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 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}}'; + + $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 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->factory->create('invalid json'); + } + + public function testMissingJsonRpcVersion(): void + { + $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 testInvalidJsonRpcVersion(): void + { + $json = '{"jsonrpc": "1.0", "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 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()); + } + + public function testErrorWithNonArrayErrorField(): void + { + $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); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); + } +} 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); + } +} 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 96% rename from tests/Schema/JsonRpc/RequestTest.php rename to tests/Unit/Schema/JsonRpc/RequestTest.php index a22b9272..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; @@ -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(); } 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); + } +} diff --git a/tests/Unit/Schema/ServerCapabilitiesTest.php b/tests/Unit/Schema/ServerCapabilitiesTest.php new file mode 100644 index 00000000..9d1562c1 --- /dev/null +++ b/tests/Unit/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/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php new file mode 100644 index 00000000..5b03f2bb --- /dev/null +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -0,0 +1,460 @@ +registry = $this->createMock(RegistryInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new CallToolHandler( + $this->registry, + $this->referenceHandler, + $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']); + $toolReference = $this->createMock(ToolReference::class); + $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('greet_user') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['name' => 'John', '_session' => $this->session]) + ->willReturn('Hello, John!'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Hello, John!') + ->willReturn([new TextContent('Hello, John!')]); + + // Logger may be called for debugging, so we don't assert never() + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $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->registry + ->expects($this->once()) + ->method('getTool') + ->with('simple_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['_session' => $this->session]) + ->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->assertEquals($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); + $toolReference = $this->createMock(ToolReference::class); + $expectedResult = new CallToolResult([new TextContent('Complex result')]); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('complex_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) + ->willReturn('Complex result'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Complex result') + ->willReturn([new TextContent('Complex result')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($expectedResult, $response->result); + } + + public function testHandleToolNotFoundExceptionReturnsError(): void + { + $request = $this->createCallToolRequest('nonexistent_tool', ['param' => 'value']); + + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('nonexistent_tool') + ->willThrowException(new ToolNotFoundException('nonexistent_tool')); + + $this->logger + ->expects($this->once()) + ->method('error'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); + } + + public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void + { + $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); + $exception = new ToolCallException('Tool execution failed'); + + $toolReference = $this->createMock(ToolReference::class); + $this->registry + ->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); + + $this->logger + ->expects($this->once()) + ->method('error'); + + $response = $this->handler->handle($request, $this->session); + + $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('Tool execution failed', $result->content[0]->text); + } + + public function testHandleWithNullResult(): void + { + $request = $this->createCallToolRequest('null_tool', []); + $expectedResult = new CallToolResult([]); + + $toolReference = $this->createMock(ToolReference::class); + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('null_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['_session' => $this->session]) + ->willReturn(null); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with(null) + ->willReturn([]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($expectedResult, $response->result); + } + + public function testConstructorWithDefaultLogger(): void + { + $handler = new CallToolHandler($this->registry, $this->referenceHandler); + + $this->assertInstanceOf(CallToolHandler::class, $handler); + } + + public function testHandleLogsErrorWithCorrectParameters(): void + { + $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); + $exception = new ToolCallException('Custom error message'); + + $toolReference = $this->createMock(ToolReference::class); + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('test_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session]) + ->willThrowException($exception); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Error while executing tool "test_tool": "Custom error message".', + [ + 'tool' => 'test_tool', + 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], + ], + ); + + $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->registry + ->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 + { + $request = $this->createCallToolRequest('tool-with_special.chars', []); + $expectedResult = new CallToolResult([new TextContent('Special tool result')]); + + $toolReference = $this->createMock(ToolReference::class); + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('tool-with_special.chars') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['_session' => $this->session]) + ->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->assertEquals($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')]); + + $toolReference = $this->createMock(ToolReference::class); + $this->registry + ->expects($this->once()) + ->method('getTool') + ->with('unicode_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) + ->willReturn('Unicode handled'); + + $toolReference + ->expects($this->once()) + ->method('formatResult') + ->with('Unicode handled') + ->willReturn([new TextContent('Unicode handled')]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $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->registry + ->expects($this->once()) + ->method('getTool') + ->with('structured_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['query' => 'php', '_session' => $this->session]) + ->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->registry + ->expects($this->once()) + ->method('getTool') + ->with('result_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['query' => 'php', '_session' => $this->session]) + ->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 + */ + 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/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php new file mode 100644 index 00000000..95b2e5c1 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -0,0 +1,438 @@ +referenceProvider = $this->createMock(RegistryInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new GetPromptHandler($this->referenceProvider, $this->referenceHandler); + } + + 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($expectedMessages); + + $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, ['_session' => $this->session]) + ->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->assertEquals($request->getId(), $response->id); + $this->assertEquals($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($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('personalized_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->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->assertEquals($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($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, ['_session' => $this->session]) + ->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->assertEquals($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($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('empty_args_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['_session' => $this->session]) + ->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->assertEquals($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($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('conversation_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, ['_session' => $this->session]) + ->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->assertEquals($expectedResult, $response->result); + } + + public function testHandlePromptNotFoundExceptionReturnsError(): void + { + $request = $this->createGetPromptRequest('nonexistent_prompt'); + $exception = new PromptNotFoundException('nonexistent_prompt'); + + $this->referenceProvider + ->expects($this->once()) + ->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::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('Failed to get prompt'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('failing_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('Failed to get 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($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('complex_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->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->assertEquals($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($expectedMessages); + + $promptReference = $this->createMock(PromptReference::class); + $this->referenceProvider + ->expects($this->once()) + ->method('getPrompt') + ->with('unicode_prompt') + ->willReturn($promptReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) + ->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->assertEquals($expectedResult, $response->result); + } + + public function testHandlePromptGetReturnsEmptyMessages(): void + { + $request = $this->createGetPromptRequest('empty_prompt'); + $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, ['_session' => $this->session]) + ->willReturn([]); + + $promptReference + ->expects($this->once()) + ->method('formatResult') + ->with([]) + ->willReturn([]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($expectedResult, $response->result); + } + + 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($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, array_merge($arguments, ['_session' => $this->session])) + ->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->assertEquals($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/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'] + ); + } +} diff --git a/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListPromptsHandlerTest.php new file mode 100644 index 00000000..21a73a9e --- /dev/null +++ b/tests/Unit/Server/Handler/Request/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/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); + } +} diff --git a/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php b/tests/Unit/Server/Handler/Request/ListResourcesHandlerTest.php new file mode 100644 index 00000000..232aef41 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/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/Unit/Server/Handler/Request/ListToolsHandlerTest.php b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php new file mode 100644 index 00000000..32f2c35e --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ListToolsHandlerTest.php @@ -0,0 +1,319 @@ +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); + } + + #[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) { + $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); + } +} diff --git a/tests/Unit/Server/Handler/Request/PingHandlerTest.php b/tests/Unit/Server/Handler/Request/PingHandlerTest.php new file mode 100644 index 00000000..2a33ecd1 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/PingHandlerTest.php @@ -0,0 +1,150 @@ +session = $this->createMock(SessionInterface::class); + $this->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->session); + + $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, $this->session); + $response2 = $this->handler->handle($request2, $this->session); + + $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, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // 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->session); + + $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, $this->session); + $response2 = $handler2->handle($request, $this->session); + + // 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], $this->session); + } + + // 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/Unit/Server/Handler/Request/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php new file mode 100644 index 00000000..a4ed0b17 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -0,0 +1,428 @@ +registry = $this->createMock(RegistryInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); + } + + 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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->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->assertEquals($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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'image/png'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->willReturn('fake-image-data'); + + $resourceReference + ->expects($this->once()) + ->method('formatResult') + ->with('fake-image-data', $uri, 'image/png') + ->willReturn([$expectedContent]); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'application/octet-stream'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->willReturn('binary-data'); + + $resourceReference + ->expects($this->once()) + ->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->assertEquals($expectedResult, $response->result); + } + + public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void + { + $uri = 'file://nonexistent/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->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::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void + { + $uri = 'file://corrupted/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceReadException('Failed to read resource: corrupted data'); + + $this->registry + ->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->registry + ->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('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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->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($expectedResult, $response->result); + + // Reset the mock for next iteration + $this->registry = $this->createMock(RegistryInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); + } + } + + 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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->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->assertEquals($expectedResult, $response->result); + } + + 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]); + + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: $mimeType), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) + ->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->assertEquals($expectedResult, $response->result); + + // Reset the mock for next iteration + $this->registry = $this->createMock(RegistryInterface::class); + $this->referenceHandler = $this->createMock(ReferenceHandlerInterface::class); + $this->handler = new ReadResourceHandler($this->registry, $this->referenceHandler); + } + } + + public function testHandleResourceNotFoundWithCustomMessage(): void + { + $uri = 'file://custom/missing.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); + } + + 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/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php new file mode 100644 index 00000000..fa949c38 --- /dev/null +++ b/tests/Unit/Server/ProtocolTest.php @@ -0,0 +1,737 @@ + */ + 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()); + + // 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; + } + }); + + // 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], + 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 + ); + + // 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')] + 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); + + // 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; + } + }); + + // 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: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionFactory: $this->sessionFactory, + sessionStore: $this->sessionStore, + ); + + $protocol->connect($this->transport); + + $sessionId = Uuid::v4(); + $protocol->processInput( + '{"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')] + public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionFactory->method('createWithId')->willReturn($session); + $this->sessionStore->method('exists')->willReturn(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; + }); + + $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: [], + 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 + ); + + // 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')] + 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); + + // 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; + } + }); + + // 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], + 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 + ); + + // 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')] + 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); + + // 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; + } + }); + + // 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], + 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 + ); + + // 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')] + 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); + + // 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; + } + }); + + // 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], + 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 + ); + + // 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')] + public function testBatchRequestsAreProcessed(): void + { + $handlerA = $this->createMock(RequestHandlerInterface::class); + $handlerA->method('supports')->willReturn(true); + $handlerA->method('handle')->willReturnCallback(function ($request) { + 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); + + // The protocol now queues responses instead of sending them directly + $session->expects($this->exactly(2)) + ->method('save'); + + $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 + ); + + // 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')] + 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 new file mode 100644 index 00000000..f7a8a370 --- /dev/null +++ b/tests/Unit/ServerTest.php @@ -0,0 +1,157 @@ + */ + 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 + { + $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); + } +}