diff --git a/.gitignore b/.gitignore index dda65497..8f118b26 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ composer.lock coverage vendor examples/**/dev.log +examples/**/cache examples/**/sessions diff --git a/README.md b/README.md index f441b3ca..52469357 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ $server = Server::builder() ->build(); $transport = new StdioTransport(); -$server->connect($transport); -$transport->listen(); + +$server->run($transport); ``` ### 3. Configure Your MCP Client @@ -175,15 +175,13 @@ $server = Server::builder() **STDIO Transport** (Command-line integration): ```php $transport = new StdioTransport(); -$server->connect($transport); -$transport->listen(); +$server->run($transport); ``` **HTTP Transport** (Web-based communication): ```php $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); -$server->connect($transport); -$response = $transport->listen(); +$response = $server->run($transport); // Handle $response in your web application ``` @@ -192,23 +190,42 @@ $response = $transport->listen(); By default, the SDK uses in-memory sessions. You can configure different session stores: ```php -use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\FileSessionStore; +use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16StoreSession; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; -// Use default in-memory sessions (TTL only) +// Use default in-memory sessions with custom TTL $server = Server::builder() ->setSession(ttl: 7200) // 2 hours ->build(); -// Use file-based sessions +// Override with file-based storage $server = Server::builder() ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); -// Use in-memory with custom TTL +// Override with in-memory storage and custom TTL $server = Server::builder() ->setSession(new InMemorySessionStore(3600)) ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16StoreSession( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); ``` ### Discovery Caching @@ -237,6 +254,7 @@ $server = Server::builder() - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage - [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts +- [Client Communiocation](docs/client-communication.md) - Communicating back to the client from server-side **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs diff --git a/composer.json b/composer.json index 00188f46..7f25eee7 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": "^8.1", "ext-fileinfo": "*", "opis/json-schema": "^2.4", + "php-http/discovery": "^1.20", "phpdocumentor/reflection-docblock": "^5.6", "psr/clock": "^1.0", "psr/container": "^2.0", @@ -51,19 +52,24 @@ }, "autoload-dev": { "psr-4": { - "Mcp\\Example\\HttpCombinedRegistration\\": "examples/http-combined-registration/", - "Mcp\\Example\\HttpComplexToolSchema\\": "examples/http-complex-tool-schema/", - "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", - "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", - "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", - "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", - "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", - "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", + "Mcp\\Example\\CachedDiscovery\\": "examples/cached-discovery/", + "Mcp\\Example\\ClientCommunication\\": "examples/client-communication/", + "Mcp\\Example\\CombinedRegistration\\": "examples/combined-registration/", + "Mcp\\Example\\ComplexToolSchema\\": "examples/complex-tool-schema/", + "Mcp\\Example\\CustomDependencies\\": "examples/custom-dependencies/", + "Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/", + "Mcp\\Example\\DiscoveryCalculator\\": "examples/discovery-calculator/", + "Mcp\\Example\\DiscoveryUserProfile\\": "examples/discovery-userprofile/", + "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", + "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", + "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", "Mcp\\Tests\\": "tests/" } }, "config": { - "sort-packages": true + "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 index 77fdc1f1..e004636e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,10 +7,7 @@ specific features and can be run independently to understand how the SDK works. - [Getting Started](#getting-started) - [Running Examples](#running-examples) -- [STDIO Examples](#stdio-examples) -- [HTTP Examples](#http-examples) -- [Advanced Patterns](#advanced-patterns) -- [Testing and Debugging](#testing-and-debugging) +- [Examples](#examples) ## Getting Started @@ -26,28 +23,30 @@ composer install ## Running Examples -### STDIO Examples +The bootstrapping of the example will choose the used transport based on the SAPI you use. -STDIO examples use standard input/output for communication: +### STDIO Transport + +The STDIO transport will use standard input/output for communication: ```bash # Interactive testing with MCP Inspector -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php # Run with debugging enabled -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php # Or configure the script path in your MCP client -# Path: php examples/stdio-discovery-calculator/server.php +# Path: php examples/discovery-calculator/server.php ``` -### HTTP Examples +### HTTP Transport -HTTP examples run as web servers: +The Streamable HTTP transport will be chosen if running examples with a web servers: ```bash # Start the server -php -S localhost:8000 examples/http-discovery-userprofile/server.php +php -S localhost:8000 examples/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -59,11 +58,11 @@ curl -X POST http://localhost:8000 \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}}' ``` -## STDIO Examples +## Examples ### Discovery Calculator -**File**: `examples/stdio-discovery-calculator/` +**File**: `examples/discovery-calculator/` **What it demonstrates:** - Attribute-based discovery using `#[McpTool]` and `#[McpResource]` @@ -87,14 +86,14 @@ public function getConfiguration(): array **Usage:** ```bash # Interactive testing -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php -# Or configure in MCP client: php examples/stdio-discovery-calculator/server.php +# Or configure in MCP client: php examples/discovery-calculator/server.php ``` ### Explicit Registration -**File**: `examples/stdio-explicit-registration/` +**File**: `examples/explicit-registration/` **What it demonstrates:** - Manual registration of tools, resources, and prompts @@ -111,7 +110,7 @@ $server = Server::builder() ### Environment Variables -**File**: `examples/stdio-env-variables/` +**File**: `examples/env-variables/` **What it demonstrates:** - Environment variable integration @@ -125,7 +124,7 @@ $server = Server::builder() ### Custom Dependencies -**File**: `examples/stdio-custom-dependencies/` +**File**: `examples/custom-dependencies/` **What it demonstrates:** - Dependency injection with PSR-11 containers @@ -145,7 +144,7 @@ $server = Server::builder() ### Cached Discovery -**File**: `examples/stdio-cached-discovery/` +**File**: `examples/cached-discovery/` **What it demonstrates:** - Discovery caching for improved performance @@ -163,11 +162,19 @@ $server = Server::builder() ->setDiscovery(__DIR__, ['.'], [], $cache) ``` -## HTTP Examples +### 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/http-discovery-userprofile/` +**File**: `examples/discovery-userprofile/` **What it demonstrates:** - HTTP transport with StreamableHttpTransport @@ -195,7 +202,7 @@ public function generateBio(string $userId, string $tone = 'professional'): arra **Usage:** ```bash # Start the HTTP server -php -S localhost:8000 examples/http-discovery-userprofile/server.php +php -S localhost:8000 examples/discovery-userprofile/server.php # Test with MCP Inspector npx @modelcontextprotocol/inspector http://localhost:8000 @@ -205,7 +212,7 @@ npx @modelcontextprotocol/inspector http://localhost:8000 ### Combined Registration -**File**: `examples/http-combined-registration/` +**File**: `examples/combined-registration/` **What it demonstrates:** - Mixing attribute discovery with manual registration @@ -222,7 +229,7 @@ $server = Server::builder() ### Complex Tool Schema -**File**: `examples/http-complex-tool-schema/` +**File**: `examples/complex-tool-schema/` **What it demonstrates:** - Advanced JSON schema definitions @@ -245,7 +252,7 @@ public function scheduleEvent(array $eventData): array ### Schema Showcase -**File**: `examples/http-schema-showcase/` +**File**: `examples/schema-showcase/` **What it demonstrates:** - Comprehensive JSON schema features diff --git a/docs/mcp-elements.md b/docs/mcp-elements.md index 911b3d52..1bb2690d 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -154,16 +154,21 @@ public function getMultipleContent(): array #### Error Handling -Tools can throw exceptions which are automatically converted to proper JSON-RPC error responses: +Tool handlers can throw any exception, but the type determines how it's handled: + +- **`ToolCallException`**: Converted to JSON-RPC response with `CallToolResult` where `isError: true`, allowing the LLM to see the error message and self-correct +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\ToolCallException; + #[McpTool] public function divideNumbers(float $a, float $b): float { if ($b === 0.0) { - throw new \InvalidArgumentException('Division by zero is not allowed'); + throw new ToolCallException('Division by zero is not allowed'); } - + return $a / $b; } @@ -171,14 +176,15 @@ public function divideNumbers(float $a, float $b): float public function processFile(string $filename): string { if (!file_exists($filename)) { - throw new \InvalidArgumentException("File not found: {$filename}"); + throw new ToolCallException("File not found: {$filename}"); } - + return file_get_contents($filename); } ``` -The SDK will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand. +**Recommendation**: Use `ToolCallException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. + ## Resources @@ -298,24 +304,31 @@ public function getMultipleResources(): array #### Error Handling -Resource handlers can throw exceptions for error cases: +Resource handlers can throw any exception, but the type determines how it's handled: + +- **`ResourceReadException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\ResourceReadException; + #[McpResource(uri: 'file://{path}')] public function getFile(string $path): string { if (!file_exists($path)) { - throw new \InvalidArgumentException("File not found: {$path}"); + throw new ResourceReadException("File not found: {$path}"); } - + if (!is_readable($path)) { - throw new \RuntimeException("File not readable: {$path}"); + throw new ResourceReadException("File not readable: {$path}"); } - + return file_get_contents($path); } ``` +**Recommendation**: Use `ResourceReadException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. + ## Resource Templates Resource templates are **dynamic resources** that use parameterized URIs with variables. They follow all the same rules @@ -449,6 +462,8 @@ public function explicitMessages(): array } ``` +The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. + #### Valid Message Roles - **`user`**: User input or questions @@ -456,33 +471,35 @@ public function explicitMessages(): array #### Error Handling -Prompt handlers can throw exceptions for invalid inputs: +Prompt handlers can throw any exception, but the type determines how it's handled: +- **`PromptGetException`**: Converted to JSON-RPC error response with the actual exception message +- **Any other exception**: Converted to JSON-RPC error response, but with a generic error message ```php +use Mcp\Exception\PromptGetException; + #[McpPrompt] public function generatePrompt(string $topic, string $style): array { $validStyles = ['casual', 'formal', 'technical']; - + if (!in_array($style, $validStyles)) { - throw new \InvalidArgumentException( + throw new PromptGetException( "Invalid style '{$style}'. Must be one of: " . implode(', ', $validStyles) ); } - + return [ ['role' => 'user', 'content' => "Write about {$topic} in a {$style} style"] ]; } ``` -The SDK automatically validates that all messages have valid roles and converts the result into the appropriate MCP prompt message format. +**Recommendation**: Use `PromptGetException` when you want to communicate specific errors to clients. Any other exception will still be converted to JSON-RPC compliant errors but with generic error messages. ## Completion Providers -Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools -and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have -dynamic parameters that benefit from completion hints. +Completion providers help MCP clients offer auto-completion suggestions for Resource Templates and Prompts. Unlike Tools and static Resources (which can be listed via `tools/list` and `resources/list`), Resource Templates and Prompts have dynamic parameters that benefit from completion hints. ### Completion Provider Types diff --git a/docs/server-builder.md b/docs/server-builder.md index f673000c..ac131374 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -12,7 +12,7 @@ various aspects of the server behavior. - [Session Management](#session-management) - [Manual Capability Registration](#manual-capability-registration) - [Service Dependencies](#service-dependencies) -- [Custom Method Handlers](#custom-method-handlers) +- [Custom Message Handlers](#custom-message-handlers) - [Complete Example](#complete-example) - [Method Reference](#method-reference) @@ -139,6 +139,9 @@ Configure session storage and lifecycle. By default, the SDK uses `InMemorySessi ```php use Mcp\Server\Session\FileSessionStore; use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\Psr16StoreSession; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Adapter\RedisAdapter; // Use default in-memory sessions with custom TTL $server = Server::builder() @@ -147,18 +150,35 @@ $server = Server::builder() // Override with file-based storage $server = Server::builder() - ->setSession(new FileSessionStore('/tmp/mcp-sessions')) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->build(); // Override with in-memory storage and custom TTL $server = Server::builder() ->setSession(new InMemorySessionStore(3600)) ->build(); + +// Override with PSR-16 cache-based storage +// Requires psr/simple-cache and symfony/cache (or any other PSR-16 implementation) +// composer require psr/simple-cache symfony/cache +$redisAdapter = new RedisAdapter( + RedisAdapter::createConnection('redis://localhost:6379'), + 'mcp_sessions' +); + +$server = Server::builder() + ->setSession(new Psr16StoreSession( + cache: new Psr16Cache($redisAdapter), + prefix: 'mcp-', + ttl: 3600 + )) + ->build(); ``` **Available Session Stores:** - `InMemorySessionStore`: Fast in-memory storage (default) - `FileSessionStore`: Persistent file-based storage +- `Psr16StoreSession`: PSR-16 compliant cache-based storage **Custom Session Stores:** @@ -344,50 +364,101 @@ $server = Server::builder() ->setEventDispatcher($eventDispatcher); ``` -## Custom Method Handlers +## Custom Message Handlers -**Low-level escape hatch.** Custom method handlers run before the SDK’s built-in handlers and give you total control over -individual JSON-RPC methods. They do not receive the builder’s registry, container, or discovery output unless you pass +**Low-level escape hatch.** Custom message handlers run before the SDK's built-in handlers and give you total control over +individual JSON-RPC messages. They do not receive the builder's registry, container, or discovery output unless you pass those dependencies in yourself. -Attach handlers with `addMethodHandler()` (single) or `addMethodHandlers()` (multiple). You can call these methods as -many times as needed; each call prepends the handlers so they execute before the defaults: +> **Warning**: Custom message handlers bypass discovery, manual capability registration, and container lookups (unless +> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler +> loads and executes them manually. Reach for this API only when you need that level of control and are comfortable +> taking on the additional plumbing. + +### Request Handlers + +Handle JSON-RPC requests (messages with an `id` that expect a response). Request handlers **must** return either a +`Response` or an `Error` object. + +Attach request handlers with `addRequestHandler()` (single) or `addRequestHandlers()` (multiple). You can call these +methods as many times as needed; each call prepends the handlers so they execute before the defaults: ```php $server = Server::builder() - ->addMethodHandler(new AuditHandler()) - ->addMethodHandlers([ - new CustomListToolsHandler(), + ->addRequestHandler(new CustomListToolsHandler()) + ->addRequestHandlers([ new CustomCallToolHandler(), + new CustomGetPromptHandler(), ]) ->build(); ``` -Custom handlers implement `MethodHandlerInterface`: +Request handlers implement `RequestHandlerInterface`: ```php -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\SessionInterface; -interface MethodHandlerInterface +interface RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool; + public function supports(Request $request): bool; - public function handle(HasMethodInterface $message, SessionInterface $session); + public function handle(Request $request, SessionInterface $session): Response|Error; } ``` -- `supports()` decides if the handler should look at the incoming message. -- `handle()` must return a JSON-RPC `Response`, an `Error`, or `null`. +- `supports()` decides if the handler should process the incoming request +- `handle()` **must** return a `Response` (on success) or an `Error` (on failure) -Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement -custom `tool/list` and `tool/call` methods independently of the registry. +### Notification Handlers -> **Warning**: Custom method handlers bypass discovery, manual capability registration, and container lookups (unlesss -> you explicitly pass them). Tools, resources, and prompts you register elsewhere will not show up unless your handler -> loads and executes them manually. -> Reach for this API only when you need that level of control and are comfortable taking on the additional plumbing. +Handle JSON-RPC notifications (messages without an `id` that don't expect a response). Notification handlers **do not** +return anything - they perform side effects only. + +Attach notification handlers with `addNotificationHandler()` (single) or `addNotificationHandlers()` (multiple): + +```php +$server = Server::builder() + ->addNotificationHandler(new LoggingNotificationHandler()) + ->addNotificationHandlers([ + new InitializedNotificationHandler(), + new ProgressNotificationHandler(), + ]) + ->build(); +``` + +Notification handlers implement `NotificationHandlerInterface`: + +```php +use Mcp\Schema\JsonRpc\Notification; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} +``` + +- `supports()` decides if the handler should process the incoming notification +- `handle()` performs side effects but **does not** return a value (notifications have no response) + +### Key Differences + +| Handler Type | Interface | Returns | Use Case | +|-------------|-----------|---------|----------| +| Request Handler | `RequestHandlerInterface` | `Response\|Error` | Handle requests that need responses (e.g., `tools/list`, `tools/call`) | +| Notification Handler | `NotificationHandlerInterface` | `void` | Handle fire-and-forget notifications (e.g., `notifications/initialized`, `notifications/progress`) | + +### Example + +Check out `examples/custom-method-handlers/server.php` for a complete example showing how to implement +custom `tools/list` and `tools/call` request handlers independently of the registry. ## Complete Example @@ -453,8 +524,10 @@ $server = Server::builder() | `setLogger()` | logger | Set PSR-3 logger | | `setContainer()` | container | Set PSR-11 container | | `setEventDispatcher()` | dispatcher | Set PSR-14 event dispatcher | -| `addMethodHandler()` | handler | Prepend a single custom method handler | -| `addMethodHandlers()` | handlers | Prepend multiple custom method handlers | +| `addRequestHandler()` | handler | Prepend a single custom request handler | +| `addRequestHandlers()` | handlers | Prepend multiple custom request handlers | +| `addNotificationHandler()` | handler | Prepend a single custom notification handler | +| `addNotificationHandlers()` | handlers | Prepend multiple custom notification handlers | | `addTool()` | handler, name?, description?, annotations?, inputSchema? | Register tool | | `addResource()` | handler, uri, name?, description?, mimeType?, size?, annotations? | Register resource | | `addResourceTemplate()` | handler, uriTemplate, name?, description?, mimeType?, annotations? | Register resource template | diff --git a/docs/transports.md b/docs/transports.md index dc0f50a2..290fd49c 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -22,9 +22,7 @@ $server = Server::builder() $transport = new SomeTransport(); -$server->connect($transport); - -$transport->listen(); // For STDIO, or handle response for HTTP +$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP ``` ## STDIO Transport @@ -70,9 +68,9 @@ $server = Server::builder() $transport = new StdioTransport(); -$server->connect($transport); +$status = $server->run($transport); -$transport->listen(); +exit($status); // 0 on clean shutdown, non-zero if STDIN errored ``` ### Client Configuration @@ -97,24 +95,90 @@ and process requests and send responses. It provides a flexible architecture tha ```php use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; +// PSR-17 factories are automatically discovered $transport = new StreamableHttpTransport( - request: $serverRequest, // PSR-7 server request - responseFactory: $responseFactory, // PSR-17 response factory - streamFactory: $streamFactory, // PSR-17 stream factory - logger: $logger // Optional PSR-3 logger + request: $serverRequest, // PSR-7 server request + responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null) + streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null) + logger: $logger // Optional PSR-3 logger ); ``` ### Parameters - **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request -- **`responseFactory`** (required): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses -- **`streamFactory`** (required): `StreamFactoryInterface` - PSR-17 factory for creating response body streams +- **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. +- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. +- **`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 @@ -128,34 +192,28 @@ This design allows integration with any PHP framework or application that suppor ### Basic Usage (Standalone) -Here's an opinionated example using Nyholm PSR-7 and Laminas emitter: +Here's a simplified example using PSR-17 discovery and Laminas emitter: ```php +use Http\Discovery\Psr17Factory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; use Mcp\Server\Session\FileSessionStore; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -// Create PSR-7 request from globals $psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); -$request = $creator->fromGlobals(); +$request = $psr17Factory->createServerRequestFromGlobals(); -// Build server $server = Server::builder() ->setServerInfo('HTTP Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) ->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions ->build(); -// Process request and get response -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); -$server->connect($transport); -$response = $transport->listen(); +$transport = new StreamableHttpTransport($request); + +$response = $server->run($transport); -// Emit response (new SapiEmitter())->emit($response); ``` @@ -180,28 +238,23 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Nyholm\Psr7\Factory\Psr17Factory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; class McpController { - #[Route('/mcp', name: 'mcp_endpoint'] - public function handle(Request $request, Server $mcpServer): Response + #[Route('/mcp', name: 'mcp_endpoint')] + public function handle(Request $request, Server $server): Response { - // Create PSR-7 factories - $psr17Factory = new Psr17Factory(); - $psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + // Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered) + $psrHttpFactory = new PsrHttpFactory(); $httpFoundationFactory = new HttpFoundationFactory(); - - // Convert Symfony request to PSR-7 $psrRequest = $psrHttpFactory->createRequest($request); - - // Process with MCP - $transport = new StreamableHttpTransport($psrRequest, $psr17Factory, $psr17Factory); - $mcpServer->connect($transport); - $psrResponse = $transport->listen(); - + + // 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); } @@ -226,21 +279,17 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; class McpController { - public function handle(ServerRequestInterface $request, Server $mcpServer): ResponseInterface + public function handle(ServerRequestInterface $request, Server $server): ResponseInterface { - $psr17Factory = new Psr17Factory(); - - // Create and connect the MCP HTTP transport - $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - $mcpServer->connect($transport); - + // Create the MCP HTTP transport + $transport = new StreamableHttpTransport($request); + // Process MCP request and return PSR-7 response // Laravel automatically handles PSR-7 responses - return $transport->listen(); + return $server->run($transport); } } @@ -255,33 +304,21 @@ Slim Framework works natively with PSR-7. Create a route handler using Slim's built-in factories and container: ```php -use Psr\Container\ContainerInterface; use Slim\Factory\AppFactory; -use Slim\Psr7\Factory\ResponseFactory; -use Slim\Psr7\Factory\StreamFactory; use Mcp\Server; use Mcp\Server\Transport\StreamableHttpTransport; $app = AppFactory::create(); -$container = $app->getContainer(); -$container->set('mcpServer', function (ContainerInterface $container) { - return Server::builder() +$app->any('/mcp', function ($request, $response) { + $server = Server::builder() ->setServerInfo('My MCP Server', '1.0.0') ->setDiscovery(__DIR__, ['.']) ->build(); -}); -$app->any('/mcp', function ($request, $response) { - $mcpServer = $this->get('mcpServer'); - - $responseFactory = new ResponseFactory(); - $streamFactory = new StreamFactory(); - - $transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory); - $mcpServer->connect($transport); - - return $transport->listen(); + $transport = new StreamableHttpTransport($request); + + return $server->run($transport); }); ``` @@ -345,6 +382,3 @@ npx @modelcontextprotocol/inspector http://localhost:8000 The choice between STDIO and HTTP transport depends on the client you want to integrate with. If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO. If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP. - -One additiona difference to consider is that STDIO is process-based (one session per process) while HTTP is -request-based (multiple sessions via headers). diff --git a/examples/README.md b/examples/README.md index 96b8773b..27874d71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,14 +3,15 @@ This directory contains various examples of how to use the PHP MCP SDK. You can run the examples with the dependencies already installed in the root directory of the SDK. +The bootstrapping of the example will choose the used transport based on the SAPI you use. For running an example, you execute the `server.php` like this: ```bash -# For examples using STDIO transport -php examples/stdio-discovery-calculator/server.php +# For using the STDIO transport: +php examples/discovery-calculator/server.php -# For examples using Streamable HTTP transport -php -S localhost:8000 examples/http-discovery-userprofile/server.php +# For using the Streamable HTTP transport: +php -S localhost:8000 examples/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -18,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php ``` ## Debugging @@ -29,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php ``` diff --git a/examples/bootstrap.php b/examples/bootstrap.php index e4a4b98d..8c0508ab 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -9,7 +9,13 @@ * file that was distributed with this source code. */ +use Http\Discovery\Psr17Factory; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Capability\Registry\Container; +use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Transport\TransportInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -21,6 +27,31 @@ exit(1); }); +/** + * @return TransportInterface|TransportInterface + */ +function transport(): TransportInterface +{ + if ('cli' === \PHP_SAPI) { + return new StdioTransport(logger: logger()); + } + + return new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + ); +} + +function shutdown(ResponseInterface|int $result): never +{ + if ('cli' === \PHP_SAPI) { + exit($result); + } + + (new SapiEmitter())->emit($result); + exit(0); +} + function logger(): LoggerInterface { return new class extends AbstractLogger { diff --git a/examples/stdio-cached-discovery/CachedCalculatorElements.php b/examples/cached-discovery/CachedCalculatorElements.php similarity index 88% rename from examples/stdio-cached-discovery/CachedCalculatorElements.php rename to examples/cached-discovery/CachedCalculatorElements.php index 2d5249df..ef67b0ff 100644 --- a/examples/stdio-cached-discovery/CachedCalculatorElements.php +++ b/examples/cached-discovery/CachedCalculatorElements.php @@ -11,9 +11,10 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCachedDiscovery; +namespace Mcp\Example\CachedDiscovery; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\ToolCallException; /** * Example MCP elements for demonstrating cached discovery. @@ -39,7 +40,7 @@ public function multiply(int $a, int $b): int public function divide(int $a, int $b): float { if (0 === $b) { - throw new \InvalidArgumentException('Division by zero is not allowed'); + throw new ToolCallException('Division by zero is not allowed'); } return $a / $b; diff --git a/examples/stdio-cached-discovery/server.php b/examples/cached-discovery/server.php similarity index 64% rename from examples/stdio-cached-discovery/server.php rename to examples/cached-discovery/server.php index dcd849ba..1d6c1e16 100644 --- a/examples/stdio-cached-discovery/server.php +++ b/examples/cached-discovery/server.php @@ -16,8 +16,8 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; -use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Mcp\Server\Session\FileSessionStore; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; use Symfony\Component\Cache\Psr16Cache; logger()->info('Starting MCP Cached Discovery Calculator Server...'); @@ -25,14 +25,13 @@ $server = Server::builder() ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.'], [], new Psr16Cache(new ArrayAdapter())) + ->setDiscovery(__DIR__, cache: new Psr16Cache(new PhpFilesAdapter(directory: __DIR__.'/cache'))) ->build(); -$transport = new StdioTransport(logger: logger()); +$result = $server->run(transport()); -$server->connect($transport); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -$transport->listen(); - -logger()->info('Server listener stopped gracefully.'); +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/http-combined-registration/DiscoveredElements.php b/examples/combined-registration/DiscoveredElements.php similarity index 95% rename from examples/http-combined-registration/DiscoveredElements.php rename to examples/combined-registration/DiscoveredElements.php index 7d030679..f7142466 100644 --- a/examples/http-combined-registration/DiscoveredElements.php +++ b/examples/combined-registration/DiscoveredElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpCombinedRegistration; +namespace Mcp\Example\CombinedRegistration; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/http-combined-registration/ManualHandlers.php b/examples/combined-registration/ManualHandlers.php similarity index 95% rename from examples/http-combined-registration/ManualHandlers.php rename to examples/combined-registration/ManualHandlers.php index 21f86e9d..65a86bc6 100644 --- a/examples/http-combined-registration/ManualHandlers.php +++ b/examples/combined-registration/ManualHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpCombinedRegistration; +namespace Mcp\Example\CombinedRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/http-combined-registration/server.php b/examples/combined-registration/server.php similarity index 56% rename from examples/http-combined-registration/server.php rename to examples/combined-registration/server.php index 6eefcfd3..02f26a4e 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/combined-registration/server.php @@ -13,25 +13,16 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Example\HttpCombinedRegistration\ManualHandlers; +use Mcp\Example\CombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; - -$psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); $server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->addTool([ManualHandlers::class, 'manualGreeter']) ->addResource( [ManualHandlers::class, 'getPriorityConfigManual'], @@ -40,10 +31,6 @@ ) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/http-complex-tool-schema/McpEventScheduler.php b/examples/complex-tool-schema/McpEventScheduler.php similarity index 93% rename from examples/http-complex-tool-schema/McpEventScheduler.php rename to examples/complex-tool-schema/McpEventScheduler.php index a9b3edc7..253ff5cb 100644 --- a/examples/http-complex-tool-schema/McpEventScheduler.php +++ b/examples/complex-tool-schema/McpEventScheduler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema; +namespace Mcp\Example\ComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\HttpComplexToolSchema\Model\EventPriority; -use Mcp\Example\HttpComplexToolSchema\Model\EventType; +use Mcp\Example\ComplexToolSchema\Model\EventPriority; +use Mcp\Example\ComplexToolSchema\Model\EventType; use Psr\Log\LoggerInterface; final class McpEventScheduler diff --git a/examples/http-complex-tool-schema/Model/EventPriority.php b/examples/complex-tool-schema/Model/EventPriority.php similarity index 87% rename from examples/http-complex-tool-schema/Model/EventPriority.php rename to examples/complex-tool-schema/Model/EventPriority.php index 1654be0e..e46a69da 100644 --- a/examples/http-complex-tool-schema/Model/EventPriority.php +++ b/examples/complex-tool-schema/Model/EventPriority.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/http-complex-tool-schema/Model/EventType.php b/examples/complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/http-complex-tool-schema/Model/EventType.php rename to examples/complex-tool-schema/Model/EventType.php index 5711662d..eaf0f431 100644 --- a/examples/http-complex-tool-schema/Model/EventType.php +++ b/examples/complex-tool-schema/Model/EventType.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventType: string { diff --git a/examples/http-complex-tool-schema/server.php b/examples/complex-tool-schema/server.php similarity index 51% rename from examples/http-complex-tool-schema/server.php rename to examples/complex-tool-schema/server.php index fbbe45a8..92f80b61 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/complex-tool-schema/server.php @@ -13,30 +13,17 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; - -$psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); $server = Server::builder() ->setServerInfo('Event Scheduler Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-custom-dependencies/McpTaskHandlers.php b/examples/custom-dependencies/McpTaskHandlers.php similarity index 93% rename from examples/stdio-custom-dependencies/McpTaskHandlers.php rename to examples/custom-dependencies/McpTaskHandlers.php index 262d1a86..00127a78 100644 --- a/examples/stdio-custom-dependencies/McpTaskHandlers.php +++ b/examples/custom-dependencies/McpTaskHandlers.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies; +namespace Mcp\Example\CustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; -use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; /** diff --git a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php b/examples/custom-dependencies/Service/InMemoryTaskRepository.php similarity index 97% rename from examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php rename to examples/custom-dependencies/Service/InMemoryTaskRepository.php index 47526c4c..dbf3b6ab 100644 --- a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php +++ b/examples/custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php b/examples/custom-dependencies/Service/StatsServiceInterface.php similarity index 87% rename from examples/stdio-custom-dependencies/Service/StatsServiceInterface.php rename to examples/custom-dependencies/Service/StatsServiceInterface.php index b8485d2a..079f7e23 100644 --- a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php +++ b/examples/custom-dependencies/Service/StatsServiceInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; interface StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/SystemStatsService.php b/examples/custom-dependencies/Service/SystemStatsService.php similarity index 94% rename from examples/stdio-custom-dependencies/Service/SystemStatsService.php rename to examples/custom-dependencies/Service/SystemStatsService.php index 075dd8a0..5cd44880 100644 --- a/examples/stdio-custom-dependencies/Service/SystemStatsService.php +++ b/examples/custom-dependencies/Service/SystemStatsService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; final class SystemStatsService implements StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php b/examples/custom-dependencies/Service/TaskRepositoryInterface.php similarity index 93% rename from examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php rename to examples/custom-dependencies/Service/TaskRepositoryInterface.php index 975cc711..7216634c 100644 --- a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php +++ b/examples/custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\DependenciesStdioExample\Service; +namespace Mcp\Example\CustomDependencies\Service; /** * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} diff --git a/examples/stdio-custom-dependencies/server.php b/examples/custom-dependencies/server.php similarity index 56% rename from examples/stdio-custom-dependencies/server.php rename to examples/custom-dependencies/server.php index 42d5b053..cd450e51 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/custom-dependencies/server.php @@ -13,14 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\DependenciesStdioExample\Service\InMemoryTaskRepository; -use Mcp\Example\DependenciesStdioExample\Service\StatsServiceInterface; -use Mcp\Example\DependenciesStdioExample\Service\SystemStatsService; -use Mcp\Example\DependenciesStdioExample\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\InMemoryTaskRepository; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\SystemStatsService; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Dependencies (Stdio) Server...'); +logger()->info('Starting MCP Custom Dependencies Server...'); $container = container(); @@ -32,15 +32,14 @@ $server = Server::builder() ->setServerInfo('Task Manager Server', '1.0.0') - ->setLogger(logger()) ->setContainer($container) - ->setDiscovery(__DIR__, ['.']) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) + ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$server->connect($transport); +$result = $server->run(transport()); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +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 index b1db5f8f..62f41df9 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -13,22 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Schema\Content\TextContent; -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\JsonRpc\Response; -use Mcp\Schema\Request\CallToolRequest; -use Mcp\Schema\Request\ListToolsRequest; -use Mcp\Schema\Result\CallToolResult; -use Mcp\Schema\Result\ListToolsResult; +use Mcp\Example\CustomMethodHandlers\CallToolRequestHandler; +use Mcp\Example\CustomMethodHandlers\ListToolsRequestHandler; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Handler\MethodHandlerInterface; -use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...'); +logger()->info('Starting MCP Custom Method Handlers Server...'); $toolDefinitions = [ 'say_hello' => new Tool( @@ -58,87 +50,21 @@ ), ]; -$listToolsHandler = new class($toolDefinitions) implements MethodHandlerInterface { - /** - * @param array $toolDefinitions - */ - public function __construct(private array $toolDefinitions) - { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof ListToolsRequest; - } - - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response - { - assert($message instanceof ListToolsRequest); - - return new Response($message->getId(), new ListToolsResult(array_values($this->toolDefinitions), null)); - } -}; - -$callToolHandler = new class($toolDefinitions) implements MethodHandlerInterface { - /** - * @param array $toolDefinitions - */ - public function __construct(private array $toolDefinitions) - { - } - - public function supports(HasMethodInterface $message): bool - { - return $message instanceof CallToolRequest; - } - - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error - { - assert($message instanceof CallToolRequest); - - $name = $message->name; - $args = $message->arguments ?? []; - - if (!isset($this->toolDefinitions[$name])) { - return new Error($message->getId(), Error::METHOD_NOT_FOUND, sprintf('Tool not found: %s', $name)); - } - - try { - switch ($name) { - case 'say_hello': - $greetName = (string) ($args['name'] ?? 'world'); - $result = [new TextContent(sprintf('Hello, %s!', $greetName))]; - break; - case 'sum': - $a = (float) ($args['a'] ?? 0); - $b = (float) ($args['b'] ?? 0); - $result = [new TextContent((string) ($a + $b))]; - break; - default: - $result = [new TextContent('Unknown tool')]; - } - - return new Response($message->getId(), new CallToolResult($result)); - } catch (Throwable $e) { - return new Response($message->getId(), new CallToolResult([new TextContent('Tool execution failed')], true)); - } - } -}; - +$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') - ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) ->setCapabilities($capabilities) - ->addMethodHandlers([$listToolsHandler, $callToolHandler]) + ->addRequestHandlers([$listToolsHandler, $callToolHandler]) ->build(); -$transport = new StdioTransport(logger: logger()); - -$server->connect($transport); +$result = $server->run(transport()); -$transport->listen(); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -logger()->info('Server listener stopped gracefully.'); +shutdown($result); diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/discovery-calculator/McpElements.php similarity index 91% rename from examples/stdio-discovery-calculator/McpElements.php rename to examples/discovery-calculator/McpElements.php index 71aea372..6276de57 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/discovery-calculator/McpElements.php @@ -9,10 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioDiscoveryCalculator; +namespace Mcp\Example\DiscoveryCalculator; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; +use Mcp\Exception\ToolCallException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -44,10 +45,10 @@ public function __construct( * @param float $b the second operand * @param string $operation the operation ('add', 'subtract', 'multiply', 'divide') * - * @return float|string the result of the calculation, or an error message string + * @return float the result of the calculation */ #[McpTool(name: 'calculate')] - public function calculate(float $a, float $b, string $operation): float|string + public function calculate(float $a, float $b, string $operation): float { $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); @@ -65,16 +66,16 @@ public function calculate(float $a, float $b, string $operation): float|string break; case 'divide': if (0 == $b) { - return 'Error: Division by zero.'; + throw new ToolCallException('Division by zero is not allowed.'); } $result = $a / $b; break; default: - return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; + throw new ToolCallException("Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."); } if (!$this->config['allow_negative'] && $result < 0) { - return 'Error: Negative results are disabled.'; + throw new ToolCallException('Negative results are disabled.'); } return round($result, $this->config['precision']); diff --git a/examples/stdio-discovery-calculator/server.php b/examples/discovery-calculator/server.php similarity index 61% rename from examples/stdio-discovery-calculator/server.php rename to examples/discovery-calculator/server.php index ad5c1cf7..c6d75e4d 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/discovery-calculator/server.php @@ -14,22 +14,21 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Stdio Calculator Server...'); +logger()->info('Starting MCP Calculator Server...'); $server = Server::builder() - ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setServerInfo('Calculator', '1.1.0', 'Basic Calculator') ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); +$result = $server->run(transport()); -$server->connect($transport); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -$transport->listen(); - -logger()->info('Server listener stopped gracefully.'); +shutdown($result); diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/discovery-userprofile/McpElements.php similarity index 89% rename from examples/http-discovery-userprofile/McpElements.php rename to examples/discovery-userprofile/McpElements.php index 7120a5ff..8418f09a 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/discovery-userprofile/McpElements.php @@ -9,13 +9,15 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; 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; /** @@ -37,7 +39,7 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger, ) { - $this->logger->debug('HttpDiscoveryUserProfile McpElements instantiated.'); + $this->logger->debug('DiscoveryUserProfile McpElements instantiated.'); } /** @@ -47,7 +49,7 @@ public function __construct( * * @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', @@ -61,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]; @@ -130,7 +131,7 @@ public function testToolWithoutParams(): array * * @return array[] prompt messages * - * @throws McpServerException if user not found + * @throws PromptGetException if user not found */ #[McpPrompt(name: 'generate_bio_prompt')] public function generateBio( @@ -140,7 +141,7 @@ public function generateBio( ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (!isset($this->users[$userId])) { - throw 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/http-discovery-userprofile/UserIdCompletionProvider.php b/examples/discovery-userprofile/UserIdCompletionProvider.php similarity index 92% rename from examples/http-discovery-userprofile/UserIdCompletionProvider.php rename to examples/discovery-userprofile/UserIdCompletionProvider.php index 37b1c5b7..69dfe4f0 100644 --- a/examples/http-discovery-userprofile/UserIdCompletionProvider.php +++ b/examples/discovery-userprofile/UserIdCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Completion\ProviderInterface; diff --git a/examples/http-discovery-userprofile/server.php b/examples/discovery-userprofile/server.php similarity index 80% rename from examples/http-discovery-userprofile/server.php rename to examples/discovery-userprofile/server.php index b1cba000..48033716 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/discovery-userprofile/server.php @@ -13,24 +13,15 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7Server\ServerRequestCreator; - -$psr17Factory = new Psr17Factory(); -$creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - -$request = $creator->fromGlobals(); $server = Server::builder() ->setServerInfo('HTTP User Profiles', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->addTool( function (float $a, float $b, string $operation = 'add'): array { $result = match ($operation) { @@ -75,10 +66,6 @@ function (): array { ) ->build(); -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - -$server->connect($transport); - -$response = $transport->listen(); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-env-variables/EnvToolHandler.php b/examples/env-variables/EnvToolHandler.php similarity index 56% rename from examples/stdio-env-variables/EnvToolHandler.php rename to examples/env-variables/EnvToolHandler.php index 49c914d5..b2d26311 100644 --- a/examples/stdio-env-variables/EnvToolHandler.php +++ b/examples/env-variables/EnvToolHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioEnvVariables; +namespace Mcp\Example\EnvVariables; use Mcp\Capability\Attribute\McpTool; @@ -23,7 +23,35 @@ final class EnvToolHandler * * @return array the result, varying by APP_MODE */ - #[McpTool(name: 'process_data_by_mode')] + #[McpTool( + name: 'process_data_by_mode', + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'mode' => [ + 'type' => 'string', + 'description' => 'The processing mode used', + ], + 'processed_input' => [ + 'type' => 'string', + 'description' => 'The processed input data (only in debug mode)', + ], + 'processed_input_length' => [ + 'type' => 'integer', + 'description' => 'The length of the processed input (only in production mode)', + ], + 'original_input' => [ + 'type' => 'string', + 'description' => 'The original input data (only in default mode)', + ], + 'message' => [ + 'type' => 'string', + 'description' => 'A descriptive message about the processing', + ], + ], + 'required' => ['mode', 'message'], + ] + )] public function processData(string $input): array { $appMode = getenv('APP_MODE'); // Read from environment diff --git a/examples/stdio-env-variables/server.php b/examples/env-variables/server.php similarity index 76% rename from examples/stdio-env-variables/server.php rename to examples/env-variables/server.php index 08848bba..48c43825 100644 --- a/examples/stdio-env-variables/server.php +++ b/examples/env-variables/server.php @@ -14,18 +14,17 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; /* |-------------------------------------------------------------------------- - | MCP Stdio Environment Variable Example Server + | MCP Environment Variable Example Server |-------------------------------------------------------------------------- | | This server demonstrates how to use environment variables to modify tool | behavior. The MCP client can set the APP_MODE environment variable to | control the server's behavior. | - | Configure your MCP Client (eg. Cursor) for this server like this: + | Configure your MCP Client (e.g. Cursor) for this server like this: | | { | "mcpServers": { @@ -47,18 +46,16 @@ | */ -logger()->info('Starting MCP Stdio Environment Variable Example Server...'); +logger()->info('Starting MCP Environment Variable Example Server...'); $server = Server::builder() ->setServerInfo('Env Var Server', '1.0.0') ->setLogger(logger()) - ->setDiscovery(__DIR__, ['.']) + ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); +$result = $server->run(transport()); -$server->connect($transport); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -$transport->listen(); - -logger()->info('Server listener stopped gracefully.'); +shutdown($result); diff --git a/examples/stdio-explicit-registration/SimpleHandlers.php b/examples/explicit-registration/SimpleHandlers.php similarity index 97% rename from examples/stdio-explicit-registration/SimpleHandlers.php rename to examples/explicit-registration/SimpleHandlers.php index 0a119e77..0fe385c1 100644 --- a/examples/stdio-explicit-registration/SimpleHandlers.php +++ b/examples/explicit-registration/SimpleHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioExplicitRegistration; +namespace Mcp\Example\ExplicitRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-explicit-registration/server.php b/examples/explicit-registration/server.php similarity index 57% rename from examples/stdio-explicit-registration/server.php rename to examples/explicit-registration/server.php index f225f989..5a61feef 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/explicit-registration/server.php @@ -13,26 +13,35 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\StdioExplicitRegistration\SimpleHandlers; +use Mcp\Example\ExplicitRegistration\SimpleHandlers; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; -logger()->info('Starting MCP Manual Registration (Stdio) Server...'); +logger()->info('Starting MCP Manual Registration Server...'); $server = Server::builder() - ->setServerInfo('Manual Reg Server', '1.0.0') + ->setServerInfo('Explicit Registration Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') ->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(); -$transport = new StdioTransport(logger: logger()); +$result = $server->run(transport()); -$server->connect($transport); +logger()->info('Server listener stopped gracefully.', ['result' => $result]); -$transport->listen(); - -logger()->info('Server listener stopped gracefully.'); +shutdown($result); diff --git a/examples/http-schema-showcase/server.php b/examples/http-schema-showcase/server.php index 8b35b6a2..e69de29b 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/http-schema-showcase/server.php @@ -1,42 +0,0 @@ -#!/usr/bin/env php -fromGlobals(); - -$server = Server::builder() - ->setServerInfo('Schema Showcase', '1.0.0') - ->setContainer(container()) - ->setLogger(logger()) - ->setSession(new FileSessionStore(__DIR__.'/sessions')) - ->setDiscovery(__DIR__, ['.']) - ->build(); - -$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory); - -$server->connect($transport); - -$response = $transport->listen(); - -(new SapiEmitter())->emit($response); diff --git a/examples/http-schema-showcase/SchemaShowcaseElements.php b/examples/schema-showcase/SchemaShowcaseElements.php similarity index 99% rename from examples/http-schema-showcase/SchemaShowcaseElements.php rename to examples/schema-showcase/SchemaShowcaseElements.php index d19bfa1f..6c7a4b93 100644 --- a/examples/http-schema-showcase/SchemaShowcaseElements.php +++ b/examples/schema-showcase/SchemaShowcaseElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpSchemaShowcase; +namespace Mcp\Example\SchemaShowcase; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; diff --git a/examples/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 f78906ae..a65e9974 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,19 +1,13 @@ parameters: ignoreErrors: - - - message: '#^Call to static method invalidParams\(\) on an unknown class Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException\.$#' - identifier: class.notFound - count: 2 - path: examples/http-discovery-userprofile/McpElements.php - - - - message: '#^PHPDoc tag @throws with type Mcp\\Example\\HttpDiscoveryUserProfile\\McpServerException is not subtype of Throwable$#' - identifier: throws.notThrowable - count: 2 - path: examples/http-discovery-userprofile/McpElements.php - - message: '#^Method Mcp\\Schema\\Result\\ReadResourceResult\:\:jsonSerialize\(\) should return array\{contents\: array\\} but returns array\{contents\: array\\}\.$#' identifier: return.type count: 1 path: src/Schema/Result/ReadResourceResult.php + + - + message: '#^Method Mcp\\Tests\\Unit\\Capability\\Discovery\\DocBlockTestFixture\:\:methodWithMultipleTags\(\) has RuntimeException in PHPDoc @throws tag but it''s not thrown\.$#' + identifier: throws.unusedType + count: 1 + path: tests/Unit/Capability/Discovery/DocBlockTestFixture.php diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 73677e33..8488e608 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -21,12 +21,14 @@ final class McpPrompt { /** - * @param ?string $name overrides the prompt name (defaults to method name) - * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 873d485c..86b33078 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -23,12 +23,13 @@ final class McpResource { /** - * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. - * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. - * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. - * @param ?string $mimeType the MIME type, if known and constant for this resource - * @param ?int $size the size in bytes, if known and constant - * @param Annotations|null $annotations optional annotations describing the resource + * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. + * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. + * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param ?string $mimeType the MIME type, if known and constant for this resource + * @param ?int $size the size in bytes, if known and constant + * @param Annotations|null $annotations optional annotations describing the resource + * @param ?array $meta Optional metadata */ public function __construct( public string $uri, @@ -37,6 +38,7 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 9b8887f1..14e66c5f 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -23,11 +23,12 @@ final class McpResourceTemplate { /** - * @param string $uriTemplate the URI template string (RFC 6570) - * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. - * @param ?string $description Optional description. Defaults to class DocBlock summary. - * @param ?string $mimeType optional default MIME type for matching resources - * @param ?Annotations $annotations optional annotations describing the resource template + * @param string $uriTemplate the URI template string (RFC 6570) + * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. + * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $mimeType optional default MIME type for matching resources + * @param ?Annotations $annotations optional annotations describing the resource template + * @param ?array $meta Optional metadata */ public function __construct( public string $uriTemplate, @@ -35,6 +36,7 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index d4af3e6c..54b4682e 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -20,14 +20,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 ?array $meta Optional metadata + * @param array|null $outputSchema Optional JSON Schema object */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, + public ?array $meta = null, + public ?array $outputSchema = null, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 1520a8e1..215ab02c 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -20,7 +20,6 @@ use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; @@ -48,7 +47,6 @@ class Discoverer { public function __construct( - private readonly ReferenceRegistryInterface $registry, private readonly LoggerInterface $logger = new NullLogger(), private ?DocBlockParser $docBlockParser = null, private ?SchemaGenerator $schemaGenerator = null, @@ -95,10 +93,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'base_path' => $basePath, ]); - $emptyState = new DiscoveryState(); - $this->registry->setDiscoveryState($emptyState); - - return $emptyState; + return new DiscoveryState(); } $finder->files() @@ -125,11 +120,7 @@ public function discover(string $basePath, array $directories, array $excludeDir 'resourceTemplates' => $discoveredCount['resourceTemplates'], ]); - $discoveryState = new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); - - $this->registry->setDiscoveryState($discoveryState); - - return $discoveryState; + return new DiscoveryState($tools, $resources, $prompts, $resourceTemplates); } /** @@ -231,7 +222,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); - $tool = new Tool($name, $inputSchema, $description, $instance->annotations); + $outputSchema = $instance->outputSchema ?? $this->schemaGenerator->generateOutputSchema($method); + $tool = new Tool($name, $inputSchema, $description, $instance->annotations, null, $instance->meta ?? null, $outputSchema); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -243,8 +235,10 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $mimeType = $instance->mimeType; $size = $instance->size; $annotations = $instance->annotations; - $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); + $meta = $instance->meta; + $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size, $meta); $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); + ++$discoveredCount['resources']; break; @@ -262,7 +256,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $prompt = new Prompt($name, $description, $arguments); + $meta = $instance->meta ?? null; + $prompt = new Prompt($name, $description, $arguments, $meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; @@ -274,7 +269,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $mimeType = $instance->mimeType; $annotations = $instance->annotations; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations); + $meta = $instance->meta ?? null; + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; diff --git a/src/Capability/Discovery/DocBlockParser.php b/src/Capability/Discovery/DocBlockParser.php index 91f417f2..127f92e8 100644 --- a/src/Capability/Discovery/DocBlockParser.php +++ b/src/Capability/Discovery/DocBlockParser.php @@ -13,6 +13,7 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Param; +use phpDocumentor\Reflection\DocBlock\Tags\TagWithType; use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactoryInterface; use Psr\Log\LoggerInterface; @@ -136,4 +137,52 @@ public function getParamTypeString(?Param $paramTag): ?string return null; } + + /** + * Gets the return type string from a Return tag. + */ + public function getReturnTypeString(?DocBlock $docBlock): ?string + { + if (null === $docBlock) { + return null; + } + + $returnTags = $docBlock->getTagsByName('return'); + if ([] === $returnTags) { + return null; + } + + $returnTag = $returnTags[0]; + + if (!$returnTag instanceof TagWithType) { + return null; + } + + $typeFromTag = trim((string) $returnTag->getType()); + + return ltrim($typeFromTag, '\\'); + } + + /** + * Gets the return type description from a Return tag. + */ + public function getReturnDescription(?DocBlock $docBlock): ?string + { + if (null === $docBlock) { + return null; + } + + $returnTags = $docBlock->getTagsByName('return'); + if ([] === $returnTags) { + return null; + } + + $returnTag = $returnTags[0]; + + if (!$returnTag instanceof TagWithType) { + return null; + } + + return trim((string) $returnTag->getDescription()) ?: null; + } } diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 936a35cf..430f892e 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; /** @@ -79,6 +80,37 @@ public function generate(\ReflectionMethod|\ReflectionFunction $reflection): arr return $this->buildSchemaFromParameters($parametersInfo, $methodSchema); } + /** + * Generates a JSON Schema object. + * + * @return array|null + */ + public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array + { + $docComment = $reflection->getDocComment() ?: null; + $docBlock = $this->docBlockParser->parseDocBlock($docComment); + + $docBlockReturnType = $this->docBlockParser->getReturnTypeString($docBlock); + $returnDescription = $this->docBlockParser->getReturnDescription($docBlock); + + $reflectionReturnType = $reflection->getReturnType(); + $reflectionReturnTypeString = $reflectionReturnType + ? $this->getTypeStringFromReflection($reflectionReturnType, $reflectionReturnType->allowsNull()) + : null; + + $docBlockHasGenerics = $docBlockReturnType && str_contains($docBlockReturnType, '<'); + $docBlockHasArrayNotation = $docBlockReturnType && str_ends_with(trim($docBlockReturnType), '[]'); + $returnTypeString = ($docBlockReturnType && ($docBlockHasGenerics || $docBlockHasArrayNotation)) + ? $docBlockReturnType + : ($reflectionReturnTypeString ?: $docBlockReturnType); + + if (!$returnTypeString || 'void' === strtolower($returnTypeString)) { + return null; + } + + return $this->buildOutputSchemaFromType($returnTypeString, $returnDescription); + } + /** * Extracts method-level or function-level Schema attribute. * @@ -409,10 +441,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(); @@ -784,4 +825,64 @@ private function mapSimpleTypeToJsonSchema(string $type): string default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object', }; } + + /** + * Builds an output schema from a return type string. + * + * @return array + */ + private function buildOutputSchemaFromType(string $returnTypeString, ?string $description): array + { + $trimmed = trim($returnTypeString); + + $isListArray = str_ends_with($trimmed, '[]'); + if (!$isListArray && str_starts_with($trimmed, 'array<') && str_ends_with($trimmed, '>')) { + // Extract generic parameters + $genericParams = substr($trimmed, 6, -1); // Remove 'array<' and '>' + $params = array_map('trim', explode(',', $genericParams)); + $isListArray = 1 === \count($params); + } + + if ($isListArray) { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'result' => [ + 'type' => 'array', + 'items' => [], + ], + ], + 'required' => ['result'], + ]; + } elseif (str_contains($trimmed, 'array')) { + $schema = [ + 'type' => 'object', + 'additionalProperties' => true, + ]; + } else { + // Handle union types (e.g., "float|string") + $types = array_filter( + array_map('trim', explode('|', $returnTypeString)), + fn ($t) => 'null' !== strtolower($t) + ); + + $mappedTypes = array_unique(array_map([$this, 'mapSimpleTypeToJsonSchema'], $types)); + $typeValue = 1 === \count($mappedTypes) ? $mappedTypes[0] : array_values($mappedTypes); + + // Handle other types - wrap in object for MCP compatibility + $schema = [ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => $typeValue], + ], + 'required' => ['result'], + ]; + } + + if ($description) { + $schema['description'] = $description; + } + + return $schema; + } } diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 6e54cfc2..3a13c323 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -23,6 +23,9 @@ use Mcp\Event\ResourceTemplateListChangedEvent; use Mcp\Event\ToolListChangedEvent; use Mcp\Exception\InvalidCursorException; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; @@ -63,6 +66,8 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt */ private array $resourceTemplates = []; + private ServerCapabilities $serverCapabilities; + public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), @@ -75,7 +80,7 @@ public function getCapabilities(): ServerCapabilities $this->logger->info('No capabilities registered on server.'); } - return new ServerCapabilities( + return $this->serverCapabilities ?? new ServerCapabilities( tools: [] !== $this->tools, toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: [] !== $this->resources || [] !== $this->resourceTemplates, @@ -207,43 +212,41 @@ public function clear(): void } } - public function getTool(string $name): ?ToolReference + public function getTool(string $name): ToolReference { - return $this->tools[$name] ?? null; + return $this->tools[$name] ?? throw new ToolNotFoundException($name); } public function getResource( string $uri, bool $includeTemplates = true, - ): ResourceReference|ResourceTemplateReference|null { + ): ResourceReference|ResourceTemplateReference { $registration = $this->resources[$uri] ?? null; if ($registration) { return $registration; } - if (!$includeTemplates) { - return null; - } - - foreach ($this->resourceTemplates as $template) { - if ($template->matches($uri)) { - return $template; + if ($includeTemplates) { + foreach ($this->resourceTemplates as $template) { + if ($template->matches($uri)) { + return $template; + } } } $this->logger->debug('No resource matched URI.', ['uri' => $uri]); - return null; + throw new ResourceNotFoundException($uri); } - public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference { - return $this->resourceTemplates[$uriTemplate] ?? null; + return $this->resourceTemplates[$uriTemplate] ?? throw new ResourceNotFoundException($uriTemplate); } - public function getPrompt(string $name): ?PromptReference + public function getPrompt(string $name): PromptReference { - return $this->prompts[$name] ?? null; + return $this->prompts[$name] ?? throw new PromptNotFoundException($name); } public function getTools(?int $limit = null, ?string $cursor = null): Page @@ -453,4 +456,9 @@ private function paginateResults(array $items, int $limit, ?string $cursor = nul return array_values(\array_slice($items, $offset, $limit)); } + + public function setServerCapabilities(ServerCapabilities $serverCapabilities): void + { + $this->serverCapabilities = $serverCapabilities; + } } diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php new file mode 100644 index 00000000..311f8fb7 --- /dev/null +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -0,0 +1,309 @@ + + * + * @phpstan-import-type Handler from ElementReference + */ +final class ArrayLoader implements LoaderInterface +{ + /** + * @param array{ + * handler: Handler, + * name: ?string, + * description: ?string, + * annotations: ?ToolAnnotations, + * icons: ?array[], + * meta: ?array, + * inputSchema: ?array, + * outputSchema: ?array + * }[] $tools + * @param array{ + * handler: Handler, + * uri: string, + * name: ?string, + * description: ?string, + * mimeType: ?string, + * size: int|null, + * annotations: ?Annotations, + * 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, + * meta: ?array + * }[] $prompts + */ + public function __construct( + private array $tools = [], + private array $resources = [], + private array $resourceTemplates = [], + private array $prompts = [], + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function load(ReferenceRegistryInterface $registry): void + { + $docBlockParser = new DocBlockParser(logger: $this->logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + + // Register Tools + foreach ($this->tools as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); + $outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection); + + $tool = new Tool($name, $inputSchema, $description, $data['annotations'] ?? null, $data['icons'] ?? null, $data['meta'] ?? null, $outputSchema); + $registry->registerTool($tool, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual tool', + ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Resources + foreach ($this->resources as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uri = $data['uri']; + $mimeType = $data['mimeType']; + $size = $data['size']; + $annotations = $data['annotations']; + $meta = $data['meta']; + + $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size, $meta); + $registry->registerResource($resource, $data['handler'], true); + + $handlerDesc = $this->getHandlerDescription($data['handler']); + $this->logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); + } catch (\Throwable $e) { + $this->logger->error( + 'Failed to register manual resource', + ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], + ); + throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); + } + } + + // Register Templates + foreach ($this->resourceTemplates as $data) { + try { + $reflection = HandlerResolver::resolve($data['handler']); + + if ($reflection instanceof \ReflectionFunction) { + $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); + $description = $data['description'] ?? null; + } else { + $classShortName = $reflection->getDeclaringClass()->getShortName(); + $methodName = $reflection->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); + + $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + } + + $uriTemplate = $data['uriTemplate']; + $mimeType = $data['mimeType']; + $annotations = $data['annotations']; + $meta = $data['meta']; + + $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations, $meta); + $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(), + ); + } + $meta = $data['meta']; + $prompt = new Prompt($name, $description, $arguments, $meta); + $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..33991368 --- /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(ReferenceRegistryInterface $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/Registry/Loader/LoaderInterface.php b/src/Capability/Registry/Loader/LoaderInterface.php new file mode 100644 index 00000000..5b7ba6a9 --- /dev/null +++ b/src/Capability/Registry/Loader/LoaderInterface.php @@ -0,0 +1,22 @@ + + */ +interface LoaderInterface +{ + public function load(ReferenceRegistryInterface $registry): void; +} diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index b0333788..7ce8c737 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,9 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientAwareInterface; +use Mcp\Server\ClientGateway; +use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** @@ -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/ReferenceProviderInterface.php b/src/Capability/Registry/ReferenceProviderInterface.php index 2f60014b..0af66e1f 100644 --- a/src/Capability/Registry/ReferenceProviderInterface.php +++ b/src/Capability/Registry/ReferenceProviderInterface.php @@ -11,6 +11,9 @@ namespace Mcp\Capability\Registry; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Page; /** @@ -23,23 +26,31 @@ interface ReferenceProviderInterface { /** * Gets a tool reference by name. + * + * @throws ToolNotFoundException */ - public function getTool(string $name): ?ToolReference; + public function getTool(string $name): ToolReference; /** * Gets a resource reference by URI (includes template matching if enabled). + * + * @throws ResourceNotFoundException */ - public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference|null; + public function getResource(string $uri, bool $includeTemplates = true): ResourceReference|ResourceTemplateReference; /** * Gets a resource template reference by URI template. + * + * @throws ResourceNotFoundException */ - public function getResourceTemplate(string $uriTemplate): ?ResourceTemplateReference; + public function getResourceTemplate(string $uriTemplate): ResourceTemplateReference; /** * Gets a prompt reference by name. + * + * @throws PromptNotFoundException */ - public function getPrompt(string $name): ?PromptReference; + public function getPrompt(string $name): PromptReference; /** * Gets all registered tools. diff --git a/src/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 c6f7ec46..88104c9d 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -17,7 +17,6 @@ use Mcp\Schema\Content\ResourceContents; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\ResourceTemplate; -use Psr\Container\ContainerInterface; /** * @phpstan-import-type Handler from ElementReference @@ -31,11 +30,6 @@ class ResourceTemplateReference extends ElementReference */ private array $variableNames; - /** - * @var array - */ - private array $uriVariables; - private string $uriTemplateRegex; /** @@ -53,22 +47,6 @@ public function __construct( $this->compileTemplate(); } - /** - * @deprecated - * Gets the resource template - * - * @return array array of ResourceContents objects - */ - public function read(ContainerInterface $container, string $uri): array - { - $arguments = array_merge($this->uriVariables, ['uri' => $uri]); - - $referenceHandler = new ReferenceHandler($container); - $result = $referenceHandler->handle($this, $arguments); - - return $this->formatResult($result, $uri, $this->resourceTemplate->mimeType); - } - /** * @return array */ @@ -79,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); - - foreach ($segments as $segment) { - if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { - $varName = $matches[1]; - $this->variableNames[] = $varName; - $regexParts[] = '(?P<'.$varName.'>[^/]+)'; - } else { - $regexParts[] = preg_quote($segment, '#'); - } - } + preg_match($this->uriTemplateRegex, $uri, $matches); - $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; + return array_filter($matches, fn ($key) => \in_array($key, $this->variableNames), \ARRAY_FILTER_USE_KEY); } /** @@ -146,9 +101,11 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = return [$readResult->resource]; } + $meta = $this->resourceTemplate->meta; + if (\is_array($readResult)) { if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]')]; + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; } $allAreResourceContents = true; @@ -196,14 +153,15 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - return [new TextResourceContents($uri, $mimeType, $readResult)]; + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; } if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { $result = BlobResourceContents::fromStream( $uri, $readResult, - $mimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream', + $meta ); @fclose($readResult); @@ -214,21 +172,21 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; } if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; } - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; } if (\is_array($readResult)) { @@ -237,7 +195,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = try { $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -247,7 +205,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -256,6 +214,26 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = throw new RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".\gettype($readResult)); } + private function compileTemplate(): void + { + $this->variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $this->resourceTemplate->uriTemplate, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $this->variableNames[] = $varName; + $regexParts[] = '(?P<'.$varName.'>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->uriTemplateRegex = '#^'.implode('', $regexParts).'$#'; + } + /** Guesses MIME type from string content (very basic) */ private function guessMimeTypeFromString(string $content): string { diff --git a/src/Capability/Registry/ToolReference.php b/src/Capability/Registry/ToolReference.php index e5b5a7df..fb8d7d90 100644 --- a/src/Capability/Registry/ToolReference.php +++ b/src/Capability/Registry/ToolReference.php @@ -111,4 +111,47 @@ public function formatResult(mixed $toolExecutionResult): array return [new TextContent($jsonResult)]; } + + /** + * Extracts structured content from a tool result using the output schema. + * + * @param mixed $toolExecutionResult the raw value returned by the tool's PHP method + * + * @return array|null the structured content, or null if not extractable + */ + public function extractStructuredContent(mixed $toolExecutionResult): ?array + { + $outputSchema = $this->tool->outputSchema; + if (null === $outputSchema) { + return null; + } + + if (isset($outputSchema['properties']['result'])) { + return ['result' => $this->normalizeValue($toolExecutionResult)]; + } + + if (isset($outputSchema['properties']) || isset($outputSchema['additionalProperties'])) { + if (\is_array($toolExecutionResult)) { + return $toolExecutionResult; + } + + if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) { + return $this->normalizeValue($toolExecutionResult); + } + } + + return null; + } + + /** + * Convert objects to arrays for a normalized structured content. + */ + private function normalizeValue(mixed $value): mixed + { + if (\is_object($value) && !($value instanceof Content)) { + return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR); + } + + return $value; + } } diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php new file mode 100644 index 00000000..f77394ff --- /dev/null +++ b/src/Exception/ClientException.php @@ -0,0 +1,33 @@ + + */ +class ClientException extends Exception +{ + public function __construct( + private readonly Error $error, + ) { + parent::__construct($error->message); + } + + public function getError(): Error + { + return $this->error; + } +} diff --git a/src/Exception/PromptGetException.php b/src/Exception/PromptGetException.php index 8970ea58..7eec0daf 100644 --- a/src/Exception/PromptGetException.php +++ b/src/Exception/PromptGetException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ final class PromptGetException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly GetPromptRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Handling prompt "%s" failed with error: "%s".', $request->name, $previous->getMessage()), previous: $previous); - } } diff --git a/src/Exception/PromptNotFoundException.php b/src/Exception/PromptNotFoundException.php index 82872e8b..81b7c6e5 100644 --- a/src/Exception/PromptNotFoundException.php +++ b/src/Exception/PromptNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\GetPromptRequest; - /** * @author Tobias Nyholm */ final class PromptNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly GetPromptRequest $request, + public readonly string $name, ) { - parent::__construct(\sprintf('Prompt not found for name: "%s".', $request->name)); + parent::__construct(\sprintf('Prompt not found: "%s".', $name)); } } diff --git a/src/Exception/ResourceNotFoundException.php b/src/Exception/ResourceNotFoundException.php index b5624bbc..420ac1a8 100644 --- a/src/Exception/ResourceNotFoundException.php +++ b/src/Exception/ResourceNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ final class ResourceNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly ReadResourceRequest $request, + public readonly string $uri, ) { - parent::__construct(\sprintf('Resource not found for uri: "%s".', $request->uri)); + parent::__construct(\sprintf('Resource not found for uri: "%s".', $uri)); } } diff --git a/src/Exception/ResourceReadException.php b/src/Exception/ResourceReadException.php index 913064b2..a89dec8e 100644 --- a/src/Exception/ResourceReadException.php +++ b/src/Exception/ResourceReadException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\ReadResourceRequest; - /** * @author Tobias Nyholm */ final class ResourceReadException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly ReadResourceRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Reading resource "%s" failed with error: "%s".', $request->uri, $previous?->getMessage() ?? ''), previous: $previous); - } } diff --git a/src/Exception/ToolCallException.php b/src/Exception/ToolCallException.php index 71978d9d..01ba9f45 100644 --- a/src/Exception/ToolCallException.php +++ b/src/Exception/ToolCallException.php @@ -11,17 +11,9 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ final class ToolCallException extends \RuntimeException implements ExceptionInterface { - public function __construct( - public readonly CallToolRequest $request, - ?\Throwable $previous = null, - ) { - parent::__construct(\sprintf('Tool call "%s" failed with error: "%s".', $request->name, $previous?->getMessage() ?? ''), previous: $previous); - } } diff --git a/src/Exception/ToolNotFoundException.php b/src/Exception/ToolNotFoundException.php index 3795d74e..0a864e75 100644 --- a/src/Exception/ToolNotFoundException.php +++ b/src/Exception/ToolNotFoundException.php @@ -11,16 +11,14 @@ namespace Mcp\Exception; -use Mcp\Schema\Request\CallToolRequest; - /** * @author Tobias Nyholm */ final class ToolNotFoundException extends \RuntimeException implements NotFoundExceptionInterface { public function __construct( - public readonly CallToolRequest $request, + public readonly string $name, ) { - parent::__construct(\sprintf('Tool not found for call: "%s".', $request->name)); + parent::__construct(\sprintf('Tool not found: "%s".', $name)); } } diff --git a/src/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/Schema/Enum/SamplingContext.php b/src/Schema/Enum/SamplingContext.php new file mode 100644 index 00000000..4c1f1851 --- /dev/null +++ b/src/Schema/Enum/SamplingContext.php @@ -0,0 +1,19 @@ + + */ +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 22083c75..cb8ed836 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -59,7 +59,13 @@ public static function fromArray(array $data): static $request->id = $data['id']; if (isset($data['params']['_meta'])) { - $request->meta = $data['params']['_meta']; + $meta = $data['params']['_meta']; + if ($meta instanceof \stdClass) { + $meta = (array) $meta; + } + if (\is_array($meta)) { + $request->meta = $meta; + } } return $request; @@ -75,6 +81,33 @@ public function getId(): string|int return $this->id; } + /** + * @return array|null + */ + public function getMeta(): ?array + { + return $this->meta; + } + + public function withId(string|int $id): static + { + $clone = clone $this; + $clone->id = $id; + + return $clone; + } + + /** + * @param array|null $meta + */ + public function withMeta(?array $meta): static + { + $clone = clone $this; + $clone->meta = $meta; + + return $clone; + } + /** * @return RequestData */ diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 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/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/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index f07a632b..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; @@ -29,29 +30,33 @@ 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 @@ -114,7 +119,7 @@ protected function getParams(): array } if (null !== $this->includeContext) { - $params['includeContext'] = $this->includeContext; + $params['includeContext'] = $this->includeContext->value; } if (null !== $this->temperature) { diff --git a/src/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 8a01bbb2..1a35f25d 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -38,12 +38,14 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param Content[] $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param mixed[] $structuredContent JSON content for `structuredContent` */ public function __construct( public readonly array $content, public readonly bool $isError = false, + public readonly ?array $structuredContent = null, ) { foreach ($this->content as $item) { if (!$item instanceof Content) { @@ -107,9 +109,15 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - return [ + $result = [ 'content' => $this->content, 'isError' => $this->isError, ]; + + if (null !== $this->structuredContent) { + $result['structuredContent'] = $this->structuredContent; + } + + 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/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/Tool.php b/src/Schema/Tool.php index 29646efc..fcd57e63 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -17,17 +17,28 @@ * 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', * properties: array, * required: string[]|null * } + * @phpstan-type ToolOutputSchema array{ + * type: 'object', + * properties?: array, + * required?: string[], + * additionalProperties?: bool, + * description?: string + * } * @phpstan-type ToolData array{ * name: string, * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * icons?: IconData[], + * _meta?: array, + * outputSchema?: ToolOutputSchema|null * } * * @author Kyrian Obikwelu @@ -35,18 +46,24 @@ 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 + * @param ToolOutputSchema|null $outputSchema optional JSON Schema object */ 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, + public readonly ?array $outputSchema = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); @@ -71,11 +88,23 @@ public static function fromArray(array $data): self $data['inputSchema']['properties'] = new \stdClass(); } + if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) { + if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) { + throw new InvalidArgumentException('Tool outputSchema must be of type "object".'); + } + if (isset($data['outputSchema']['properties']) && \is_array($data['outputSchema']['properties']) && empty($data['outputSchema']['properties'])) { + $data['outputSchema']['properties'] = new \stdClass(); + } + } + return new 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, + $data['outputSchema'] ); } @@ -85,6 +114,9 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], + * _meta?: array, + * outputSchema?: ToolOutputSchema * } */ public function jsonSerialize(): array @@ -99,6 +131,15 @@ 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; + } + if (null !== $this->outputSchema) { + $data['outputSchema'] = $this->outputSchema; + } return $data; } diff --git a/src/Server.php b/src/Server.php index ec3d6542..8657610a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,11 +12,10 @@ namespace Mcp; use Mcp\Server\Builder; -use Mcp\Server\Handler\JsonRpcHandler; +use Mcp\Server\Protocol; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Uid\Uuid; /** * @author Christopher Hertel @@ -25,7 +24,7 @@ final class Server { public function __construct( - private readonly JsonRpcHandler $jsonRpcHandler, + private readonly Protocol $protocol, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -35,26 +34,25 @@ public static function builder(): Builder return new Builder(); } - public function connect(TransportInterface $transport): void + /** + * @template TResult + * + * @param TransportInterface $transport + * + * @return TResult + */ + public function run(TransportInterface $transport): mixed { $transport->initialize(); - $this->logger->info('Transport initialized.', [ - 'transport' => $transport::class, - ]); + $this->protocol->connect($transport); - $transport->onMessage(function (string $message, ?Uuid $sessionId) use ($transport) { - foreach ($this->jsonRpcHandler->process($message, $sessionId) as [$response, $context]) { - if (null === $response) { - continue; - } + $this->logger->info('Running server...'); - $transport->send($response, $context); - } - }); - - $transport->onSessionEnd(function (Uuid $sessionId) { - $this->jsonRpcHandler->destroySession($sessionId); - }); + try { + return $transport->listen(); + } finally { + $transport->close(); + } } } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 8ac2dc4a..273dfe30 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -11,33 +11,23 @@ namespace Mcp\Server; -use Mcp\Capability\Attribute\CompletionProvider; -use Mcp\Capability\Completion\EnumCompletionProvider; -use Mcp\Capability\Completion\ListCompletionProvider; -use Mcp\Capability\Completion\ProviderInterface; -use Mcp\Capability\Discovery\CachedDiscoverer; -use Mcp\Capability\Discovery\Discoverer; -use Mcp\Capability\Discovery\DocBlockParser; -use Mcp\Capability\Discovery\HandlerResolver; -use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; use Mcp\Capability\Registry\ElementReference; +use Mcp\Capability\Registry\Loader\ArrayLoader; +use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; -use Mcp\Exception\ConfigurationException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Icon; use Mcp\Schema\Implementation; -use Mcp\Schema\Prompt; -use Mcp\Schema\PromptArgument; -use Mcp\Schema\Resource; -use Mcp\Schema\ResourceTemplate; use Mcp\Schema\ServerCapabilities; -use Mcp\Schema\Tool; use Mcp\Schema\ToolAnnotations; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Handler\MethodHandlerInterface; +use Mcp\Server\Handler\Notification\NotificationHandlerInterface; +use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -75,12 +65,17 @@ final class Builder private ?string $instructions = null; - private ?ServerCapabilities $explicitCapabilities = null; + private ?ProtocolVersion $protocolVersion = null; /** - * @var array + * @var array> */ - private array $customMethodHandlers = []; + private array $requestHandlers = []; + + /** + * @var array + */ + private array $notificationHandlers = []; /** * @var array{ @@ -88,6 +83,10 @@ final class Builder * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * icons: ?array[], + * meta: ?array, + * inputSchema: ?array, + * outputSchema: ?array * }[] */ private array $tools = []; @@ -101,6 +100,7 @@ final class Builder * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * meta: ?array * }[] */ private array $resources = []; @@ -113,6 +113,7 @@ final class Builder * description: ?string, * mimeType: ?string, * annotations: ?Annotations, + * meta: ?array * }[] */ private array $resourceTemplates = []; @@ -122,6 +123,7 @@ final class Builder * handler: Handler, * name: ?string, * description: ?string, + * meta: ?array * }[] */ private array $prompts = []; @@ -138,12 +140,26 @@ final class Builder */ 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): self - { - $this->serverInfo = new Implementation(trim($name), trim($version), $description); + public function setServerInfo( + string $name, + string $version, + ?string $description = null, + ?array $icons = null, + ?string $websiteUrl = null, + ): self { + $this->serverInfo = new Implementation(trim($name), trim($version), $description, $icons, $websiteUrl); return $this; } @@ -175,19 +191,21 @@ public function setInstructions(?string $instructions): self /** * Explicitly set server capabilities. If set, this overrides automatic detection. */ - public function setCapabilities(ServerCapabilities $capabilities): self + public function setCapabilities(ServerCapabilities $serverCapabilities): self { - $this->explicitCapabilities = $capabilities; + $this->serverCapabilities = $serverCapabilities; return $this; } /** * Register a single custom method handler. + * + * @param RequestHandlerInterface $handler */ - public function addMethodHandler(MethodHandlerInterface $handler): self + public function addRequestHandler(RequestHandlerInterface $handler): self { - $this->customMethodHandlers[] = $handler; + $this->requestHandlers[] = $handler; return $this; } @@ -195,12 +213,36 @@ public function addMethodHandler(MethodHandlerInterface $handler): self /** * Register multiple custom method handlers. * - * @param iterable $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 addMethodHandlers(iterable $handlers): self + public function addNotificationHandlers(iterable $handlers): self { foreach ($handlers as $handler) { - $this->customMethodHandlers[] = $handler; + $this->notificationHandlers[] = $handler; } return $this; @@ -264,11 +306,19 @@ public function setDiscovery( 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 array|null $outputSchema */ public function addTool( callable|array|string $handler, @@ -276,8 +326,9 @@ public function addTool( ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, + ?array $outputSchema = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'outputSchema'); return $this; } @@ -338,6 +389,16 @@ public function addPrompt(\Closure|array|string $handler, ?string $name = null, return $this; } + /** + * @param LoaderInterface[] $loaders + */ + public function addLoaders(...$loaders): self + { + $this->loaders = [...$this->loaders, ...$loaders]; + + return $this; + } + /** * Builds the fully configured Server instance. */ @@ -347,10 +408,21 @@ public function build(): Server $container = $this->container ?? new Container(); $registry = new Registry($this->eventDispatcher, $logger); - $this->registerCapabilities($registry, $logger); + $loaders = [ + ...$this->loaders, + new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), + ]; if (null !== $this->discoveryBasePath) { - $this->performDiscovery($registry, $logger); + $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $logger, $this->discoveryCache); + } + + foreach ($loaders as $loader) { + $loader->load($registry); + } + + if ($this->serverCapabilities) { + $registry->setServerCapabilities($this->serverCapabilities); } $sessionTtl = $this->sessionTtl ?? 3600; @@ -358,21 +430,24 @@ public function build(): Server $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); $messageFactory = MessageFactory::make(); - $capabilities = $this->explicitCapabilities ?? $registry->getCapabilities(); - $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); + $capabilities = $registry->getCapabilities(); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = new ReferenceHandler($container); - $methodHandlers = array_merge($this->customMethodHandlers, [ - new Handler\Request\PingHandler(), - new Handler\Request\InitializeHandler($configuration), - new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), + $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), - new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), - new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), + ]); + $notificationHandlers = array_merge($this->notificationHandlers, [ new Handler\Notification\InitializedHandler(), ]); @@ -384,247 +459,6 @@ public function build(): Server logger: $logger, ); - return new Server($jsonRpcHandler, $logger); - } - - private function performDiscovery( - Registry\ReferenceRegistryInterface $registry, - LoggerInterface $logger, - ): void { - $discovery = new Discoverer($registry, $logger); - - if (null !== $this->discoveryCache) { - $discovery = new CachedDiscoverer($discovery, $this->discoveryCache, $logger); - } - - $discovery->discover($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs); - } - - /** - * Helper to perform the actual registration based on stored data. - * Moved into the builder. - */ - private function registerCapabilities( - Registry\ReferenceRegistryInterface $registry, - LoggerInterface $logger = new NullLogger(), - ): void { - if (empty($this->tools) && empty($this->resources) && empty($this->resourceTemplates) && empty($this->prompts)) { - return; - } - - $docBlockParser = new DocBlockParser(logger: $logger); - $schemaGenerator = new SchemaGenerator($docBlockParser); - - // Register Tools - foreach ($this->tools as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_tool_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); - - $tool = new Tool($name, $inputSchema, $description, $data['annotations']); - $registry->registerTool($tool, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual tool', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Resources - foreach ($this->resources as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_resource_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $uri = $data['uri']; - $mimeType = $data['mimeType']; - $size = $data['size']; - $annotations = $data['annotations']; - - $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); - $registry->registerResource($resource, $data['handler'], true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual resource', - ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Templates - foreach ($this->resourceTemplates as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_template_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $uriTemplate = $data['uriTemplate']; - $mimeType = $data['mimeType']; - $annotations = $data['annotations']; - - $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual template', - ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); - } - } - - // Register Prompts - foreach ($this->prompts as $data) { - try { - $reflection = HandlerResolver::resolve($data['handler']); - - if ($reflection instanceof \ReflectionFunction) { - $name = $data['name'] ?? 'closure_prompt_'.spl_object_id($data['handler']); - $description = $data['description'] ?? null; - } else { - $classShortName = $reflection->getDeclaringClass()->getShortName(); - $methodName = $reflection->getName(); - $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); - - $name = $data['name'] ?? ('__invoke' === $methodName ? $classShortName : $methodName); - $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; - } - - $arguments = []; - $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags( - $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null), - ) : []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - - // Basic DI check (heuristic) - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $paramTag = $paramTags['$'.$param->getName()] ?? null; - $arguments[] = new PromptArgument( - $param->getName(), - $paramTag ? trim((string) $paramTag->getDescription()) : null, - !$param->isOptional() && !$param->isDefaultValueAvailable(), - ); - } - - $prompt = new Prompt($name, $description, $arguments); - $completionProviders = $this->getCompletionProviders($reflection); - $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); - - $handlerDesc = $this->getHandlerDescription($data['handler']); - $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); - } catch (\Throwable $e) { - $logger->error( - 'Failed to register manual prompt', - ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e], - ); - throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); - } - } - - $logger->debug('Manual element registration complete.'); - } - - /** - * @param Handler $handler - */ - private function getHandlerDescription(\Closure|array|string $handler): string - { - if ($handler instanceof \Closure) { - return 'Closure'; - } - - if (\is_array($handler)) { - return \sprintf('%s::%s', - \is_object($handler[0]) ? $handler[0]::class : $handler[0], - $handler[1], - ); - } - - return (string) $handler; - } - - /** - * @return array - */ - private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array - { - $completionProviders = []; - foreach ($reflection->getParameters() as $param) { - $reflectionType = $param->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { - continue; - } - - $completionAttributes = $param->getAttributes( - CompletionProvider::class, - \ReflectionAttribute::IS_INSTANCEOF, - ); - if (!empty($completionAttributes)) { - $attributeInstance = $completionAttributes[0]->newInstance(); - - if ($attributeInstance->provider) { - $completionProviders[$param->getName()] = $attributeInstance->provider; - } elseif ($attributeInstance->providerClass) { - $completionProviders[$param->getName()] = $attributeInstance->providerClass; - } elseif ($attributeInstance->values) { - $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); - } elseif ($attributeInstance->enum) { - $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); - } - } - } - - return $completionProviders; + 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 ab629b58..2f0bd7f0 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -11,6 +11,7 @@ namespace Mcp\Server; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; @@ -34,6 +35,7 @@ public function __construct( public readonly ServerCapabilities $capabilities, public readonly int $paginationLimit = 50, public readonly ?string $instructions = null, + public readonly ?ProtocolVersion $protocolVersion = null, ) { } } diff --git a/src/Server/Handler/JsonRpcHandler.php b/src/Server/Handler/JsonRpcHandler.php deleted file mode 100644 index 5f785bc5..00000000 --- a/src/Server/Handler/JsonRpcHandler.php +++ /dev/null @@ -1,258 +0,0 @@ - - */ -class JsonRpcHandler -{ - /** - * @param array $methodHandlers - */ - public function __construct( - private readonly array $methodHandlers, - private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, - private readonly LoggerInterface $logger = new NullLogger(), - ) { - } - - /** - * @return iterable}> - */ - public function process(string $input, ?Uuid $sessionId): iterable - { - $this->logger->info('Received message to process.', ['message' => $input]); - - $this->runGarbageCollection(); - - try { - $messages = iterator_to_array($this->messageFactory->create($input)); - } catch (\JsonException $e) { - $this->logger->warning('Failed to decode json message.', ['exception' => $e]); - $error = Error::forParseError($e->getMessage()); - yield [$this->encodeResponse($error), []]; - - return; - } - - $hasInitializeRequest = false; - foreach ($messages as $message) { - if ($message instanceof InitializeRequest) { - $hasInitializeRequest = true; - break; - } - } - - $session = null; - - if ($hasInitializeRequest) { - // Spec: An initialize request must not be part of a batch. - if (\count($messages) > 1) { - $error = Error::forInvalidRequest('The "initialize" request MUST NOT be part of a batch.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - // Spec: An initialize request must not have a session ID. - if ($sessionId) { - $error = Error::forInvalidRequest('A session ID MUST NOT be sent with an "initialize" request.'); - yield [$this->encodeResponse($error), []]; - - return; - } - - $session = $this->sessionFactory->create($this->sessionStore); - } else { - if (!$sessionId) { - $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); - yield [$this->encodeResponse($error), ['status_code' => 400]]; - - return; - } - - if (!$this->sessionStore->exists($sessionId)) { - $error = Error::forInvalidRequest('Session not found or has expired.'); - yield [$this->encodeResponse($error), ['status_code' => 404]]; - - return; - } - - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); - } - - foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->logger->warning('Failed to create message.', ['exception' => $message]); - $error = Error::forInvalidRequest($message->getMessage()); - yield [$this->encodeResponse($error), []]; - continue; - } - - $this->logger->debug(\sprintf('Decoded incoming message "%s".', $message::class), [ - 'method' => $message->getMethod(), - ]); - - $messageId = $message instanceof Request ? $message->getId() : 0; - - try { - $response = $this->handle($message, $session); - yield [$this->encodeResponse($response), ['session_id' => $session->getId()]]; - } catch (\DomainException) { - yield [null, []]; - } catch (NotFoundExceptionInterface $e) { - $this->logger->warning( - \sprintf('Failed to create response: %s', $e->getMessage()), - ['exception' => $e], - ); - - $error = Error::forMethodNotFound($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\InvalidArgumentException $e) { - $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInvalidParams($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } catch (\Throwable $e) { - $this->logger->critical(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - - $error = Error::forInternalError($e->getMessage(), $messageId); - yield [$this->encodeResponse($error), []]; - } - } - - $session->save(); - } - - /** - * Encodes a response to JSON, handling encoding errors gracefully. - */ - private function encodeResponse(Response|Error|null $response): ?string - { - if (null === $response) { - $this->logger->info('The handler created an empty response.'); - - return null; - } - - $this->logger->info('Encoding response.', ['response' => $response]); - - try { - if ($response instanceof Response && [] === $response->result) { - return json_encode($response, \JSON_THROW_ON_ERROR | \JSON_FORCE_OBJECT); - } - - return json_encode($response, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', [ - 'message_id' => $response->getId(), - 'exception' => $e, - ]); - - $fallbackError = new Error( - id: $response->getId(), - code: Error::INTERNAL_ERROR, - message: 'Response could not be encoded to JSON' - ); - - return json_encode($fallbackError, \JSON_THROW_ON_ERROR); - } - } - - /** - * If the handler does support the message, but does not create a response, other handlers will be tried. - * - * @throws NotFoundExceptionInterface When no handler is found for the request method - * @throws ExceptionInterface When a request handler throws an exception - */ - private function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null - { - $this->logger->info(\sprintf('Handling message for method "%s".', $message::getMethod()), [ - 'message' => $message, - ]); - - $handled = false; - foreach ($this->methodHandlers as $handler) { - if (!$handler->supports($message)) { - continue; - } - - $return = $handler->handle($message, $session); - $handled = true; - - $this->logger->debug(\sprintf('Message handled by "%s".', $handler::class), [ - 'method' => $message::getMethod(), - 'response' => $return, - ]); - - if (null !== $return) { - return $return; - } - } - - if ($handled) { - return null; - } - - throw new HandlerNotFoundException(\sprintf('No handler found for method "%s".', $message::getMethod())); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - private function runGarbageCollection(): void - { - if (random_int(0, 100) > 1) { - return; - } - - $deletedSessions = $this->sessionStore->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } - } - - /** - * Destroy a specific session. - */ - public function destroySession(Uuid $sessionId): void - { - $this->sessionStore->destroy($sessionId); - $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); - } -} diff --git a/src/Server/Handler/MethodHandlerInterface.php b/src/Server/Handler/MethodHandlerInterface.php deleted file mode 100644 index 0f61631b..00000000 --- a/src/Server/Handler/MethodHandlerInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -interface MethodHandlerInterface -{ - public function supports(HasMethodInterface $message): bool; - - /** - * @throws ExceptionInterface When the handler encounters an error processing the request - */ - public function handle(HasMethodInterface $message, SessionInterface $session): Response|Error|null; -} diff --git a/src/Server/Handler/Notification/InitializedHandler.php b/src/Server/Handler/Notification/InitializedHandler.php index 01881a13..08dec76a 100644 --- a/src/Server/Handler/Notification/InitializedHandler.php +++ b/src/Server/Handler/Notification/InitializedHandler.php @@ -11,27 +11,24 @@ namespace Mcp\Server\Handler\Notification; -use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; -use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\InitializedNotification; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** * @author Christopher Hertel */ -final class InitializedHandler implements MethodHandlerInterface +final class InitializedHandler implements NotificationHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Notification $notification): bool { - return $message instanceof InitializedNotification; + return $notification instanceof InitializedNotification; } - public function handle(InitializedNotification|HasMethodInterface $message, SessionInterface $session): Response|Error|null + public function handle(Notification $message, SessionInterface $session): void { - $session->set('initialized', true); + \assert($message instanceof InitializedNotification); - return null; + $session->set('initialized', true); } } diff --git a/src/Server/Handler/Notification/NotificationHandlerInterface.php b/src/Server/Handler/Notification/NotificationHandlerInterface.php new file mode 100644 index 00000000..8746dc73 --- /dev/null +++ b/src/Server/Handler/Notification/NotificationHandlerInterface.php @@ -0,0 +1,25 @@ + + */ +interface NotificationHandlerInterface +{ + public function supports(Notification $notification): bool; + + public function handle(Notification $notification, SessionInterface $session): void; +} diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index d9b36066..cb308261 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -13,24 +13,25 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\ExceptionInterface; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel * @author Tobias Nyholm */ -final class CallToolHandler implements MethodHandlerInterface +final class CallToolHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -39,53 +40,66 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CallToolRequest; + return $request instanceof CallToolRequest; } - public function handle(CallToolRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CallToolRequest); + \assert($request instanceof CallToolRequest); - $toolName = $message->name; - $arguments = $message->arguments ?? []; + $toolName = $request->name; + $arguments = $request->arguments ?? []; $this->logger->debug('Executing tool', ['name' => $toolName, 'arguments' => $arguments]); try { $reference = $this->referenceProvider->getTool($toolName); - if (null === $reference) { - throw new ToolNotFoundException($message); - } + + $arguments['_session'] = $session; $result = $this->referenceHandler->handle($reference, $arguments); - $formatted = $reference->formatResult($result); + + $structuredContent = null; + if (null !== $reference->tool->outputSchema && !$result instanceof CallToolResult) { + $structuredContent = $reference->extractStructuredContent($result); + } + + if (!$result instanceof CallToolResult) { + $result = new CallToolResult($reference->formatResult($result), false, $structuredContent); + } $this->logger->debug('Tool executed successfully', [ 'name' => $toolName, 'result_type' => \gettype($result), + 'structured_content' => $structuredContent, ]); - return new Response($message->getId(), new CallToolResult($formatted)); - } catch (ToolNotFoundException $e) { - $this->logger->error('Tool not found', ['name' => $toolName]); - - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); - } catch (ToolCallException|ExceptionInterface $e) { + 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, ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + $errorContent = [new TextContent($e->getMessage())]; + + return new Response($request->getId(), CallToolResult::error($errorContent)); + } catch (ToolNotFoundException $e) { + $this->logger->error('Tool not found', ['name' => $toolName]); + + return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); } catch (\Throwable $e) { $this->logger->error('Unhandled error during tool execution', [ 'name' => $toolName, 'exception' => $e->getMessage(), ]); - return Error::forInternalError('Error while executing tool', $message->getId()); + return Error::forInternalError('Error while executing tool', $request->getId()); } } } diff --git a/src/Server/Handler/Request/CompletionCompleteHandler.php b/src/Server/Handler/Request/CompletionCompleteHandler.php index be4d67d1..f1c1b9d6 100644 --- a/src/Server/Handler/Request/CompletionCompleteHandler.php +++ b/src/Server/Handler/Request/CompletionCompleteHandler.php @@ -13,21 +13,26 @@ use Mcp\Capability\Completion\ProviderInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CompletionCompleteResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** * Handles completion/complete requests. * + * @implements RequestHandlerInterface + * * @author Kyrian Obikwelu */ -final class CompletionCompleteHandler implements MethodHandlerInterface +final class CompletionCompleteHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -35,54 +40,54 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof CompletionCompleteRequest; + return $request instanceof CompletionCompleteRequest; } - public function handle(CompletionCompleteRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof CompletionCompleteRequest); - - $name = $message->argument['name'] ?? ''; - $value = $message->argument['value'] ?? ''; + \assert($request instanceof CompletionCompleteRequest); - $reference = match ($message->ref->type) { - 'ref/prompt' => $this->referenceProvider->getPrompt($message->ref->name), - 'ref/resource' => $this->referenceProvider->getResourceTemplate($message->ref->uri), - default => null, - }; + $name = $request->argument['name'] ?? ''; + $value = $request->argument['value'] ?? ''; - if (null === $reference) { - return new Response($message->getId(), new CompletionCompleteResult([])); - } + try { + $reference = match (true) { + $request->ref instanceof PromptReference => $this->referenceProvider->getPrompt($request->ref->name), + $request->ref instanceof ResourceReference => $this->referenceProvider->getResource($request->ref->uri), + }; - $providers = $reference->completionProviders; - $provider = $providers[$name] ?? null; - if (null === $provider) { - return new Response($message->getId(), new CompletionCompleteResult([])); - } + $providers = $reference->completionProviders; + $provider = $providers[$name] ?? null; + if (null === $provider) { + return new Response($request->getId(), new CompletionCompleteResult([])); + } - if (\is_string($provider)) { - if (!class_exists($provider)) { - return Error::forInternalError('Invalid completion provider', $message->getId()); + if (\is_string($provider)) { + if (!class_exists($provider)) { + return Error::forInternalError('Invalid completion provider', $request->getId()); + } + $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); } - $provider = $this->container?->has($provider) ? $this->container->get($provider) : new $provider(); - } - if (!$provider instanceof ProviderInterface) { - return Error::forInternalError('Invalid completion provider type', $message->getId()); - } + if (!$provider instanceof ProviderInterface) { + return Error::forInternalError('Invalid completion provider type', $request->getId()); + } - try { $completions = $provider->getCompletions($value); $total = \count($completions); $hasMore = $total > 100; $paged = \array_slice($completions, 0, 100); - return new Response($message->getId(), new CompletionCompleteResult($paged, $total, $hasMore)); - } catch (\Throwable) { - return Error::forInternalError('Error while handling completion request', $message->getId()); + 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 index 758ab9de..866275e9 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,23 +13,23 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ReferenceProviderInterface; -use Mcp\Exception\ExceptionInterface; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ -final class GetPromptHandler implements MethodHandlerInterface +final class GetPromptHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -38,39 +38,41 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof GetPromptRequest; + return $request instanceof GetPromptRequest; } - public function handle(GetPromptRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof GetPromptRequest); + \assert($request instanceof GetPromptRequest); - $promptName = $message->name; - $arguments = $message->arguments ?? []; + $promptName = $request->name; + $arguments = $request->arguments ?? []; try { $reference = $this->referenceProvider->getPrompt($promptName); - if (null === $reference) { - throw new PromptNotFoundException($message); - } + + $arguments['_session'] = $session; $result = $this->referenceHandler->handle($reference, $arguments); $formatted = $reference->formatResult($result); - return new Response($message->getId(), new GetPromptResult($formatted)); + return new Response($request->getId(), new GetPromptResult($formatted)); + } catch (PromptGetException $e) { + $this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); + + return Error::forInternalError($e->getMessage(), $request->getId()); } catch (PromptNotFoundException $e) { $this->logger->error('Prompt not found', ['prompt_name' => $promptName]); - return new Error($message->getId(), Error::METHOD_NOT_FOUND, $e->getMessage()); - } catch (PromptGetException|ExceptionInterface $e) { - $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); - - return Error::forInternalError('Error while handling prompt', $message->getId()); + return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error('Error while handling prompt', ['prompt_name' => $promptName]); + $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage())); return Error::forInternalError('Error while handling prompt', $message->getId()); } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index e9d7a751..d814d9dd 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -12,42 +12,48 @@ namespace Mcp\Server\Handler\Request; use Mcp\Schema\Implementation; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; use Mcp\Schema\ServerCapabilities; use Mcp\Server\Configuration; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ -final class InitializeHandler implements MethodHandlerInterface +final class InitializeHandler implements RequestHandlerInterface { public function __construct( public readonly ?Configuration $configuration = null, ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof InitializeRequest; + return $request instanceof InitializeRequest; } - public function handle(InitializeRequest|HasMethodInterface $message, SessionInterface $session): Response + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof InitializeRequest); + \assert($request instanceof InitializeRequest); - $session->set('client_info', $message->clientInfo->jsonSerialize()); + $session->set('client_info', $request->clientInfo->jsonSerialize()); return new Response( - $message->getId(), + $request->getId(), new InitializeResult( $this->configuration->capabilities ?? new ServerCapabilities(), $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, + null, + $this->configuration?->protocolVersion, ), ); } diff --git a/src/Server/Handler/Request/ListPromptsHandler.php b/src/Server/Handler/Request/ListPromptsHandler.php index 4a5b0556..aa75fef0 100644 --- a/src/Server/Handler/Request/ListPromptsHandler.php +++ b/src/Server/Handler/Request/ListPromptsHandler.php @@ -13,17 +13,18 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Result\ListPromptsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ -final class ListPromptsHandler implements MethodHandlerInterface +final class ListPromptsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +32,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListPromptsRequest; + return $request instanceof ListPromptsRequest; } /** + * @return Response + * * @throws InvalidCursorException */ - public function handle(ListPromptsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListPromptsRequest); + \assert($request instanceof ListPromptsRequest); - $page = $this->registry->getPrompts($this->pageSize, $message->cursor); + $page = $this->registry->getPrompts($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListPromptsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourceTemplatesHandler.php b/src/Server/Handler/Request/ListResourceTemplatesHandler.php index eadd3427..ce77b62a 100644 --- a/src/Server/Handler/Request/ListResourceTemplatesHandler.php +++ b/src/Server/Handler/Request/ListResourceTemplatesHandler.php @@ -13,17 +13,18 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourceTemplatesRequest; use Mcp\Schema\Result\ListResourceTemplatesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ -final class ListResourceTemplatesHandler implements MethodHandlerInterface +final class ListResourceTemplatesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +32,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourceTemplatesRequest; + return $request instanceof ListResourceTemplatesRequest; } /** + * @return Response + * * @throws InvalidCursorException */ - public function handle(ListResourceTemplatesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourceTemplatesRequest); + \assert($request instanceof ListResourceTemplatesRequest); - $page = $this->registry->getResourceTemplates($this->pageSize, $message->cursor); + $page = $this->registry->getResourceTemplates($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourceTemplatesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListResourcesHandler.php b/src/Server/Handler/Request/ListResourcesHandler.php index 383a5f43..4dc5ceb2 100644 --- a/src/Server/Handler/Request/ListResourcesHandler.php +++ b/src/Server/Handler/Request/ListResourcesHandler.php @@ -13,17 +13,18 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListResourcesRequest; use Mcp\Schema\Result\ListResourcesResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ -final class ListResourcesHandler implements MethodHandlerInterface +final class ListResourcesHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -31,22 +32,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListResourcesRequest; + return $request instanceof ListResourcesRequest; } /** + * @return Response + * * @throws InvalidCursorException */ - public function handle(ListResourcesRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListResourcesRequest); + \assert($request instanceof ListResourcesRequest); - $page = $this->registry->getResources($this->pageSize, $message->cursor); + $page = $this->registry->getResources($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListResourcesResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/ListToolsHandler.php b/src/Server/Handler/Request/ListToolsHandler.php index d34b9a5b..7c7f7788 100644 --- a/src/Server/Handler/Request/ListToolsHandler.php +++ b/src/Server/Handler/Request/ListToolsHandler.php @@ -13,18 +13,19 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Exception\InvalidCursorException; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Result\ListToolsResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel * @author Tobias Nyholm */ -final class ListToolsHandler implements MethodHandlerInterface +final class ListToolsHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $registry, @@ -32,22 +33,24 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ListToolsRequest; + return $request instanceof ListToolsRequest; } /** + * @return Response + * * @throws InvalidCursorException When the cursor is invalid */ - public function handle(ListToolsRequest|HasMethodInterface $message, SessionInterface $session): Response + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof ListToolsRequest); + \assert($request instanceof ListToolsRequest); - $page = $this->registry->getTools($this->pageSize, $message->cursor); + $page = $this->registry->getTools($this->pageSize, $request->cursor); return new Response( - $message->getId(), + $request->getId(), new ListToolsResult($page->references, $page->nextCursor), ); } diff --git a/src/Server/Handler/Request/PingHandler.php b/src/Server/Handler/Request/PingHandler.php index bc2cae32..507680fa 100644 --- a/src/Server/Handler/Request/PingHandler.php +++ b/src/Server/Handler/Request/PingHandler.php @@ -11,27 +11,31 @@ namespace Mcp\Server\Handler\Request; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Result\EmptyResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; /** + * @implements RequestHandlerInterface + * * @author Christopher Hertel */ -final class PingHandler implements MethodHandlerInterface +final class PingHandler implements RequestHandlerInterface { - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof PingRequest; + return $request instanceof PingRequest; } - public function handle(PingRequest|HasMethodInterface $message, SessionInterface $session): Response + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response { - \assert($message instanceof PingRequest); + \assert($request instanceof PingRequest); - return new Response($message->getId(), new EmptyResult()); + return new Response($request->getId(), new EmptyResult()); } } diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index 6691021a..c5160cd5 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -15,20 +15,22 @@ use Mcp\Capability\Registry\ReferenceProviderInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; -use Mcp\Schema\JsonRpc\HasMethodInterface; +use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; -use Mcp\Server\Handler\MethodHandlerInterface; use Mcp\Server\Session\SessionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** + * @implements RequestHandlerInterface + * * @author Tobias Nyholm */ -final class ReadResourceHandler implements MethodHandlerInterface +final class ReadResourceHandler implements RequestHandlerInterface { public function __construct( private readonly ReferenceProviderInterface $referenceProvider, @@ -37,42 +39,54 @@ public function __construct( ) { } - public function supports(HasMethodInterface $message): bool + public function supports(Request $request): bool { - return $message instanceof ReadResourceRequest; + return $request instanceof ReadResourceRequest; } - public function handle(ReadResourceRequest|HasMethodInterface $message, SessionInterface $session): Response|Error + /** + * @return Response|Error + */ + public function handle(Request $request, SessionInterface $session): Response|Error { - \assert($message instanceof ReadResourceRequest); + \assert($request instanceof ReadResourceRequest); - $uri = $message->uri; + $uri = $request->uri; $this->logger->debug('Reading resource', ['uri' => $uri]); try { $reference = $this->referenceProvider->getResource($uri); - if (null === $reference) { - throw new ResourceNotFoundException($message); - } - $result = $this->referenceHandler->handle($reference, ['uri' => $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($message->getId(), new ReadResourceResult($formatted)); + return new Response($request->getId(), new ReadResourceResult($formatted)); + } catch (ResourceReadException $e) { + $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + + return Error::forInternalError($e->getMessage(), $request->getId()); } catch (ResourceNotFoundException $e) { $this->logger->error('Resource not found', ['uri' => $uri]); - return new Error($message->getId(), Error::RESOURCE_NOT_FOUND, $e->getMessage()); + return Error::forResourceNotFound($e->getMessage(), $request->getId()); } catch (\Throwable $e) { - $this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage())); + $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage())); - return Error::forInternalError('Error while reading resource', $message->getId()); + return Error::forInternalError('Error while reading resource', $request->getId()); } } } diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php new file mode 100644 index 00000000..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/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/Session/FileSessionStore.php b/src/Server/Session/FileSessionStore.php index 217d1eb4..0a7b7cd4 100644 --- a/src/Server/Session/FileSessionStore.php +++ b/src/Server/Session/FileSessionStore.php @@ -50,9 +50,9 @@ public function exists(Uuid $id): bool return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; } - public function read(Uuid $sessionId): string|false + public function read(Uuid $id): string|false { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); if (!is_file($path)) { return false; @@ -73,9 +73,9 @@ public function read(Uuid $sessionId): string|false return $data; } - public function write(Uuid $sessionId, string $data): bool + public function write(Uuid $id, string $data): bool { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); $tmp = $path.'.tmp'; if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { @@ -98,9 +98,9 @@ public function write(Uuid $sessionId, string $data): bool return true; } - public function destroy(Uuid $sessionId): bool + public function destroy(Uuid $id): bool { - $path = $this->pathFor($sessionId); + $path = $this->pathFor($id); if (is_file($path)) { @unlink($path); diff --git a/src/Server/Session/InMemorySessionStore.php b/src/Server/Session/InMemorySessionStore.php index 4051ba76..9f8077c6 100644 --- a/src/Server/Session/InMemorySessionStore.php +++ b/src/Server/Session/InMemorySessionStore.php @@ -35,9 +35,9 @@ public function exists(Uuid $id): bool return isset($this->store[$id->toRfc4122()]); } - public function read(Uuid $sessionId): string|false + public function read(Uuid $id): string|false { - $session = $this->store[$sessionId->toRfc4122()] ?? ''; + $session = $this->store[$id->toRfc4122()] ?? ''; if ('' === $session) { return false; } @@ -45,7 +45,7 @@ public function read(Uuid $sessionId): string|false $currentTimestamp = $this->clock->now()->getTimestamp(); if ($currentTimestamp - $session['timestamp'] > $this->ttl) { - unset($this->store[$sessionId->toRfc4122()]); + unset($this->store[$id->toRfc4122()]); return false; } @@ -53,9 +53,9 @@ public function read(Uuid $sessionId): string|false return $session['data']; } - public function write(Uuid $sessionId, string $data): bool + public function write(Uuid $id, string $data): bool { - $this->store[$sessionId->toRfc4122()] = [ + $this->store[$id->toRfc4122()] = [ 'data' => $data, 'timestamp' => $this->clock->now()->getTimestamp(), ]; @@ -63,10 +63,10 @@ public function write(Uuid $sessionId, string $data): bool return true; } - public function destroy(Uuid $sessionId): bool + public function destroy(Uuid $id): bool { - if (isset($this->store[$sessionId->toRfc4122()])) { - unset($this->store[$sessionId->toRfc4122()]); + if (isset($this->store[$id->toRfc4122()])) { + unset($this->store[$id->toRfc4122()]); } return true; 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 index 6bf3c306..e02fcc7c 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -51,9 +51,9 @@ public function getStore(): SessionStoreInterface return $this->store; } - public function save(): void + public function save(): bool { - $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); + return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); } public function get(string $key, mixed $default = null): mixed diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php index 9ee5e807..8f93a6e4 100644 --- a/src/Server/Session/SessionInterface.php +++ b/src/Server/Session/SessionInterface.php @@ -26,7 +26,7 @@ public function getId(): Uuid; /** * Save the session. */ - public function save(): void; + public function save(): bool; /** * Get a specific attribute from the session. diff --git a/src/Server/Transport/BaseTransport.php b/src/Server/Transport/BaseTransport.php new file mode 100644 index 00000000..c2938b52 --- /dev/null +++ b/src/Server/Transport/BaseTransport.php @@ -0,0 +1,136 @@ + + */ +abstract class BaseTransport +{ + use ManagesTransportCallbacks; + + protected ?Uuid $sessionId = null; + + /** + * @var McpFiber|null + */ + protected ?\Fiber $sessionFiber = null; + + public function __construct( + protected readonly LoggerInterface $logger, + ) { + } + + public function initialize(): void + { + } + + public function close(): void + { + } + + public function setSessionId(?Uuid $sessionId): void + { + $this->sessionId = $sessionId; + } + + /** + * @param McpFiber $fiber + */ + public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void + { + $this->sessionFiber = $fiber; + $this->sessionId = $sessionId; + } + + /** + * @return array}> + */ + protected function getOutgoingMessages(?Uuid $sessionId): array + { + if ($sessionId && \is_callable($this->outgoingMessagesProvider)) { + return ($this->outgoingMessagesProvider)($sessionId); + } + + return []; + } + + /** + * @return array> + */ + protected function getPendingRequests(?Uuid $sessionId): array + { + if ($sessionId && \is_callable($this->pendingRequestsProvider)) { + return ($this->pendingRequestsProvider)($sessionId); + } + + return []; + } + + /** + * @phpstan-return FiberResume + */ + protected function checkForResponse(int $requestId, ?Uuid $sessionId): Response|Error|null + { + if ($sessionId && \is_callable($this->responseFinder)) { + return ($this->responseFinder)($requestId, $sessionId); + } + + return null; + } + + /** + * @param FiberSuspend|null $yielded + */ + protected function handleFiberYield(mixed $yielded, ?Uuid $sessionId): void + { + if (null === $yielded || !\is_callable($this->fiberYieldHandler)) { + return; + } + + try { + ($this->fiberYieldHandler)($yielded, $sessionId); + } catch (\Throwable $e) { + $this->logger->error('Fiber yield handler failed.', [ + 'exception' => $e, + 'sessionId' => $sessionId?->toRfc4122(), + ]); + } + } + + protected function handleMessage(string $payload, ?Uuid $sessionId): void + { + if (\is_callable($this->messageListener)) { + ($this->messageListener)($payload, $sessionId); + } + } + + protected function handleSessionEnd(?Uuid $sessionId): void + { + if ($sessionId && \is_callable($this->sessionEndListener)) { + ($this->sessionEndListener)($sessionId); + } + } +} diff --git a/src/Server/Transport/CallbackStream.php b/src/Server/Transport/CallbackStream.php new file mode 100644 index 00000000..85525232 --- /dev/null +++ b/src/Server/Transport/CallbackStream.php @@ -0,0 +1,156 @@ + + */ +final class CallbackStream implements StreamInterface +{ + private bool $called = false; + + private ?\Throwable $exception = null; + + /** + * @param callable(): void $callback The callback to execute when stream is read + */ + public function __construct(private $callback, private LoggerInterface $logger = new NullLogger()) + { + } + + public function __toString(): string + { + try { + $this->invoke(); + } catch (\Throwable $e) { + $this->exception = $e; + $this->logger->error( + \sprintf('CallbackStream execution failed: %s', $e->getMessage()), + ['exception' => $e] + ); + } + + return ''; + } + + public function read($length): string + { + $this->invoke(); + + if (null !== $this->exception) { + throw $this->exception; + } + + return ''; + } + + public function getContents(): string + { + $this->invoke(); + + if (null !== $this->exception) { + throw $this->exception; + } + + return ''; + } + + public function eof(): bool + { + return $this->called; + } + + public function close(): void + { + // No-op - callback-based stream doesn't need closing + } + + public function detach() + { + return null; + } + + public function getSize(): ?int + { + return null; // Unknown size for callback streams + } + + public function tell(): int + { + return 0; + } + + public function isSeekable(): bool + { + return false; + } + + public function seek($offset, $whence = \SEEK_SET): void + { + throw new \RuntimeException('Stream is not seekable'); + } + + public function rewind(): void + { + throw new \RuntimeException('Stream is not seekable'); + } + + public function isWritable(): bool + { + return false; + } + + public function write($string): int + { + throw new \RuntimeException('Stream is not writable'); + } + + public function isReadable(): bool + { + return !$this->called; + } + + private function invoke(): void + { + if ($this->called) { + return; + } + + $this->called = true; + $this->exception = null; + ($this->callback)(); + } + + public function getMetadata($key = null) + { + return null === $key ? [] : null; + } +} diff --git a/src/Server/Transport/InMemoryTransport.php b/src/Server/Transport/InMemoryTransport.php index 2da8d215..a9ffca97 100644 --- a/src/Server/Transport/InMemoryTransport.php +++ b/src/Server/Transport/InMemoryTransport.php @@ -14,17 +14,13 @@ use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Tobias Nyholm */ -class InMemoryTransport implements TransportInterface +class InMemoryTransport extends BaseTransport implements TransportInterface { - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionDestroyListener; - - private ?Uuid $sessionId = null; + use ManagesTransportCallbacks; /** * @param list $messages @@ -50,30 +46,30 @@ public function send(string $data, array $context): void } } + /** + * @return null + */ public function listen(): mixed { foreach ($this->messages as $message) { - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $message, $this->sessionId); - } + $this->handleMessage($message, $this->sessionId); } - if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { - \call_user_func($this->sessionDestroyListener, $this->sessionId); - } + $this->handleSessionEnd($this->sessionId); + + $this->sessionId = null; return null; } - public function onSessionEnd(callable $listener): void + public function setSessionId(?Uuid $sessionId): void { - $this->sessionDestroyListener = $listener; + $this->sessionId = $sessionId; } public function close(): void { - if (\is_callable($this->sessionDestroyListener) && null !== $this->sessionId) { - \call_user_func($this->sessionDestroyListener, $this->sessionId); - } + $this->handleSessionEnd($this->sessionId); + $this->sessionId = null; } } diff --git a/src/Server/Transport/ManagesTransportCallbacks.php b/src/Server/Transport/ManagesTransportCallbacks.php new file mode 100644 index 00000000..a0d1aa6b --- /dev/null +++ b/src/Server/Transport/ManagesTransportCallbacks.php @@ -0,0 +1,82 @@ + + * */ +trait ManagesTransportCallbacks +{ + /** @var callable(string, ?Uuid): void */ + protected $messageListener; + + /** @var callable(Uuid): void */ + protected $sessionEndListener; + + /** @var callable(Uuid): array}> */ + protected $outgoingMessagesProvider; + + /** @var callable(Uuid): array> */ + protected $pendingRequestsProvider; + + /** @var callable(int, Uuid): Response>|Error|null */ + protected $responseFinder; + + /** @var callable(FiberSuspend|null, ?Uuid): void */ + protected $fiberYieldHandler; + + public function onMessage(callable $listener): void + { + $this->messageListener = $listener; + } + + public function onSessionEnd(callable $listener): void + { + $this->sessionEndListener = $listener; + } + + public function setOutgoingMessagesProvider(callable $provider): void + { + $this->outgoingMessagesProvider = $provider; + } + + public function setPendingRequestsProvider(callable $provider): void + { + $this->pendingRequestsProvider = $provider; + } + + /** + * @param callable(int, Uuid):(Response>|Error|null) $finder + */ + public function setResponseFinder(callable $finder): void + { + $this->responseFinder = $finder; + } + + /** + * @param callable(FiberSuspend|null, ?Uuid): void $handler + */ + public function setFiberYieldHandler(callable $handler): void + { + $this->fiberYieldHandler = $handler; + } +} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 6b4a337a..5f0afbfb 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -11,23 +11,17 @@ namespace Mcp\Server\Transport; +use Mcp\Schema\JsonRpc\Error; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Kyrian Obikwelu - */ -class StdioTransport implements TransportInterface + * */ +class StdioTransport extends BaseTransport implements TransportInterface { - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionEndListener; - - private ?Uuid $sessionId = null; - /** * @param resource $input * @param resource $output @@ -35,73 +29,137 @@ class StdioTransport implements TransportInterface public function __construct( private $input = \STDIN, private $output = \STDOUT, - private readonly LoggerInterface $logger = new NullLogger(), + LoggerInterface $logger = new NullLogger(), ) { + parent::__construct($logger); } - public function initialize(): void + public function send(string $data, array $context): void { + if (isset($context['session_id'])) { + $this->sessionId = $context['session_id']; + } + + $this->writeLine($data); } - public function onMessage(callable $listener): void + public function listen(): int { - $this->messageListener = $listener; + $this->logger->info('StdioTransport is listening for messages on STDIN...'); + stream_set_blocking($this->input, false); + + while (!feof($this->input)) { + $this->processInput(); + $this->processFiber(); + $this->flushOutgoingMessages(); + } + + $this->logger->info('StdioTransport finished listening.'); + $this->handleSessionEnd($this->sessionId); + + return 0; } - public function send(string $data, array $context): void + protected function processInput(): void { - $this->logger->debug('Sending data to client via StdioTransport.', ['data' => $data]); + $line = fgets($this->input); + if (false === $line) { + usleep(50000); // 50ms - if (isset($context['session_id'])) { - $this->sessionId = $context['session_id']; + return; } - fwrite($this->output, $data.\PHP_EOL); + $trimmedLine = trim($line); + if (!empty($trimmedLine)) { + $this->handleMessage($trimmedLine, $this->sessionId); + } } - public function listen(): mixed + private function processFiber(): void { - $this->logger->info('StdioTransport is listening for messages on STDIN...'); + if (null === $this->sessionFiber) { + return; + } - while (!feof($this->input)) { - $line = fgets($this->input); - if (false === $line) { - break; - } + if ($this->sessionFiber->isTerminated()) { + $this->handleFiberTermination(); - $trimmedLine = trim($line); - if (!empty($trimmedLine)) { - $this->logger->debug('Received message on StdioTransport.', ['line' => $trimmedLine]); - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $trimmedLine, $this->sessionId); - } - } + return; } - $this->logger->info('StdioTransport finished listening.'); + if (!$this->sessionFiber->isSuspended()) { + return; + } + + $pendingRequests = $this->getPendingRequests($this->sessionId); - if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { - \call_user_func($this->sessionEndListener, $this->sessionId); + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); + + return; } - return null; + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout'] ?? 120; + + $response = $this->checkForResponse($requestId, $this->sessionId); + + if (null !== $response) { + $yielded = $this->sessionFiber->resume($response); + $this->handleFiberYield($yielded, $this->sessionId); + + return; + } + + if (time() - $timestamp >= $timeout) { + $error = Error::forInternalError('Request timed out', $requestId); + $yielded = $this->sessionFiber->resume($error); + $this->handleFiberYield($yielded, $this->sessionId); + + return; + } + } } - public function onSessionEnd(callable $listener): void + private function handleFiberTermination(): void { - $this->sessionEndListener = $listener; + $finalResult = $this->sessionFiber->getReturn(); + + if (null !== $finalResult) { + try { + $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); + $this->writeLine($encoded); + } catch (\JsonException $e) { + $this->logger->error('STDIO: Failed to encode final Fiber result.', ['exception' => $e]); + } + } + + $this->sessionFiber = null; } - public function close(): void + private function flushOutgoingMessages(): void { - if (\is_callable($this->sessionEndListener) && null !== $this->sessionId) { - \call_user_func($this->sessionEndListener, $this->sessionId); + $messages = $this->getOutgoingMessages($this->sessionId); + + foreach ($messages as $message) { + $this->writeLine($message['message']); } + } + private function writeLine(string $payload): void + { + fwrite($this->output, $payload.\PHP_EOL); + } + + public function close(): void + { + $this->handleSessionEnd($this->sessionId); if (\is_resource($this->input)) { fclose($this->input); } - if (\is_resource($this->output)) { fclose($this->output); } diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 7f5035fe..297cad17 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -11,6 +11,7 @@ namespace Mcp\Server\Transport; +use Http\Discovery\Psr17FactoryDiscovery; use Mcp\Schema\JsonRpc\Error; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -21,38 +22,43 @@ use Symfony\Component\Uid\Uuid; /** + * @implements TransportInterface + * * @author Kyrian Obikwelu - */ -class StreamableHttpTransport implements TransportInterface + * */ +class StreamableHttpTransport extends BaseTransport implements TransportInterface { - /** @var callable(string, ?Uuid): void */ - private $messageListener; - - /** @var callable(Uuid): void */ - private $sessionEndListener; - - private ?Uuid $sessionId = null; + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; - /** @var string[] */ - private array $outgoingMessages = []; - private ?Uuid $outgoingSessionId = null; - private ?int $outgoingStatusCode = null; + private ?string $immediateResponse = null; + private ?int $immediateStatusCode = null; /** @var array */ - private array $corsHeaders = [ - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization, Accept', - ]; + private array $corsHeaders; + /** + * @param array $corsHeaders + */ public function __construct( private readonly ServerRequestInterface $request, - private readonly ResponseFactoryInterface $responseFactory, - private readonly StreamFactoryInterface $streamFactory, - private readonly LoggerInterface $logger = new NullLogger(), + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $corsHeaders = [], + LoggerInterface $logger = new NullLogger(), ) { + 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 initialize(): void @@ -61,44 +67,20 @@ public function initialize(): void public function send(string $data, array $context): void { - $this->outgoingMessages[] = $data; - - if (isset($context['session_id'])) { - $this->outgoingSessionId = $context['session_id']; - } - - if (isset($context['status_code']) && \is_int($context['status_code'])) { - $this->outgoingStatusCode = $context['status_code']; - } - - $this->logger->debug('Sending data to client via StreamableHttpTransport.', [ - 'data' => $data, - 'session_id' => $this->outgoingSessionId?->toRfc4122(), - 'status_code' => $this->outgoingStatusCode, - ]); + $this->immediateResponse = $data; + $this->immediateStatusCode = $context['status_code'] ?? 200; } - public function listen(): mixed + public function listen(): ResponseInterface { return match ($this->request->getMethod()) { 'OPTIONS' => $this->handleOptionsRequest(), - 'GET' => $this->handleGetRequest(), 'POST' => $this->handlePostRequest(), 'DELETE' => $this->handleDeleteRequest(), - default => $this->handleUnsupportedRequest(), + default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405), }; } - public function onMessage(callable $listener): void - { - $this->messageListener = $listener; - } - - public function onSessionEnd(callable $listener): void - { - $this->sessionEndListener = $listener; - } - protected function handleOptionsRequest(): ResponseInterface { return $this->withCorsHeaders($this->responseFactory->createResponse(204)); @@ -106,89 +88,163 @@ protected function handleOptionsRequest(): ResponseInterface protected function handlePostRequest(): ResponseInterface { - $acceptHeader = $this->request->getHeaderLine('Accept'); - if (!str_contains($acceptHeader, 'application/json') || !str_contains($acceptHeader, 'text/event-stream')) { - $error = Error::forInvalidRequest('Not Acceptable: Client must accept both application/json and text/event-stream.'); - $this->logger->warning('Client does not accept required content types.', ['accept' => $acceptHeader]); + $body = $this->request->getBody()->getContents(); + $this->handleMessage($body, $this->sessionId); - return $this->createErrorResponse($error, 406); + if (null !== $this->immediateResponse) { + $response = $this->responseFactory->createResponse($this->immediateStatusCode ?? 200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($this->immediateResponse)); + + return $this->withCorsHeaders($response); } - if (!str_contains($this->request->getHeaderLine('Content-Type'), 'application/json')) { - $error = Error::forInvalidRequest('Unsupported Media Type: Content-Type must be application/json.'); - $this->logger->warning('Client sent unsupported content type.', ['content_type' => $this->request->getHeaderLine('Content-Type')]); + if (null !== $this->sessionFiber) { + $this->logger->info('Fiber suspended, handling via SSE.'); - return $this->createErrorResponse($error, 415); + return $this->createStreamedResponse(); } - $body = $this->request->getBody()->getContents(); - if (empty($body)) { - $error = Error::forInvalidRequest('Bad Request: Empty request body.'); - $this->logger->warning('Client sent empty request body.'); + return $this->createJsonResponse(); + } - return $this->createErrorResponse($error, 400); + protected function handleDeleteRequest(): ResponseInterface + { + if (!$this->sessionId) { + return $this->createErrorResponse(Error::forInvalidRequest('Mcp-Session-Id header is required.'), 400); } - $this->logger->debug('Received message on StreamableHttpTransport.', [ - 'body' => $body, - 'session_id' => $this->sessionId?->toRfc4122(), - ]); + $this->handleSessionEnd($this->sessionId); - if (\is_callable($this->messageListener)) { - \call_user_func($this->messageListener, $body, $this->sessionId); - } + return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + } - if (empty($this->outgoingMessages)) { + protected function createJsonResponse(): ResponseInterface + { + $outgoingMessages = $this->getOutgoingMessages($this->sessionId); + + if (empty($outgoingMessages)) { return $this->withCorsHeaders($this->responseFactory->createResponse(202)); } - $responseBody = 1 === \count($this->outgoingMessages) - ? $this->outgoingMessages[0] - : '['.implode(',', $this->outgoingMessages).']'; - - $status = $this->outgoingStatusCode ?? 200; + $messages = array_column($outgoingMessages, 'message'); + $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; - $response = $this->responseFactory->createResponse($status) + $response = $this->responseFactory->createResponse(200) ->withHeader('Content-Type', 'application/json') ->withBody($this->streamFactory->createStream($responseBody)); - if ($this->outgoingSessionId) { - $response = $response->withHeader('Mcp-Session-Id', $this->outgoingSessionId->toRfc4122()); + if ($this->sessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); } return $this->withCorsHeaders($response); } - protected function handleGetRequest(): ResponseInterface + protected function createStreamedResponse(): ResponseInterface { - $response = $this->createErrorResponse(Error::forInvalidRequest('Not Yet Implemented'), 405); + $callback = function (): void { + try { + $this->logger->info('SSE: Starting request processing loop'); + + while ($this->sessionFiber->isSuspended()) { + $this->flushOutgoingMessages($this->sessionId); + + $pendingRequests = $this->getPendingRequests($this->sessionId); + + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); + continue; + } + + $resumed = false; + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout'] ?? 120; + + $response = $this->checkForResponse($requestId, $this->sessionId); + + if (null !== $response) { + $yielded = $this->sessionFiber->resume($response); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + + if (time() - $timestamp >= $timeout) { + $error = Error::forInternalError('Request timed out', $requestId); + $yielded = $this->sessionFiber->resume($error); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + } + + if (!$resumed) { + usleep(100000); + } // Prevent tight loop + } + + $this->handleFiberTermination(); + } finally { + $this->sessionFiber = null; + } + }; + + $stream = new CallbackStream($callback, $this->logger); + $response = $this->responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody($stream); + + if ($this->sessionId) { + $response = $response->withHeader('Mcp-Session-Id', $this->sessionId->toRfc4122()); + } return $this->withCorsHeaders($response); } - protected function handleDeleteRequest(): ResponseInterface + private function handleFiberTermination(): void { - if (!$this->sessionId) { - $error = Error::forInvalidRequest('Bad Request: Mcp-Session-Id header is required for DELETE requests.'); - $this->logger->warning('DELETE request received without session ID.'); - - return $this->createErrorResponse($error, 400); - } - - if (\is_callable($this->sessionEndListener)) { - \call_user_func($this->sessionEndListener, $this->sessionId); + $finalResult = $this->sessionFiber->getReturn(); + + if (null !== $finalResult) { + try { + $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); + echo "event: message\n"; + echo "data: {$encoded}\n\n"; + @ob_flush(); + flush(); + } catch (\JsonException $e) { + $this->logger->error('SSE: Failed to encode final Fiber result.', ['exception' => $e]); + } } - return $this->withCorsHeaders($this->responseFactory->createResponse(204)); + $this->sessionFiber = null; } - protected function handleUnsupportedRequest(): ResponseInterface + private function flushOutgoingMessages(?Uuid $sessionId): void { - $this->logger->warning('Unsupported HTTP method received.', [ - 'method' => $this->request->getMethod(), - ]); + $messages = $this->getOutgoingMessages($sessionId); - $response = $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405); + 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); } @@ -201,17 +257,4 @@ protected function withCorsHeaders(ResponseInterface $response): ResponseInterfa return $response; } - - protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface - { - $errorPayload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); - - return $this->responseFactory->createResponse($statusCode) - ->withHeader('Content-Type', 'application/json') - ->withBody($this->streamFactory->createStream($errorPayload)); - } - - public function close(): void - { - } } diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index a8040a0f..5a874a76 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -11,9 +11,21 @@ namespace Mcp\Server\Transport; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Symfony\Component\Uid\Uuid; /** + * @template-covariant TResult + * + * @phpstan-type FiberReturn (Response|Error) + * @phpstan-type FiberResume (FiberReturn|null) + * @phpstan-type FiberSuspend ( + * array{type: 'notification', notification: \Mcp\Schema\JsonRpc\Notification}| + * array{type: 'request', request: \Mcp\Schema\JsonRpc\Request, timeout?: int} + * ) + * @phpstan-type McpFiber \Fiber + * * @author Christopher Hertel * @author Kyrian Obikwelu */ @@ -24,13 +36,6 @@ interface TransportInterface */ public function initialize(): void; - /** - * Registers a callback that will be invoked whenever the transport receives an incoming message. - * - * @param callable(string $message, ?Uuid $sessionId): void $listener The callback function to execute when the message occurs - */ - public function onMessage(callable $listener): void; - /** * Starts the transport's execution process. * @@ -38,31 +43,90 @@ public function onMessage(callable $listener): void; * - For a single-request transport like HTTP, this will process the request * and return a result (e.g., a PSR-7 Response) to be sent to the client. * - * @return mixed the result of the transport's execution, if any + * @return TResult the result of the transport's execution, if any */ public function listen(): mixed; /** - * Sends a raw JSON-RPC message string back to the client. + * 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 string $data The JSON-RPC message string to send - * @param array $context The context of the message + * @param array $context Context about this message: + * - 'session_id': Uuid|null + * - 'type': 'response'|'request'|'notification' + * - 'status_code': int (HTTP status code for errors) */ public function send(string $data, array $context): void; /** - * Registers a callback that will be invoked when a session needs to be destroyed. - * This can happen when a client disconnects or explicitly ends their session. + * 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; /** - * Closes the transport and cleans up any resources. + * Set a provider function to retrieve all queued outgoing messages. + * + * The transport calls this to retrieve all queued messages for a session. * - * This method should be called when the transport is no longer needed. - * It should clean up any resources and close any connections. + * @param callable(Uuid $sessionId): array}> $provider */ - public function close(): void; + public function setOutgoingMessagesProvider(callable $provider): void; + + /** + * Set a provider function to retrieve all pending server-initiated requests. + * + * The transport calls this to decide if it should wait for a client response before resuming a Fiber. + * + * @param callable(Uuid $sessionId): array> $provider + */ + public function setPendingRequestsProvider(callable $provider): void; + + /** + * Set a finder function to check for a specific client response. + * + * @param callable(int, Uuid):FiberResume $finder + */ + public function setResponseFinder(callable $finder): void; + + /** + * Set a handler for processing values yielded from a suspended Fiber. + * + * The transport calls this to let the Protocol handle new requests/notifications + * that are yielded from a Fiber's execution. + * + * @param callable(FiberSuspend|null, ?Uuid $sessionId): void $handler + */ + public function setFiberYieldHandler(callable $handler): void; + + /** + * @param McpFiber $fiber + */ + public function attachFiberToSession(\Fiber $fiber, Uuid $sessionId): void; + + /** + * Set the session ID for the current transport context. + * + * @param Uuid|null $sessionId The session ID, or null to clear + */ + public function setSessionId(?Uuid $sessionId): void; } diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php new file mode 100644 index 00000000..ba287fd0 --- /dev/null +++ b/tests/Inspector/Http/HttpClientCommunicationTest.php @@ -0,0 +1,64 @@ +markTestSkipped('Test skipped: SDK cannot handle logging/setLevel requests required by logging capability, and built-in PHP server does not support sampling.'); + } + + public static function provideMethods(): array + { + return [ + ...parent::provideMethods(), + 'Prepare Project Briefing (Simple)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'prepare_project_briefing', + 'toolArgs' => [ + 'projectName' => 'Website Redesign', + 'milestones' => ['Discovery', 'Design', 'Development', 'Testing'], + ], + ], + 'testName' => 'prepare_project_briefing_simple', + ], + 'Prepare Project Briefing (Complex)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'prepare_project_briefing', + 'toolArgs' => [ + 'projectName' => 'Mobile App Launch', + 'milestones' => ['Market Research', 'UI/UX Design', 'MVP Development', 'Beta Testing', 'Marketing Campaign', 'Public Launch'], + ], + ], + 'testName' => 'prepare_project_briefing_complex', + ], + 'Run Service Maintenance' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'run_service_maintenance', + 'toolArgs' => [ + 'serviceName' => 'Payment Gateway API', + ], + ], + 'testName' => 'run_service_maintenance', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/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/Inspector/snapshots/StdioCalculatorExampleTest-prompts_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json similarity index 95% rename from tests/Inspector/snapshots/StdioCalculatorExampleTest-prompts_list.json rename to tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json index 911451f2..7292222c 100644 --- a/tests/Inspector/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/snapshots/StdioCalculatorExampleTest-resources_templates_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_templates_list.json rename to tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json new file mode 100644 index 00000000..d849f400 --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "System status: OK (discovered)" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json new file mode 100644 index 00000000..4d8cf0da --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Hello HTTP Test User, from manual registration!" + } + ], + "isError": false +} diff --git a/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json new file mode 100644 index 00000000..04d8ea1c --- /dev/null +++ b/tests/Inspector/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json @@ -0,0 +1,28 @@ +{ + "tools": [ + { + "name": "manualGreeter", + "description": "A manually registered tool.", + "inputSchema": { + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "the user to greet" + } + }, + "required": [ + "user" + ] + } + }, + { + "name": "discovered_status_check", + "description": "A tool discovered via attributes.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/tests/Inspector/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 index 71a065a6..b28a9c1b 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -11,53 +11,114 @@ namespace Mcp\Tests\Inspector; +use Mcp\Schema\Enum\LoggingLevel; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.16.8'; + private const INSPECTOR_VERSION = '0.17.2'; + /** @param array $options */ #[DataProvider('provideMethods')] - public function testResourcesListOutputMatchesSnapshot(string $method): void - { - $process = (new Process([ + public function testOutputMatchesSnapshot( + string $method, + array $options = [], + ?string $testName = null, + ): void { + $inspector = \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION); + + $args = [ 'npx', - \sprintf('@modelcontextprotocol/inspector@%s', self::INSPECTOR_VERSION), + $inspector, '--cli', - 'php', - $this->getServerScript(), + ...$this->getServerConnectionArgs(), + '--transport', + $this->getTransport(), '--method', $method, - ]))->mustRun(); + ]; + + // 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); + } + } + } - $output = $process->getOutput(); - $snapshotFile = $this->getSnapshotFilePath($method); + // 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, $output.\PHP_EOL); + file_put_contents($snapshotFile, $normalizedOutput.\PHP_EOL); $this->markTestIncomplete("Snapshot created at $snapshotFile, please re-run tests."); } $expected = file_get_contents($snapshotFile); - $this->assertJsonStringEqualsJsonString($expected, $output); + $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput); } - /** - * List of methods to test. - * - * @return array - */ - abstract public static function provideMethods(): array; - - abstract protected function getServerScript(): string; + protected function normalizeTestOutput(string $output, ?string $testName = null): string + { + return $output; + } - /** - * @return array - */ - protected static function provideListMethods(): array + /** @return array> */ + public static function provideMethods(): array { return [ 'Prompt Listing' => ['method' => 'prompts/list'], @@ -67,10 +128,10 @@ protected static function provideListMethods(): array ]; } - private function getSnapshotFilePath(string $method): string - { - $className = substr(static::class, strrpos(static::class, '\\') + 1); + abstract protected function getSnapshotFilePath(string $method, ?string $testName = null): string; - return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).'.json'; - } + /** @return array */ + abstract protected function getServerConnectionArgs(): array; + + abstract protected function getTransport(): string; } diff --git a/tests/Inspector/ManualStdioExampleTest.php b/tests/Inspector/ManualStdioExampleTest.php deleted file mode 100644 index 582de0dc..00000000 --- a/tests/Inspector/ManualStdioExampleTest.php +++ /dev/null @@ -1,29 +0,0 @@ - [ + '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..2e04b001 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "8" + } + ], + "isError": false, + "structuredContent": { + "result": 8 + } +} 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..0bf67735 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "-3" + } + ], + "isError": false, + "structuredContent": { + "result": -3 + } +} 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..9c494626 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "5" + } + ], + "isError": false, + "structuredContent": { + "result": 5 + } +} 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..8008d493 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "3.5" + } + ], + "isError": false, + "structuredContent": { + "result": 3.5 + } +} 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..8b9291b9 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "24" + } + ], + "isError": false, + "structuredContent": { + "result": 24 + } +} 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..d288fbb7 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "0" + } + ], + "isError": false, + "structuredContent": { + "result": 0 + } +} 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..5fd7af5a --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "256" + } + ], + "isError": false, + "structuredContent": { + "result": 256 + } +} 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..6d58bb70 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "1" + } + ], + "isError": false, + "structuredContent": { + "result": 1 + } +} 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..fd15afae --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json @@ -0,0 +1,120 @@ +{ + "tools": [ + { + "name": "add_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "integer" + } + }, + "required": [ + "result" + ] + } + }, + { + "name": "multiply_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "integer" + } + }, + "required": [ + "result" + ] + } + }, + { + "name": "divide_numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "number" + } + }, + "required": [ + "result" + ] + } + }, + { + "name": "power", + "inputSchema": { + "type": "object", + "properties": { + "base": { + "type": "integer" + }, + "exponent": { + "type": "integer" + } + }, + "required": [ + "base", + "exponent" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "integer" + } + }, + "required": [ + "result" + ] + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCalculatorExampleTest-resources_read.json b/tests/Inspector/Stdio/snapshots/StdioCalculatorExampleTest-resources_read.json new file mode 100644 index 00000000..c15d9a8e --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCalculatorExampleTest-resources_read.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "config://calculator/settings", + "mimeType": "application/json", + "text": "{\n \"precision\": 2,\n \"allow_negative\": true\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioCalculatorExampleTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioCalculatorExampleTest-tools_call.json new file mode 100644 index 00000000..e69de29b 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..baff3cac --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json @@ -0,0 +1,16 @@ +{ + "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, + "structuredContent": { + "id": 4, + "userId": "alice", + "description": "Complete the project documentation", + "completed": false, + "createdAt": "2025-01-01T00:00:00+00:00" + } +} 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..2263660e --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json @@ -0,0 +1,13 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Task 1 completed.\"\n}" + } + ], + "isError": false, + "structuredContent": { + "success": true, + "message": "Task 1 completed." + } +} 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..f8c8fa11 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "[]" + } + ], + "isError": false, + "structuredContent": { + "result": [] + } +} 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..efa50407 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json @@ -0,0 +1,80 @@ +{ + "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" + ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true, + "description": "the created task details" + } + }, + { + "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" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": [] + } + }, + "required": [ + "result" + ], + "description": "a list of tasks" + } + }, + { + "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" + ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true, + "description": "status of the operation" + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json similarity index 99% rename from tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json index e6402959..a40f3489 100644 --- a/tests/Inspector/snapshots/StdioCalculatorExampleTest-resources_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json @@ -8,4 +8,3 @@ } ] } - 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..bdfec0c0 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "19.8" + } + ], + "isError": false, + "structuredContent": { + "result": 19.8 + } +} 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..7cda55e0 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json @@ -0,0 +1,13 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Precision updated to 3.\"\n}" + } + ], + "isError": false, + "structuredContent": { + "success": true, + "message": "Precision updated to 3." + } +} diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json new file mode 100644 index 00000000..a73c8b94 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "19.8" + } + ], + "isError": false +} diff --git a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json similarity index 74% rename from tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json index 2812c849..965b37cf 100644 --- a/tests/Inspector/snapshots/StdioCalculatorExampleTest-tools_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json @@ -24,6 +24,18 @@ "b", "operation" ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "number" + } + }, + "required": [ + "result" + ], + "description": "the result of the calculation" } }, { @@ -44,8 +56,12 @@ "setting", "value" ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true, + "description": "success message or error" } } ] } - 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..b046832e --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json @@ -0,0 +1,14 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"mode\": \"debug\",\n \"processed_input\": \"DEBUG TEST\",\n \"message\": \"Processed in DEBUG mode.\"\n}" + } + ], + "isError": false, + "structuredContent": { + "mode": "debug", + "processed_input": "DEBUG TEST", + "message": "Processed in DEBUG mode." + } +} 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..af00a82b --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json @@ -0,0 +1,14 @@ +{ + "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, + "structuredContent": { + "mode": "default", + "original_input": "test data", + "message": "Processed in default mode (APP_MODE not recognized or not set)." + } +} 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..4f30f8a0 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json @@ -0,0 +1,14 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"mode\": \"production\",\n \"processed_input_length\": 15,\n \"message\": \"Processed in PRODUCTION mode (summary only).\"\n}" + } + ], + "isError": false, + "structuredContent": { + "mode": "production", + "processed_input_length": 15, + "message": "Processed in PRODUCTION mode (summary only)." + } +} 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..a44bd918 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json @@ -0,0 +1,49 @@ +{ + "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" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "The processing mode used" + }, + "processed_input": { + "type": "string", + "description": "The processed input data (only in debug mode)" + }, + "processed_input_length": { + "type": "integer", + "description": "The length of the processed input (only in production mode)" + }, + "original_input": { + "type": "string", + "description": "The original input data (only in default mode)" + }, + "message": { + "type": "string", + "description": "A descriptive message about the processing" + } + }, + "required": [ + "mode", + "message" + ] + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json new file mode 100644 index 00000000..b5777fa4 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json @@ -0,0 +1,11 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Craft a personalized greeting for Alice." + } + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json new file mode 100644 index 00000000..e432a6f4 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json @@ -0,0 +1,11 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Craft a personalized greeting for Bob." + } + } + ] +} diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json similarity index 100% rename from tests/Inspector/snapshots/ManualStdioExampleTest-prompts_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json similarity index 100% rename from tests/Inspector/snapshots/ManualStdioExampleTest-resources_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json new file mode 100644 index 00000000..1547bdaa --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "app://version", + "mimeType": "text/plain", + "text": "1.0-manual" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json new file mode 100644 index 00000000..d3eca519 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "item://123/details", + "mimeType": "application/json", + "text": "{\n \"id\": \"123\",\n \"name\": \"Item 123\",\n \"description\": \"Details for item 123 from manual template.\"\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json new file mode 100644 index 00000000..6a2dd65f --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "item://ABC/details", + "mimeType": "application/json", + "text": "{\n \"id\": \"ABC\",\n \"name\": \"Item ABC\",\n \"description\": \"Details for item ABC from manual template.\"\n}" + } + ] +} diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json similarity index 100% rename from tests/Inspector/snapshots/ManualStdioExampleTest-resources_templates_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json diff --git a/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json new file mode 100644 index 00000000..f193e6d1 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "Echo: Hello World!" + } + ], + "isError": false, + "structuredContent": { + "result": "Echo: Hello World!" + } +} 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..5136263b --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "Echo: Test with emoji 🎉 and symbols @#$%" + } + ], + "isError": false, + "structuredContent": { + "result": "Echo: Test with emoji 🎉 and symbols @#$%" + } +} diff --git a/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json similarity index 60% rename from tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json rename to tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json index a5ecc534..cadd0633 100644 --- a/tests/Inspector/snapshots/ManualStdioExampleTest-tools_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json @@ -14,6 +14,18 @@ "required": [ "text" ] + }, + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + }, + "required": [ + "result" + ], + "description": "the echoed text" } } ] diff --git a/tests/Inspector/StdioCalculatorExampleTest.php b/tests/Inspector/StdioCalculatorExampleTest.php index b1b1bc5e..e69de29b 100644 --- a/tests/Inspector/StdioCalculatorExampleTest.php +++ b/tests/Inspector/StdioCalculatorExampleTest.php @@ -1,27 +0,0 @@ -assertNull($attribute->name); $this->assertNull($attribute->description); + $this->assertNull($attribute->outputSchema); } public function testInstantiatesWithMissingOptionalArguments(): void @@ -48,5 +49,31 @@ public function testInstantiatesWithMissingOptionalArguments(): void // Assert $this->assertNull($attribute->name); $this->assertNull($attribute->description); + $this->assertNull($attribute->outputSchema); + } + + public function testInstantiatesWithOutputSchema(): void + { + // Arrange + $name = 'test-tool-name'; + $description = 'This is a test description.'; + $outputSchema = [ + 'type' => 'object', + 'properties' => [ + 'result' => [ + 'type' => 'string', + 'description' => 'The result of the operation', + ], + ], + 'required' => ['result'], + ]; + + // Act + $attribute = new McpTool(name: $name, description: $description, outputSchema: $outputSchema); + + // Assert + $this->assertSame($name, $attribute->name); + $this->assertSame($description, $attribute->description); + $this->assertSame($outputSchema, $attribute->outputSchema); } } diff --git a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php index b7b9dc31..75ffd88c 100644 --- a/tests/Unit/Capability/Discovery/CachedDiscovererTest.php +++ b/tests/Unit/Capability/Discovery/CachedDiscovererTest.php @@ -14,7 +14,6 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscoveryState; -use Mcp\Capability\Registry; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; @@ -23,8 +22,7 @@ class CachedDiscovererTest extends TestCase { public function testCachedDiscovererUsesCacheOnSecondCall(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); $cache->expects($this->once()) @@ -47,8 +45,7 @@ public function testCachedDiscovererUsesCacheOnSecondCall(): void public function testCachedDiscovererReturnsCachedResults(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); $cachedState = new DiscoveryState(); @@ -71,8 +68,7 @@ public function testCachedDiscovererReturnsCachedResults(): void public function testCacheKeyGeneration(): void { - $registry = new Registry(null, new NullLogger()); - $discoverer = new Discoverer($registry, new NullLogger()); + $discoverer = new Discoverer(); $cache = $this->createMock(CacheInterface::class); diff --git a/tests/Unit/Capability/Discovery/DiscoveryTest.php b/tests/Unit/Capability/Discovery/DiscoveryTest.php index 0767ff12..2208a915 100644 --- a/tests/Unit/Capability/Discovery/DiscoveryTest.php +++ b/tests/Unit/Capability/Discovery/DiscoveryTest.php @@ -14,10 +14,6 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Completion\ListCompletionProvider; use Mcp\Capability\Discovery\Discoverer; -use Mcp\Capability\Registry; -use Mcp\Capability\Registry\PromptReference; -use Mcp\Capability\Registry\ResourceReference; -use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture; use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler; @@ -29,155 +25,139 @@ class DiscoveryTest extends TestCase { - private Registry $registry; private Discoverer $discoverer; protected function setUp(): void { - $this->registry = new Registry(); - $this->discoverer = new Discoverer($this->registry); + $this->discoverer = new Discoverer(); } public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $tools = $this->registry->getTools(); + $tools = $discovery->getTools(); $this->assertCount(4, $tools); - $greetUserTool = $this->registry->getTool('greet_user'); - $this->assertInstanceOf(ToolReference::class, $greetUserTool); - $this->assertFalse($greetUserTool->isManual); - $this->assertEquals('greet_user', $greetUserTool->tool->name); - $this->assertEquals('Greets a user by name.', $greetUserTool->tool->description); - $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $greetUserTool->handler); - $this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []); - - $repeatActionTool = $this->registry->getTool('repeatAction'); - $this->assertInstanceOf(ToolReference::class, $repeatActionTool); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); - $this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint); - $this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? [])); - - $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); - $this->assertInstanceOf(ToolReference::class, $invokableCalcTool); - $this->assertFalse($invokableCalcTool->isManual); - $this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler); - - $this->assertNull($this->registry->getTool('private_tool_should_be_ignored')); - $this->assertNull($this->registry->getTool('protected_tool_should_be_ignored')); - $this->assertNull($this->registry->getTool('static_tool_should_be_ignored')); - - $resources = $this->registry->getResources(); + $this->assertArrayHasKey('greet_user', $tools); + $this->assertFalse($tools['greet_user']->isManual); + $this->assertEquals('greet_user', $tools['greet_user']->tool->name); + $this->assertEquals('Greets a user by name.', $tools['greet_user']->tool->description); + $this->assertEquals([DiscoverableToolHandler::class, 'greet'], $tools['greet_user']->handler); + $this->assertArrayHasKey('name', $tools['greet_user']->tool->inputSchema['properties'] ?? []); + + $this->assertArrayHasKey('repeatAction', $tools); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); + $this->assertTrue($tools['repeatAction']->tool->annotations->readOnlyHint); + $this->assertEquals(['count', 'loudly', 'mode'], array_keys($tools['repeatAction']->tool->inputSchema['properties'] ?? [])); + + $this->assertArrayHasKey('InvokableCalculator', $tools); + $this->assertInstanceOf(ToolReference::class, $tools['InvokableCalculator']); + $this->assertFalse($tools['InvokableCalculator']->isManual); + $this->assertEquals([InvocableToolFixture::class, '__invoke'], $tools['InvokableCalculator']->handler); + + $this->assertArrayNotHasKey('private_tool_should_be_ignored', $tools); + $this->assertArrayNotHasKey('protected_tool_should_be_ignored', $tools); + $this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools); + + $resources = $discovery->getResources(); $this->assertCount(3, $resources); - $appVersionRes = $this->registry->getResource('app://info/version'); - $this->assertInstanceOf(ResourceReference::class, $appVersionRes); - $this->assertFalse($appVersionRes->isManual); - $this->assertEquals('app_version', $appVersionRes->schema->name); - $this->assertEquals('text/plain', $appVersionRes->schema->mimeType); + $this->assertArrayHasKey('app://info/version', $resources); + $this->assertFalse($resources['app://info/version']->isManual); + $this->assertEquals('app_version', $resources['app://info/version']->schema->name); + $this->assertEquals('text/plain', $resources['app://info/version']->schema->mimeType); - $invokableStatusRes = $this->registry->getResource('invokable://config/status'); - $this->assertInstanceOf(ResourceReference::class, $invokableStatusRes); - $this->assertFalse($invokableStatusRes->isManual); - $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler); + $this->assertArrayHasKey('invokable://config/status', $resources); + $this->assertFalse($resources['invokable://config/status']->isManual); + $this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler); - $prompts = $this->registry->getPrompts(); + $prompts = $discovery->getPrompts(); $this->assertCount(4, $prompts); - $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); - $this->assertInstanceOf(PromptReference::class, $storyPrompt); - $this->assertFalse($storyPrompt->isManual); - $this->assertCount(2, $storyPrompt->prompt->arguments); - $this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']); + $this->assertArrayHasKey('creative_story_prompt', $prompts); + $this->assertFalse($prompts['creative_story_prompt']->isManual); + $this->assertCount(2, $prompts['creative_story_prompt']->prompt->arguments); + $this->assertEquals(CompletionProviderFixture::class, $prompts['creative_story_prompt']->completionProviders['genre']); - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertInstanceOf(PromptReference::class, $simplePrompt); - $this->assertFalse($simplePrompt->isManual); + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts); + $this->assertFalse($prompts['simpleQuestionPrompt']->isManual); - $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); - $this->assertInstanceOf(PromptReference::class, $invokableGreeter); - $this->assertFalse($invokableGreeter->isManual); - $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler); + $this->assertArrayHasKey('InvokableGreeterPrompt', $prompts); + $this->assertFalse($prompts['InvokableGreeterPrompt']->isManual); + $this->assertEquals([InvocablePromptFixture::class, '__invoke'], $prompts['InvokableGreeterPrompt']->handler); - $contentCreatorPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentCreatorPrompt); - $this->assertFalse($contentCreatorPrompt->isManual); - $this->assertCount(3, $contentCreatorPrompt->completionProviders); + $this->assertArrayHasKey('content_creator', $prompts); + $this->assertFalse($prompts['content_creator']->isManual); + $this->assertCount(3, $prompts['content_creator']->completionProviders); - $templates = $this->registry->getResourceTemplates(); + $templates = $discovery->getResourceTemplates(); $this->assertCount(4, $templates); - $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $productTemplate); - $this->assertFalse($productTemplate->isManual); - $this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name); - $this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']); - $this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames()); - - $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $invokableUserTemplate); - $this->assertFalse($invokableUserTemplate->isManual); - $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler); + $this->assertArrayHasKey('product://{region}/details/{productId}', $templates); + $this->assertFalse($templates['product://{region}/details/{productId}']->isManual); + $this->assertEquals('product_details_template', $templates['product://{region}/details/{productId}']->resourceTemplate->name); + $this->assertEquals(CompletionProviderFixture::class, $templates['product://{region}/details/{productId}']->completionProviders['region']); + $this->assertEqualsCanonicalizing(['region', 'productId'], $templates['product://{region}/details/{productId}']->getVariableNames()); + + $this->assertArrayHasKey('invokable://user-profile/{userId}', $templates); + $this->assertFalse($templates['invokable://user-profile/{userId}']->isManual); + $this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $templates['invokable://user-profile/{userId}']->handler); } public function testDoesNotDiscoverElementsFromExcludedDirectories() { - $this->discoverer->discover(__DIR__, ['Fixtures']); - $this->assertInstanceOf(ToolReference::class, $this->registry->getTool('hidden_subdir_tool')); - - $this->registry->clear(); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); + $this->assertArrayHasKey('hidden_subdir_tool', $discovery->getTools()); - $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); - $this->assertNull($this->registry->getTool('hidden_subdir_tool')); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']); + $this->assertArrayNotHasKey('hidden_subdir_tool', $discovery->getTools()); } public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles() { - $this->discoverer->discover(__DIR__, ['EmptyDir']); - $tools = $this->registry->getTools(); - $this->assertEmpty($tools->references); + $discovery = $this->discoverer->discover(__DIR__, ['EmptyDir']); + + $this->assertTrue($discovery->isEmpty()); } public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $repeatActionTool = $this->registry->getTool('repeatAction'); - $this->assertEquals('repeatAction', $repeatActionTool->tool->name); - $this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description); + $this->assertArrayHasKey('repeatAction', $tools = $discovery->getTools()); + $this->assertEquals('repeatAction', $tools['repeatAction']->tool->name); + $this->assertEquals('A tool with more complex parameters and inferred name/description.', $tools['repeatAction']->tool->description); - $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); - $this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name); - $this->assertNull($simplePrompt->prompt->description); + $this->assertArrayHasKey('simpleQuestionPrompt', $prompts = $discovery->getPrompts()); + $this->assertEquals('simpleQuestionPrompt', $prompts['simpleQuestionPrompt']->prompt->name); + $this->assertNull($prompts['simpleQuestionPrompt']->prompt->description); - $invokableCalc = $this->registry->getTool('InvokableCalculator'); - $this->assertEquals('InvokableCalculator', $invokableCalc->tool->name); - $this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description); + $this->assertArrayHasKey('InvokableCalculator', $tools); + $this->assertEquals('InvokableCalculator', $tools['InvokableCalculator']->tool->name); + $this->assertEquals('An invokable calculator tool.', $tools['InvokableCalculator']->tool->description); } public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes() { - $this->discoverer->discover(__DIR__, ['Fixtures']); + $discovery = $this->discoverer->discover(__DIR__, ['Fixtures']); - $contentPrompt = $this->registry->getPrompt('content_creator'); - $this->assertInstanceOf(PromptReference::class, $contentPrompt); - $this->assertCount(3, $contentPrompt->completionProviders); + $this->assertArrayHasKey('content_creator', $prompts = $discovery->getPrompts()); + $this->assertCount(3, $prompts['content_creator']->completionProviders); - $typeProvider = $contentPrompt->completionProviders['type']; + $typeProvider = $prompts['content_creator']->completionProviders['type']; $this->assertInstanceOf(ListCompletionProvider::class, $typeProvider); - $statusProvider = $contentPrompt->completionProviders['status']; + $statusProvider = $prompts['content_creator']->completionProviders['status']; $this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider); - $priorityProvider = $contentPrompt->completionProviders['priority']; + $priorityProvider = $prompts['content_creator']->completionProviders['priority']; $this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider); - $contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}'); - $this->assertInstanceOf(ResourceTemplateReference::class, $contentTemplate); - $this->assertCount(1, $contentTemplate->completionProviders); + $this->assertArrayHasKey('content://{category}/{slug}', $templates = $discovery->getResourceTemplates()); + $this->assertCount(1, $templates['content://{category}/{slug}']->completionProviders); - $categoryProvider = $contentTemplate->completionProviders['category']; + $categoryProvider = $templates['content://{category}/{slug}']->completionProviders['category']; $this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider); } } diff --git a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php index 0f015ce4..a218ad63 100644 --- a/tests/Unit/Capability/Discovery/DocBlockTestFixture.php +++ b/tests/Unit/Capability/Discovery/DocBlockTestFixture.php @@ -75,7 +75,7 @@ public function methodWithReturn(): string * @deprecated use newMethod() instead * @see DocBlockTestFixture::newMethod() */ - public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */ + public function methodWithMultipleTags(float $value): bool { return true; } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 5a7fcaeb..125d0189 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -412,4 +412,53 @@ public function parameterSchemaInferredType( $inferredParam, ): void { } + + // ===== OUTPUT SCHEMA FIXTURES ===== + public function stringReturn(): string + { + return 'test'; + } + + public function integerReturn(): int + { + return 42; + } + + public function floatReturn(): float + { + return 3.14; + } + + public function booleanReturn(): bool + { + return true; + } + + public function nullableReturn(): ?string + { + return null; + } + + public function objectReturn(): \stdClass + { + return new \stdClass(); + } + + public function docBlockReturnType(): string + { + return '42'; + } + + public function unionReturn(): float|string + { + return 'test'; + } + + /** + * @return string The result of the operation + */ + public function returnWithDescription(): string + { + return 'result'; + } } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4cbfce52..5ff7e3c6 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -24,7 +24,7 @@ protected function setUp(): void $this->schemaGenerator = new SchemaGenerator(new DocBlockParser()); } - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters() + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); $schema = $this->schemaGenerator->generate($method); @@ -35,7 +35,7 @@ public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters() $this->assertArrayNotHasKey('required', $schema); } - public function testInfersBasicTypesFromPhpTypeHints() + public function testInfersBasicTypesFromPhpTypeHints(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); $schema = $this->schemaGenerator->generate($method); @@ -47,7 +47,7 @@ public function testInfersBasicTypesFromPhpTypeHints() $this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']); } - public function testInfersTypesAndDescriptionsFromDocBlockTags() + public function testInfersTypesAndDescriptionsFromDocBlockTags(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); $schema = $this->schemaGenerator->generate($method); @@ -58,7 +58,7 @@ public function testInfersTypesAndDescriptionsFromDocBlockTags() $this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']); } - public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions() + public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); $schema = $this->schemaGenerator->generate($method); @@ -68,7 +68,7 @@ public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions() $this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']); } - public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute() + public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); $schema = $this->schemaGenerator->generate($method); @@ -90,7 +90,7 @@ public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute() ], $schema); } - public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties() + public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); $schema = $this->schemaGenerator->generate($method); @@ -102,7 +102,7 @@ public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties( $this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']); } - public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute() + public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); $schema = $this->schemaGenerator->generate($method); @@ -122,7 +122,7 @@ public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAt $this->assertEquals(['profiles'], $schema['required']); } - public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes() + public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); $schema = $this->schemaGenerator->generate($method); @@ -141,7 +141,7 @@ public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes( $this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']); } - public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -151,7 +151,7 @@ public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes() $this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']); } - public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -161,7 +161,7 @@ public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes( $this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']); } - public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes() + public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -170,7 +170,7 @@ public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes() $this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']); } - public function testMergesMethodLevelAndParameterLevelSchemaAttributes() + public function testMergesMethodLevelAndParameterLevelSchemaAttributes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); $schema = $this->schemaGenerator->generate($method); @@ -179,7 +179,7 @@ public function testMergesMethodLevelAndParameterLevelSchemaAttributes() $this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']); } - public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints() + public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); $schema = $this->schemaGenerator->generate($method); @@ -188,7 +188,7 @@ public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSch $this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']); } - public function testGeneratesCorrectSchemaForEnumParameters() + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); $schema = $this->schemaGenerator->generate($method); @@ -200,7 +200,7 @@ public function testGeneratesCorrectSchemaForEnumParameters() $this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']); } - public function testGeneratesCorrectSchemaForArrayTypeDeclarations() + public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); $schema = $this->schemaGenerator->generate($method); @@ -216,7 +216,7 @@ public function testGeneratesCorrectSchemaForArrayTypeDeclarations() $this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']); } - public function testHandlesNullableTypeHintsAndOptionalParameters() + public function testHandlesNullableTypeHintsAndOptionalParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); $schema = $this->schemaGenerator->generate($method); @@ -228,7 +228,7 @@ public function testHandlesNullableTypeHintsAndOptionalParameters() $this->assertEqualsCanonicalizing(['nullableString'], $schema['required']); } - public function testGeneratesSchemaForPhpUnionTypes() + public function testGeneratesSchemaForPhpUnionTypes(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); $schema = $this->schemaGenerator->generate($method); @@ -237,7 +237,7 @@ public function testGeneratesSchemaForPhpUnionTypes() $this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']); } - public function testRepresentsVariadicStringParametersAsArrayOfStrings() + public function testRepresentsVariadicStringParametersAsArrayOfStrings(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); $schema = $this->schemaGenerator->generate($method); @@ -245,7 +245,7 @@ public function testRepresentsVariadicStringParametersAsArrayOfStrings() $this->assertArrayNotHasKey('required', $schema); } - public function testAppliesItemConstraintsToVariadicParameters() + public function testAppliesItemConstraintsToVariadicParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); $schema = $this->schemaGenerator->generate($method); @@ -253,7 +253,7 @@ public function testAppliesItemConstraintsToVariadicParameters() $this->assertArrayNotHasKey('required', $schema); } - public function testHandlesMixedTypeHintsOmittingExplicitType() + public function testHandlesMixedTypeHintsOmittingExplicitType(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); $schema = $this->schemaGenerator->generate($method); @@ -262,7 +262,7 @@ public function testHandlesMixedTypeHintsOmittingExplicitType() $this->assertEqualsCanonicalizing(['anyValue'], $schema['required']); } - public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures() + public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); $schema = $this->schemaGenerator->generate($method); @@ -301,7 +301,7 @@ public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures() $this->assertEquals(['order'], $schema['required']); } - public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint() + public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); $schema = $this->schemaGenerator->generate($method); @@ -311,7 +311,7 @@ public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTy $this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']); } - public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema() + public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); $schema = $this->schemaGenerator->generate($method); @@ -320,11 +320,109 @@ public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenW $this->assertArrayNotHasKey('required', $schema); } - public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven() + public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); $schema = $this->schemaGenerator->generate($method); $this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']); $this->assertEquals(['inferredParam'], $schema['required']); } + + public function testGenerateOutputSchemaReturnsNullForVoidReturnType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertNull($schema); + } + + /** + * @dataProvider providesAllOutputSchemaResultReturnTypes + */ + public function testGenerateOutputSchemaForBasicReturnTypes(string $methodName, string $expectedType): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, $methodName); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => $expectedType], + ], + 'required' => ['result'], + ], $schema); + } + + public function testGenerateOutputSchemaWithReturnDescription(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnWithDescription'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => 'string'], + ], + 'required' => ['result'], + 'description' => 'The result of the operation', + ], $schema); + } + + public function testGenerateOutputSchemaForArrayReturnType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'additionalProperties' => true, + ], $schema); + } + + public function testGenerateOutputSchemaForUnionReturnType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionReturn'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => ['string', 'number']], + ], + 'required' => ['result'], + ], $schema); + } + + public function testGenerateOutputSchemaUsesPhpTypeHintOverDocBlock(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockReturnType'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'properties' => [ + 'result' => ['type' => 'string'], + ], + 'required' => ['result'], + ], $schema); + } + + public function testGenerateOutputSchemaForComplexNestedSchema(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); + $schema = $this->schemaGenerator->generateOutputSchema($method); + $this->assertEquals([ + 'type' => 'object', + 'additionalProperties' => true, + ], $schema); + } + + /** + * @return array + */ + public static function providesAllOutputSchemaResultReturnTypes(): array + { + return [ + 'string' => ['stringReturn', 'string'], + 'integer' => ['integerReturn', 'integer'], + 'float' => ['floatReturn', 'number'], + 'boolean' => ['booleanReturn', 'boolean'], + 'nullable string' => ['nullableReturn', 'string'], + 'object' => ['objectReturn', 'object'], + ]; + } } diff --git a/tests/Unit/Capability/Registry/RegistryProviderTest.php b/tests/Unit/Capability/Registry/RegistryProviderTest.php index b1eaa857..cebf474c 100644 --- a/tests/Unit/Capability/Registry/RegistryProviderTest.php +++ b/tests/Unit/Capability/Registry/RegistryProviderTest.php @@ -16,6 +16,9 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -45,10 +48,12 @@ public function testGetToolReturnsRegisteredTool(): void $this->assertFalse($toolRef->isManual); } - public function testGetToolReturnsNullForUnregisteredTool(): void + public function testGetToolThrowsExceptionForUnregisteredTool(): void { - $toolRef = $this->registry->getTool('non_existent_tool'); - $this->assertNull($toolRef); + $this->expectException(ToolNotFoundException::class); + $this->expectExceptionMessage('Tool not found: "non_existent_tool".'); + + $this->registry->getTool('non_existent_tool'); } public function testGetResourceReturnsRegisteredResource(): void @@ -65,10 +70,12 @@ public function testGetResourceReturnsRegisteredResource(): void $this->assertFalse($resourceRef->isManual); } - public function testGetResourceReturnsNullForUnregisteredResource(): void + public function testGetResourceThrowsExceptionForUnregisteredResource(): void { - $resourceRef = $this->registry->getResource('test://non_existent'); - $this->assertNull($resourceRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://non_existent".'); + + $this->registry->getResource('test://non_existent'); } public function testGetResourceMatchesResourceTemplate(): void @@ -84,15 +91,17 @@ public function testGetResourceMatchesResourceTemplate(): void $this->assertEquals($handler, $resourceRef->handler); } - public function testGetResourceWithIncludeTemplatesFalse(): void + public function testGetResourceWithIncludeTemplatesFalseThrowsException(): void { $template = $this->createValidResourceTemplate('test://{id}'); $handler = fn (string $id) => "content for {$id}"; $this->registry->registerResourceTemplate($template, $handler); - $resourceRef = $this->registry->getResource('test://123', false); - $this->assertNull($resourceRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://123".'); + + $this->registry->getResource('test://123', false); } public function testGetResourcePrefersDirectResourceOverTemplate(): void @@ -125,10 +134,12 @@ public function testGetResourceTemplateReturnsRegisteredTemplate(): void $this->assertFalse($templateRef->isManual); } - public function testGetResourceTemplateReturnsNullForUnregisteredTemplate(): void + public function testGetResourceTemplateThrowsExceptionForUnregisteredTemplate(): void { - $templateRef = $this->registry->getResourceTemplate('test://{non_existent}'); - $this->assertNull($templateRef); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Resource not found for uri: "test://{non_existent}".'); + + $this->registry->getResourceTemplate('test://{non_existent}'); } public function testGetPromptReturnsRegisteredPrompt(): void @@ -145,10 +156,12 @@ public function testGetPromptReturnsRegisteredPrompt(): void $this->assertFalse($promptRef->isManual); } - public function testGetPromptReturnsNullForUnregisteredPrompt(): void + public function testGetPromptThrowsExceptionForUnregisteredPrompt(): void { - $promptRef = $this->registry->getPrompt('non_existent_prompt'); - $this->assertNull($promptRef); + $this->expectException(PromptNotFoundException::class); + $this->expectExceptionMessage('Prompt not found: "non_existent_prompt".'); + + $this->registry->getPrompt('non_existent_prompt'); } public function testGetToolsReturnsAllRegisteredTools(): void diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index e18f9d1f..33cb967e 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -13,6 +13,9 @@ use Mcp\Capability\Completion\EnumCompletionProvider; use Mcp\Capability\Registry; +use Mcp\Exception\PromptNotFoundException; +use Mcp\Exception\ResourceNotFoundException; +use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Prompt; use Mcp\Schema\Resource; use Mcp\Schema\ResourceTemplate; @@ -80,6 +83,42 @@ public function testGetCapabilitiesWhenPopulated(): void $this->assertFalse($capabilities->logging); } + public function testSetCustomCapabilities(): void + { + $serverCapabilities = new ServerCapabilities( + tools: false, + toolsListChanged: true, + resources: false, + resourcesSubscribe: false, + resourcesListChanged: false, + prompts: false, + promptsListChanged: false, + logging: true, + completions: true, + ); + $tool = $this->createValidTool('test_tool'); + $resource = $this->createValidResource('test://resource'); + $prompt = $this->createValidPrompt('test_prompt'); + $template = $this->createValidResourceTemplate('test://{id}'); + + $this->registry->registerTool($tool, fn () => 'result'); + $this->registry->registerResource($resource, fn () => 'content'); + $this->registry->registerPrompt($prompt, fn () => []); + $this->registry->registerResourceTemplate($template, fn () => 'template'); + + $this->registry->setServerCapabilities($serverCapabilities); + + $capabilities = $this->registry->getCapabilities(); + + $this->assertFalse($capabilities->tools); + $this->assertFalse($capabilities->resources); + $this->assertFalse($capabilities->prompts); + $this->assertTrue($capabilities->completions); + $this->assertFalse($capabilities->resourcesSubscribe); + $this->assertTrue($capabilities->logging); + $this->assertTrue($capabilities->toolsListChanged); + } + public function testRegisterToolWithManualFlag(): void { $tool = $this->createValidTool('test_tool'); @@ -228,23 +267,36 @@ public function testClearRemovesOnlyDiscoveredElements(): void $this->registry->registerResourceTemplate($manualTemplate, fn () => 'manual', [], true); $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); - $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Removed 4 discovered elements from internal registry.'); + // Test that all elements exist + $this->registry->getTool('manual_tool'); + $this->registry->getResource('test://manual'); + $this->registry->getPrompt('manual_prompt'); + $this->registry->getResourceTemplate('manual://{id}'); + $this->registry->getTool('discovered_tool'); + $this->registry->getResource('test://discovered'); + $this->registry->getPrompt('discovered_prompt'); + $this->registry->getResourceTemplate('discovered://{id}'); $this->registry->clear(); - $this->assertNotNull($this->registry->getTool('manual_tool')); - $this->assertNull($this->registry->getTool('discovered_tool')); - $this->assertNotNull($this->registry->getResource('test://manual')); - $this->assertNull( - $this->registry->getResource('test://discovered', false), - ); // Don't include templates to avoid debug log - $this->assertNotNull($this->registry->getPrompt('manual_prompt')); - $this->assertNull($this->registry->getPrompt('discovered_prompt')); - $this->assertNotNull($this->registry->getResourceTemplate('manual://{id}')); - $this->assertNull($this->registry->getResourceTemplate('discovered://{id}')); + // Manual elements should still exist + $this->registry->getTool('manual_tool'); + $this->registry->getResource('test://manual'); + $this->registry->getPrompt('manual_prompt'); + $this->registry->getResourceTemplate('manual://{id}'); + + // Test that all discovered elements throw exceptions + $this->expectException(ToolNotFoundException::class); + $this->registry->getTool('discovered_tool'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResource('test://discovered'); + + $this->expectException(PromptNotFoundException::class); + $this->registry->getPrompt('discovered_prompt'); + + $this->expectException(ResourceNotFoundException::class); + $this->registry->getResourceTemplate('discovered://{id}'); } public function testClearLogsNothingWhenNoDiscoveredElements(): void @@ -258,7 +310,7 @@ public function testClearLogsNothingWhenNoDiscoveredElements(): void $this->registry->clear(); - $this->assertNotNull($this->registry->getTool('manual_tool')); + $this->registry->getTool('manual_tool'); } public function testRegisterToolHandlesStringHandler(): void diff --git a/tests/Unit/JsonRpc/HandlerTest.php b/tests/Unit/JsonRpc/HandlerTest.php deleted file mode 100644 index be9820ed..00000000 --- a/tests/Unit/JsonRpc/HandlerTest.php +++ /dev/null @@ -1,118 +0,0 @@ -getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle'); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->once())->method('handle'); - - $sessionFactory = $this->createMock(SessionFactoryInterface::class); - $sessionStore = $this->createMock(SessionStoreInterface::class); - $session = $this->createMock(SessionInterface::class); - - $sessionFactory->method('create')->willReturn($session); - $sessionFactory->method('createWithId')->willReturn($session); - $sessionStore->method('exists')->willReturn(true); - - $jsonRpc = new JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "method": "notifications/initialized"}', - $sessionId - ); - iterator_to_array($result); - } - - #[TestDox('Make sure a single request can NOT be handled by multiple handlers.')] - public function testHandleMultipleRequests() - { - $handlerA = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerA->method('supports')->willReturn(true); - $handlerA->expects($this->once())->method('handle')->willReturn(new Response(1, ['result' => 'success'])); - - $handlerB = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerB->method('supports')->willReturn(false); - $handlerB->expects($this->never())->method('handle'); - - $handlerC = $this->getMockBuilder(MethodHandlerInterface::class) - ->disableOriginalConstructor() - ->onlyMethods(['supports', 'handle']) - ->getMock(); - $handlerC->method('supports')->willReturn(true); - $handlerC->expects($this->never())->method('handle'); - - $sessionFactory = $this->createMock(SessionFactoryInterface::class); - $sessionStore = $this->createMock(SessionStoreInterface::class); - $session = $this->createMock(SessionInterface::class); - - $sessionFactory->method('create')->willReturn($session); - $sessionFactory->method('createWithId')->willReturn($session); - $sessionStore->method('exists')->willReturn(true); - - $jsonRpc = new JsonRpcHandler( - methodHandlers: [$handlerA, $handlerB, $handlerC], - messageFactory: MessageFactory::make(), - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, - ); - $sessionId = Uuid::v4(); - $result = $jsonRpc->process( - '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}', - $sessionId - ); - iterator_to_array($result); - } -} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 12d2b233..d38aabeb 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -13,9 +13,12 @@ use Mcp\Exception\InvalidInputMessageException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\CancelledNotification; use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Request\PingRequest; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -28,68 +31,373 @@ protected function setUp(): void CancelledNotification::class, InitializedNotification::class, GetPromptRequest::class, + PingRequest::class, ]); } - public function testCreateRequest() + public function testCreateRequestWithIntegerId(): void { $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var GetPromptRequest $result */ + $result = $results[0]; $this->assertInstanceOf(GetPromptRequest::class, $result); $this->assertSame('prompts/get', $result::getMethod()); $this->assertSame('create_story', $result->name); $this->assertSame(123, $result->getId()); } - public function testCreateNotification() + public function testCreateRequestWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "method": "ping", "id": "abc-123"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var PingRequest $result */ + $result = $results[0]; + $this->assertInstanceOf(PingRequest::class, $result); + $this->assertSame('ping', $result::getMethod()); + $this->assertSame('abc-123', $result->getId()); + } + + public function testCreateNotification(): void { $json = '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 12345}}'; - $result = $this->first($this->factory->create($json)); + $results = $this->factory->create($json); + $this->assertCount(1, $results); + /** @var CancelledNotification $result */ + $result = $results[0]; $this->assertInstanceOf(CancelledNotification::class, $result); $this->assertSame('notifications/cancelled', $result::getMethod()); $this->assertSame(12345, $result->requestId); } - public function testInvalidJson() + public function testCreateNotificationWithoutParams(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var InitializedNotification $result */ + $result = $results[0]; + $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertSame('notifications/initialized', $result::getMethod()); + } + + public function testCreateResponseWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 456, "result": {"content": [{"type": "text", "text": "Hello"}]}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response> $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(456, $result->getId()); + $this->assertIsArray($result->result); + $this->assertArrayHasKey('content', $result->result); + } + + public function testCreateResponseWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "response-1", "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Response> $result */ + $result = $results[0]; + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('response-1', $result->getId()); + $this->assertEquals(['status' => 'ok'], $result->result); + } + + public function testCreateErrorWithIntegerId(): void + { + $json = '{"jsonrpc": "2.0", "id": 789, "error": {"code": -32601, "message": "Method not found"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame(789, $result->getId()); + $this->assertSame(-32601, $result->code); + $this->assertSame('Method not found', $result->message); + $this->assertNull($result->data); + } + + public function testCreateErrorWithStringId(): void + { + $json = '{"jsonrpc": "2.0", "id": "err-1", "error": {"code": -32600, "message": "Invalid request"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertSame('err-1', $result->getId()); + $this->assertSame(-32600, $result->code); + $this->assertSame('Invalid request', $result->message); + } + + public function testCreateErrorWithData(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "Server error", "data": {"details": "Something went wrong"}}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + /** @var Error $result */ + $result = $results[0]; + $this->assertInstanceOf(Error::class, $result); + $this->assertEquals(['details' => 'Something went wrong'], $result->data); + } + + public function testBatchRequests(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "test"}, "id": 2}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(3, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(GetPromptRequest::class, $results[1]); + $this->assertInstanceOf(InitializedNotification::class, $results[2]); + } + + public function testBatchWithMixedMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "id": 2, "result": {"status": "ok"}}, + {"jsonrpc": "2.0", "id": 3, "error": {"code": -32600, "message": "Invalid"}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(Response::class, $results[1]); + $this->assertInstanceOf(Error::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testInvalidJson(): void { $this->expectException(\JsonException::class); - $this->first($this->factory->create('invalid json')); + $this->factory->create('invalid json'); } - public function testMissingMethod() + public function testMissingJsonRpcVersion(): void { - $result = $this->first($this->factory->create('{"jsonrpc": "2.0", "params": {}, "id": 1}')); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $json = '{"method": "ping", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); } - public function testBatchMissingMethod() + public function testInvalidJsonRpcVersion(): void { - $results = $this->factory->create('[{"jsonrpc": "2.0", "params": {}, "id": 1}, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}]'); + $json = '{"jsonrpc": "1.0", "method": "ping", "id": 1}'; - $results = iterator_to_array($results); - $result = array_shift($results); - $this->assertInstanceOf(InvalidInputMessageException::class, $result); - $this->assertEquals('Invalid JSON-RPC request, missing valid "method".', $result->getMessage()); + $results = $this->factory->create($json); - $result = array_shift($results); - $this->assertInstanceOf(InitializedNotification::class, $result); + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('jsonrpc', $results[0]->getMessage()); + } + + public function testMissingAllIdentifyingFields(): void + { + $json = '{"jsonrpc": "2.0", "params": {}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('missing', $results[0]->getMessage()); + } + + public function testUnknownMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testUnknownNotificationMethod(): void + { + $json = '{"jsonrpc": "2.0", "method": "notifications/unknown"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('Unknown method', $results[0]->getMessage()); + } + + public function testNotificationMethodUsedAsRequest(): void + { + // When a notification method is used with an id, it should still create the notification + // The fromArray validation will handle any issues + $json = '{"jsonrpc": "2.0", "method": "notifications/initialized", "id": 1}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + // The notification class will reject the id in fromArray validation + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + public function testErrorMissingId(): void + { + $json = '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorMissingCode(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorMissingMessage(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); + } + + public function testBatchWithErrors(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "params": {}, "id": 2}, + {"jsonrpc": "2.0", "method": "unknown/method", "id": 3}, + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(4, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[2]); + $this->assertInstanceOf(InitializedNotification::class, $results[3]); + } + + public function testMakeFactoryWithDefaultMessages(): void + { + $factory = MessageFactory::make(); + $json = '{"jsonrpc": "2.0", "method": "ping", "id": 1}'; + + $results = $factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + } + + public function testResponseWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": true, "result": {"status": "ok"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testErrorWithInvalidIdType(): void + { + $json = '{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('id', $results[0]->getMessage()); + } + + public function testResponseWithNonArrayResult(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "result": "not an array"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('result', $results[0]->getMessage()); } - /** - * @param iterable $items - */ - private function first(iterable $items): mixed + public function testErrorWithNonArrayErrorField(): void { - foreach ($items as $item) { - return $item; - } + $json = '{"jsonrpc": "2.0", "id": 1, "error": "not an object"}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('error', $results[0]->getMessage()); + } + + public function testErrorWithInvalidCodeType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": "not-a-number", "message": "Invalid"}}'; + + $results = $this->factory->create($json); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('code', $results[0]->getMessage()); + } + + public function testErrorWithInvalidMessageType(): void + { + $json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32600, "message": 123}}'; + + $results = $this->factory->create($json); - return null; + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('message', $results[0]->getMessage()); } } diff --git a/tests/Unit/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/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/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 4423cbd6..009bd1e7 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Tool; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Session\SessionInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -30,10 +31,10 @@ class CallToolHandlerTest extends TestCase { private CallToolHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private LoggerInterface|MockObject $logger; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private LoggerInterface&MockObject $logger; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -59,7 +60,13 @@ public function testSupportsCallToolRequest(): void public function testHandleSuccessfulToolCall(): void { $request = $this->createCallToolRequest('greet_user', ['name' => 'John']); - $toolReference = $this->createMock(ToolReference::class); + $tool = new Tool('greet_user', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = $this->getMockBuilder(ToolReference::class) + ->setConstructorArgs([$tool, function () { + return 'Hello, John!'; + }]) + ->onlyMethods(['formatResult']) + ->getMock(); $expectedResult = new CallToolResult([new TextContent('Hello, John!')]); $this->referenceProvider @@ -71,7 +78,7 @@ public function testHandleSuccessfulToolCall(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['name' => 'John']) + ->with($toolReference, ['name' => 'John', '_session' => $this->session]) ->willReturn('Hello, John!'); $toolReference @@ -80,9 +87,7 @@ public function testHandleSuccessfulToolCall(): void ->with('Hello, John!') ->willReturn([new TextContent('Hello, John!')]); - $this->logger - ->expects($this->never()) - ->method('error'); + // Logger may be called for debugging, so we don't assert never() $response = $this->handler->handle($request, $this->session); @@ -94,7 +99,10 @@ public function testHandleSuccessfulToolCall(): void public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); - $toolReference = $this->createMock(ToolReference::class); + $tool = new Tool('simple_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = new ToolReference($tool, function () { + return 'Simple result'; + }); $expectedResult = new CallToolResult([new TextContent('Simple result')]); $this->referenceProvider @@ -106,15 +114,9 @@ public function testHandleToolCallWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->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); @@ -131,7 +133,10 @@ public function testHandleToolCallWithComplexArguments(): void 'null_param' => null, ]; $request = $this->createCallToolRequest('complex_tool', $arguments); - $toolReference = $this->createMock(ToolReference::class); + $tool = new Tool('complex_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = new ToolReference($tool, function () { + return 'Complex result'; + }); $expectedResult = new CallToolResult([new TextContent('Complex result')]); $this->referenceProvider @@ -143,15 +148,9 @@ public function testHandleToolCallWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, $arguments) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn('Complex result'); - $toolReference - ->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); @@ -166,7 +165,7 @@ public function testHandleToolNotFoundExceptionReturnsError(): void ->expects($this->once()) ->method('getTool') ->with('nonexistent_tool') - ->willThrowException(new ToolNotFoundException($request)); + ->willThrowException(new ToolNotFoundException('nonexistent_tool')); $this->logger ->expects($this->once()) @@ -179,10 +178,10 @@ public function testHandleToolNotFoundExceptionReturnsError(): void $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); } - public function testHandleToolExecutionExceptionReturnsError(): void + public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void { $request = $this->createCallToolRequest('failing_tool', ['param' => 'value']); - $exception = new ToolCallException($request, new \RuntimeException('Tool execution failed')); + $exception = new ToolCallException('Tool execution failed'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -194,7 +193,7 @@ public function testHandleToolExecutionExceptionReturnsError(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['param' => 'value']) + ->with($toolReference, ['param' => 'value', '_session' => $this->session]) ->willThrowException($exception); $this->logger @@ -203,17 +202,26 @@ public function testHandleToolExecutionExceptionReturnsError(): void $response = $this->handler->handle($request, $this->session); - $this->assertInstanceOf(Error::class, $response); + $this->assertInstanceOf(Response::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + + $result = $response->result; + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertTrue($result->isError); + $this->assertCount(1, $result->content); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertEquals('Tool execution failed', $result->content[0]->text); } public function testHandleWithNullResult(): void { $request = $this->createCallToolRequest('null_tool', []); - $expectedResult = new CallToolResult([]); + $tool = new Tool('null_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = new ToolReference($tool, function () { + return null; + }); + $expectedResult = new CallToolResult([new TextContent('(null)')]); - $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider ->expects($this->once()) ->method('getTool') @@ -223,15 +231,9 @@ public function testHandleWithNullResult(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->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); @@ -248,7 +250,7 @@ public function testConstructorWithDefaultLogger(): void public function testHandleLogsErrorWithCorrectParameters(): void { $request = $this->createCallToolRequest('test_tool', ['key1' => 'value1', 'key2' => 42]); - $exception = new ToolCallException($request, new \RuntimeException('Custom error message')); + $exception = new ToolCallException('Custom error message'); $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider @@ -260,29 +262,74 @@ public function testHandleLogsErrorWithCorrectParameters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, ['key1' => 'value1', 'key2' => 42]) + ->with($toolReference, ['key1' => 'value1', 'key2' => 42, '_session' => $this->session]) ->willThrowException($exception); $this->logger ->expects($this->once()) ->method('error') ->with( - 'Error while executing tool "test_tool": "Tool call "test_tool" failed with error: "Custom error message".".', + 'Error while executing tool "test_tool": "Custom error message".', [ 'tool' => 'test_tool', - 'arguments' => ['key1' => 'value1', 'key2' => 42], + 'arguments' => ['key1' => 'value1', 'key2' => 42, '_session' => $this->session], ], ); - $this->handler->handle($request, $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->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('failing_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['param' => 'value', '_session' => $this->session]) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + // Generic exceptions should return Error, not Response + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Error while executing tool', $response->message); } public function testHandleWithSpecialCharactersInToolName(): void { $request = $this->createCallToolRequest('tool-with_special.chars', []); + $tool = new Tool('tool-with_special.chars', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); $expectedResult = new CallToolResult([new TextContent('Special tool result')]); - $toolReference = $this->createMock(ToolReference::class); + $toolReference = $this->getMockBuilder(ToolReference::class) + ->setConstructorArgs([$tool, function () { + return 'Special tool result'; + }]) + ->onlyMethods(['formatResult']) + ->getMock(); + $this->referenceProvider ->expects($this->once()) ->method('getTool') @@ -292,7 +339,7 @@ public function testHandleWithSpecialCharactersInToolName(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, []) + ->with($toolReference, ['_session' => $this->session]) ->willReturn('Special tool result'); $toolReference @@ -315,9 +362,12 @@ public function testHandleWithSpecialCharactersInArguments(): void 'quotes' => 'text with "quotes" and \'single quotes\'', ]; $request = $this->createCallToolRequest('unicode_tool', $arguments); + $tool = new Tool('unicode_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = new ToolReference($tool, function () { + return 'Unicode handled'; + }); $expectedResult = new CallToolResult([new TextContent('Unicode handled')]); - $toolReference = $this->createMock(ToolReference::class); $this->referenceProvider ->expects($this->once()) ->method('getTool') @@ -327,19 +377,83 @@ public function testHandleWithSpecialCharactersInArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($toolReference, $arguments) + ->with($toolReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn('Unicode handled'); + $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']); + $tool = new Tool('structured_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = $this->getMockBuilder(ToolReference::class) + ->setConstructorArgs([$tool, function () { + return 'Rendered results'; + }]) + ->onlyMethods(['formatResult']) + ->getMock(); + $structuredResult = new CallToolResult([new TextContent('Rendered results')], false, ['result' => 'Rendered results']); + + $this->referenceProvider + ->expects($this->once()) + ->method('getTool') + ->with('structured_tool') + ->willReturn($toolReference); + + $this->referenceHandler + ->expects($this->once()) + ->method('handle') + ->with($toolReference, ['query' => 'php', '_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']); + $tool = new Tool('result_tool', ['type' => 'object', 'properties' => [], 'required' => null], null, null, null, null); + $toolReference = $this->getMockBuilder(ToolReference::class) + ->setConstructorArgs([$tool, function () { + return 'Error result'; + }]) + ->onlyMethods(['formatResult']) + ->getMock(); + $callToolResult = new CallToolResult([new TextContent('Error result')], true); + + $this->referenceProvider ->expects($this->once()) - ->method('formatResult') - ->with('Unicode handled') - ->willReturn([new TextContent('Unicode handled')]); + ->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->assertEquals($expectedResult, $response->result); + $this->assertSame($callToolResult, $response->result); + $this->assertArrayNotHasKey('structuredContent', $response->result->jsonSerialize()); } /** diff --git a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php index 3f5171b1..03abe085 100644 --- a/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/GetPromptHandlerTest.php @@ -31,9 +31,9 @@ class GetPromptHandlerTest extends TestCase { private GetPromptHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -70,7 +70,7 @@ public function testHandleSuccessfulPromptGet(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -112,7 +112,7 @@ public function testHandlePromptGetWithArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -145,7 +145,7 @@ public function testHandlePromptGetWithNullArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -178,7 +178,7 @@ public function testHandlePromptGetWithEmptyArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -213,7 +213,7 @@ public function testHandlePromptGetWithMultipleMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn($expectedMessages); $promptReference @@ -231,7 +231,7 @@ public function testHandlePromptGetWithMultipleMessages(): void public function testHandlePromptNotFoundExceptionReturnsError(): void { $request = $this->createGetPromptRequest('nonexistent_prompt'); - $exception = new PromptNotFoundException($request); + $exception = new PromptNotFoundException('nonexistent_prompt'); $this->referenceProvider ->expects($this->once()) @@ -243,14 +243,14 @@ public function testHandlePromptNotFoundExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); - $this->assertEquals(Error::METHOD_NOT_FOUND, $response->code); - $this->assertEquals('Prompt not found for name: "nonexistent_prompt".', $response->message); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message); } public function testHandlePromptGetExceptionReturnsError(): void { $request = $this->createGetPromptRequest('failing_prompt'); - $exception = new PromptGetException($request, new \RuntimeException('Failed to get prompt')); + $exception = new PromptGetException('Failed to get prompt'); $this->referenceProvider ->expects($this->once()) @@ -263,7 +263,7 @@ public function testHandlePromptGetExceptionReturnsError(): void $this->assertInstanceOf(Error::class, $response); $this->assertEquals($request->getId(), $response->id); $this->assertEquals(Error::INTERNAL_ERROR, $response->code); - $this->assertEquals('Error while handling prompt', $response->message); + $this->assertEquals('Failed to get prompt', $response->message); } public function testHandlePromptGetWithComplexArguments(): void @@ -299,7 +299,7 @@ public function testHandlePromptGetWithComplexArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -337,7 +337,7 @@ public function testHandlePromptGetWithSpecialCharacters(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference @@ -367,7 +367,7 @@ public function testHandlePromptGetReturnsEmptyMessages(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, []) + ->with($promptReference, ['_session' => $this->session]) ->willReturn([]); $promptReference @@ -405,7 +405,7 @@ public function testHandlePromptGetWithLargeNumberOfArguments(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($promptReference, $arguments) + ->with($promptReference, array_merge($arguments, ['_session' => $this->session])) ->willReturn($expectedMessages); $promptReference diff --git a/tests/Unit/Server/Handler/Request/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/ReadResourceHandlerTest.php b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php index 440005d4..92b5a6f2 100644 --- a/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/ReadResourceHandlerTest.php @@ -31,9 +31,9 @@ class ReadResourceHandlerTest extends TestCase { private ReadResourceHandler $handler; - private ReferenceProviderInterface|MockObject $referenceProvider; - private ReferenceHandlerInterface|MockObject $referenceHandler; - private SessionInterface|MockObject $session; + private ReferenceProviderInterface&MockObject $referenceProvider; + private ReferenceHandlerInterface&MockObject $referenceHandler; + private SessionInterface&MockObject $session; protected function setUp(): void { @@ -75,7 +75,7 @@ public function testHandleSuccessfulResourceRead(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('test'); $resourceReference @@ -115,7 +115,7 @@ public function testHandleResourceReadWithBlobContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('fake-image-data'); $resourceReference @@ -159,7 +159,7 @@ public function testHandleResourceReadWithMultipleContents(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('binary-data'); $resourceReference @@ -178,7 +178,7 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void { $uri = 'file://nonexistent/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once()) @@ -194,14 +194,31 @@ public function testHandleResourceNotFoundExceptionReturnsSpecificError(): void $this->assertEquals('Resource not found for uri: "'.$uri.'".', $response->message); } - public function testHandleResourceReadExceptionReturnsGenericError(): void + public function testHandleResourceReadExceptionReturnsActualErrorMessage(): void { $uri = 'file://corrupted/file.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceReadException( - $request, - new \RuntimeException('Failed to read resource: corrupted data'), - ); + $exception = new ResourceReadException('Failed to read resource: corrupted data'); + + $this->referenceProvider + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertEquals(Error::INTERNAL_ERROR, $response->code); + $this->assertEquals('Failed to read resource: corrupted data', $response->message); + } + + public function testHandleGenericExceptionReturnsGenericError(): void + { + $uri = 'file://problematic/file.txt'; + $request = $this->createReadResourceRequest($uri); + $exception = new \RuntimeException('Internal database connection failed'); $this->referenceProvider ->expects($this->once()) @@ -250,7 +267,7 @@ public function testHandleResourceReadWithDifferentUriSchemes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn('test'); $resourceReference @@ -295,7 +312,7 @@ public function testHandleResourceReadWithEmptyContent(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn(''); $resourceReference @@ -357,7 +374,7 @@ public function testHandleResourceReadWithDifferentMimeTypes(): void $this->referenceHandler ->expects($this->once()) ->method('handle') - ->with($resourceReference, ['uri' => $uri]) + ->with($resourceReference, ['uri' => $uri, '_session' => $this->session]) ->willReturn($expectedContent); $resourceReference @@ -382,7 +399,7 @@ public function testHandleResourceNotFoundWithCustomMessage(): void { $uri = 'file://custom/missing.txt'; $request = $this->createReadResourceRequest($uri); - $exception = new ResourceNotFoundException($request); + $exception = new ResourceNotFoundException($uri); $this->referenceProvider ->expects($this->once()) 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 index 1abba9f6..f7a8a370 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -12,36 +12,146 @@ namespace Mcp\Tests\Unit; use Mcp\Server; -use Mcp\Server\Handler\JsonRpcHandler; -use Mcp\Server\Transport\InMemoryTransport; +use Mcp\Server\Builder; +use Mcp\Server\Protocol; +use Mcp\Server\Transport\TransportInterface; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ServerTest extends TestCase +final class ServerTest extends TestCase { - public function testJsonExceptions() + /** @var MockObject&Protocol */ + private $protocol; + + /** @var MockObject&TransportInterface */ + private $transport; + + protected function setUp(): void + { + $this->protocol = $this->createMock(Protocol::class); + $this->transport = $this->createMock(TransportInterface::class); + } + + #[TestDox('builder() returns a Builder instance')] + public function testBuilderReturnsBuilderInstance(): void + { + $builder = Server::builder(); + + $this->assertInstanceOf(Builder::class, $builder); + } + + #[TestDox('run() orchestrates transport lifecycle and protocol connection')] + public function testRunOrchestatesTransportLifecycle(): void + { + $callOrder = []; + + $this->transport->expects($this->once()) + ->method('initialize') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'initialize'; + }); + + $this->protocol->expects($this->once()) + ->method('connect') + ->with($this->transport) + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'connect'; + }); + + $this->transport->expects($this->once()) + ->method('listen') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'listen'; + + return 0; + }); + + $this->transport->expects($this->once()) + ->method('close') + ->willReturnCallback(function () use (&$callOrder) { + $callOrder[] = 'close'; + }); + + $server = new Server($this->protocol); + $result = $server->run($this->transport); + + $this->assertEquals([ + 'initialize', + 'connect', + 'listen', + 'close', + ], $callOrder); + + $this->assertEquals(0, $result); + } + + #[TestDox('run() closes transport even if listen() throws exception')] + public function testRunClosesTransportEvenOnException(): void + { + $this->transport->method('initialize'); + $this->protocol->method('connect'); + + $this->transport->expects($this->once()) + ->method('listen') + ->willThrowException(new \RuntimeException('Transport error')); + + // close() should still be called even though listen() threw + $this->transport->expects($this->once())->method('close'); + + $server = new Server($this->protocol); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Transport error'); + + $server->run($this->transport); + } + + #[TestDox('run() propagates exception if initialize() throws')] + public function testRunPropagatesInitializeException(): void { - $handler = $this->getMockBuilder(JsonRpcHandler::class) - ->disableOriginalConstructor() - ->onlyMethods(['process']) - ->getMock(); - - $handler->expects($this->exactly(2))->method('process')->willReturnOnConsecutiveCalls( - [['{"jsonrpc":"2.0","id":0,"error":{"code":-32700,"message":"Parse error"}}', []]], - [['success', []]] - ); - - $transport = $this->getMockBuilder(InMemoryTransport::class) - ->setConstructorArgs([['foo', 'bar']]) - ->onlyMethods(['send']) - ->getMock(); - $transport->expects($this->exactly(2))->method('send')->willReturnOnConsecutiveCalls( - null, - null - ); - - $server = new Server($handler); - $server->connect($transport); - - $transport->listen(); + $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); } }