diff --git a/README.md b/README.md index 394cd37c..52469357 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ $server = Server::builder() - [Server Builder](docs/server-builder.md) - Complete ServerBuilder reference and configuration - [Transports](docs/transports.md) - STDIO and HTTP transport setup and usage - [MCP Elements](docs/mcp-elements.md) - Creating tools, resources, and prompts +- [Client Communiocation](docs/client-communication.md) - Communicating back to the client from server-side **Learning:** - [Examples](docs/examples.md) - Comprehensive example walkthroughs diff --git a/composer.json b/composer.json index dfb304e7..7f25eee7 100644 --- a/composer.json +++ b/composer.json @@ -52,16 +52,17 @@ }, "autoload-dev": { "psr-4": { - "Mcp\\Example\\HttpCombinedRegistration\\": "examples/http-combined-registration/", - "Mcp\\Example\\HttpComplexToolSchema\\": "examples/http-complex-tool-schema/", - "Mcp\\Example\\HttpDiscoveryUserProfile\\": "examples/http-discovery-userprofile/", - "Mcp\\Example\\HttpSchemaShowcase\\": "examples/http-schema-showcase/", - "Mcp\\Example\\StdioCachedDiscovery\\": "examples/stdio-cached-discovery/", - "Mcp\\Example\\StdioCustomDependencies\\": "examples/stdio-custom-dependencies/", - "Mcp\\Example\\StdioDiscoveryCalculator\\": "examples/stdio-discovery-calculator/", - "Mcp\\Example\\StdioEnvVariables\\": "examples/stdio-env-variables/", - "Mcp\\Example\\StdioExplicitRegistration\\": "examples/stdio-explicit-registration/", + "Mcp\\Example\\CachedDiscovery\\": "examples/cached-discovery/", + "Mcp\\Example\\ClientCommunication\\": "examples/client-communication/", + "Mcp\\Example\\CombinedRegistration\\": "examples/combined-registration/", + "Mcp\\Example\\ComplexToolSchema\\": "examples/complex-tool-schema/", + "Mcp\\Example\\CustomDependencies\\": "examples/custom-dependencies/", "Mcp\\Example\\CustomMethodHandlers\\": "examples/custom-method-handlers/", + "Mcp\\Example\\DiscoveryCalculator\\": "examples/discovery-calculator/", + "Mcp\\Example\\DiscoveryUserProfile\\": "examples/discovery-userprofile/", + "Mcp\\Example\\EnvVariables\\": "examples/env-variables/", + "Mcp\\Example\\ExplicitRegistration\\": "examples/explicit-registration/", + "Mcp\\Example\\SchemaShowcase\\": "examples/schema-showcase/", "Mcp\\Tests\\": "tests/" } }, diff --git a/docs/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 1bb2690d..1846a7dd 100644 --- a/docs/mcp-elements.md +++ b/docs/mcp-elements.md @@ -72,6 +72,8 @@ class Calculator - **`name`** (optional): Tool identifier. Defaults to method name if not provided. - **`description`** (optional): Tool description. Defaults to docblock summary if not provided, otherwise uses method name. - **`annotations`** (optional): `ToolAnnotations` object for additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. **Priority for name/description**: Attribute parameters → DocBlock content → Method name @@ -217,6 +219,9 @@ class ConfigProvider - **`description`** (optional): Resource description. Defaults to docblock summary if not provided. - **`mimeType`** (optional): MIME type of the resource content. - **`size`** (optional): Size in bytes if known. +- **`annotations`** (optional): Additional metadata. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. **Standard Protocol URI Schemes**: `https://` (web resources), `file://` (filesystem), `git://` (version control). **Custom schemes**: `config://`, `data://`, `db://`, `api://` or any RFC 3986 compliant scheme. @@ -399,6 +404,8 @@ class PromptGenerator - **`name`** (optional): Prompt identifier. Defaults to method name if not provided. - **`description`** (optional): Prompt description. Defaults to docblock summary if not provided. +- **`icons`** (optional): Array of `Icon` objects for visual representation. +- **`meta`** (optional): Arbitrary key-value pairs for custom metadata. ### Prompt Return Values diff --git a/docs/server-builder.md b/docs/server-builder.md index ac131374..5b00f902 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -52,14 +52,25 @@ final `Server` instance ready for use. Set the server's identity with name, version, and optional description: ```php +use Mcp\Schema\Icon; +use Mcp\Server; + $server = Server::builder() - ->setServerInfo('Calculator Server', '1.2.0', 'Advanced mathematical calculations'); + ->setServerInfo( + name: 'Calculator Server', + version: '1.2.0', + description: 'Advanced mathematical calculations', + icons: [new Icon('https://example.com/icon.png', 'image/png', ['64x64'])], + websiteUrl: 'https://example.com' + '); ``` **Parameters:** - `$name` (string): The server name - `$version` (string): Version string (semantic versioning recommended) - `$description` (string|null): Optional description +- `$icons` (Icon[]|null): Optional array of server icons +- `$websiteUrl` (string|null): Optional server website URL ### Pagination Limit @@ -263,6 +274,16 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The tool handler +- `name` (string|null): Optional tool name +- `description` (string|null): Optional tool description +- `annotations` (ToolAnnotations|null): Optional annotations for the tool +- `inputSchema` (array|null): Optional input schema for the tool +- `icons` (Icon[]|null): Optional array of icons for the tool +- `meta` (array|null): Optional metadata for the tool + ### Manual Resource Registration Register static resources: @@ -278,6 +299,18 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The resource handler +- `uri` (string): The resource URI +- `name` (string|null): Optional resource name +- `description` (string|null): Optional resource description +- `mimeType` (string|null): Optional MIME type of the resource +- `size` (int|null): Optional size of the resource in bytes +- `annotations` (Annotations|null): Optional annotations for the resource +- `icons` (Icon[]|null): Optional array of icons for the resource +- `meta` (array|null): Optional metadata for the resource + ### Manual Resource Template Registration Register dynamic resources with URI templates: @@ -293,6 +326,15 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The resource template handler +- `uriTemplate` (string): The resource URI template +- `name` (string|null): Optional resource template name +- `description` (string|null): Optional resource template description +- `mimeType` (string|null): Optional MIME type of the resource +- `annotations` (Annotations|null): Optional annotations for the resource template + ### Manual Prompt Registration Register prompt generators: @@ -306,6 +348,13 @@ $server = Server::builder() ); ``` +#### Parameters + +- `handler` (callable|string): The prompt handler +- `name` (string|null): Optional prompt name +- `description` (string|null): Optional prompt description +- `icons` (Icon[]|null): Optional array of icons for the prompt + **Note:** `name` and `description` are optional for all manual registrations. If not provided, they will be derived from the handler's method name and docblock. diff --git a/examples/README.md b/examples/README.md index 96b8773b..27874d71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,14 +3,15 @@ This directory contains various examples of how to use the PHP MCP SDK. You can run the examples with the dependencies already installed in the root directory of the SDK. +The bootstrapping of the example will choose the used transport based on the SAPI you use. For running an example, you execute the `server.php` like this: ```bash -# For examples using STDIO transport -php examples/stdio-discovery-calculator/server.php +# For using the STDIO transport: +php examples/discovery-calculator/server.php -# For examples using Streamable HTTP transport -php -S localhost:8000 examples/http-discovery-userprofile/server.php +# For using the Streamable HTTP transport: +php -S localhost:8000 examples/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -18,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php ``` ## Debugging @@ -29,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/stdio-discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php ``` diff --git a/examples/bootstrap.php b/examples/bootstrap.php index e4a4b98d..8c0508ab 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -9,7 +9,13 @@ * file that was distributed with this source code. */ +use Http\Discovery\Psr17Factory; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Capability\Registry\Container; +use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Transport\StreamableHttpTransport; +use Mcp\Server\Transport\TransportInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -21,6 +27,31 @@ exit(1); }); +/** + * @return TransportInterface|TransportInterface + */ +function transport(): TransportInterface +{ + if ('cli' === \PHP_SAPI) { + return new StdioTransport(logger: logger()); + } + + return new StreamableHttpTransport( + (new Psr17Factory())->createServerRequestFromGlobals(), + logger: logger(), + ); +} + +function shutdown(ResponseInterface|int $result): never +{ + if ('cli' === \PHP_SAPI) { + exit($result); + } + + (new SapiEmitter())->emit($result); + exit(0); +} + function logger(): LoggerInterface { return new class extends AbstractLogger { diff --git a/examples/stdio-cached-discovery/CachedCalculatorElements.php b/examples/cached-discovery/CachedCalculatorElements.php similarity index 96% rename from examples/stdio-cached-discovery/CachedCalculatorElements.php rename to examples/cached-discovery/CachedCalculatorElements.php index 1da3d277..ef67b0ff 100644 --- a/examples/stdio-cached-discovery/CachedCalculatorElements.php +++ b/examples/cached-discovery/CachedCalculatorElements.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCachedDiscovery; +namespace Mcp\Example\CachedDiscovery; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; diff --git a/examples/stdio-cached-discovery/server.php b/examples/cached-discovery/server.php similarity index 85% rename from examples/stdio-cached-discovery/server.php rename to examples/cached-discovery/server.php index 38b5e9ce..1d6c1e16 100644 --- a/examples/stdio-cached-discovery/server.php +++ b/examples/cached-discovery/server.php @@ -16,7 +16,7 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; use Symfony\Component\Cache\Adapter\PhpFilesAdapter; use Symfony\Component\Cache\Psr16Cache; @@ -25,14 +25,13 @@ $server = Server::builder() ->setServerInfo('Cached Discovery Calculator', '1.0.0', 'Calculator with cached discovery for better performance.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) ->setDiscovery(__DIR__, cache: new Psr16Cache(new PhpFilesAdapter(directory: __DIR__.'/cache'))) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/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 67% rename from examples/http-combined-registration/server.php rename to examples/combined-registration/server.php index 2c3184a6..02f26a4e 100644 --- a/examples/http-combined-registration/server.php +++ b/examples/combined-registration/server.php @@ -13,15 +13,9 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use Mcp\Example\HttpCombinedRegistration\ManualHandlers; +use Mcp\Example\CombinedRegistration\ManualHandlers; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Combined HTTP Server', '1.0.0') @@ -37,8 +31,6 @@ ) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/http-complex-tool-schema/McpEventScheduler.php b/examples/complex-tool-schema/McpEventScheduler.php similarity index 93% rename from examples/http-complex-tool-schema/McpEventScheduler.php rename to examples/complex-tool-schema/McpEventScheduler.php index a9b3edc7..253ff5cb 100644 --- a/examples/http-complex-tool-schema/McpEventScheduler.php +++ b/examples/complex-tool-schema/McpEventScheduler.php @@ -9,11 +9,11 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema; +namespace Mcp\Example\ComplexToolSchema; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\HttpComplexToolSchema\Model\EventPriority; -use Mcp\Example\HttpComplexToolSchema\Model\EventType; +use Mcp\Example\ComplexToolSchema\Model\EventPriority; +use Mcp\Example\ComplexToolSchema\Model\EventType; use Psr\Log\LoggerInterface; final class McpEventScheduler diff --git a/examples/http-complex-tool-schema/Model/EventPriority.php b/examples/complex-tool-schema/Model/EventPriority.php similarity index 87% rename from examples/http-complex-tool-schema/Model/EventPriority.php rename to examples/complex-tool-schema/Model/EventPriority.php index 1654be0e..e46a69da 100644 --- a/examples/http-complex-tool-schema/Model/EventPriority.php +++ b/examples/complex-tool-schema/Model/EventPriority.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventPriority: int { diff --git a/examples/http-complex-tool-schema/Model/EventType.php b/examples/complex-tool-schema/Model/EventType.php similarity index 88% rename from examples/http-complex-tool-schema/Model/EventType.php rename to examples/complex-tool-schema/Model/EventType.php index 5711662d..eaf0f431 100644 --- a/examples/http-complex-tool-schema/Model/EventType.php +++ b/examples/complex-tool-schema/Model/EventType.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpComplexToolSchema\Model; +namespace Mcp\Example\ComplexToolSchema\Model; enum EventType: string { diff --git a/examples/http-complex-tool-schema/server.php b/examples/complex-tool-schema/server.php similarity index 64% rename from examples/http-complex-tool-schema/server.php rename to examples/complex-tool-schema/server.php index b6d29378..92f80b61 100644 --- a/examples/http-complex-tool-schema/server.php +++ b/examples/complex-tool-schema/server.php @@ -13,14 +13,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('Event Scheduler Server', '1.0.0') @@ -30,8 +24,6 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-custom-dependencies/McpTaskHandlers.php b/examples/custom-dependencies/McpTaskHandlers.php similarity index 93% rename from examples/stdio-custom-dependencies/McpTaskHandlers.php rename to examples/custom-dependencies/McpTaskHandlers.php index 65d2f003..00127a78 100644 --- a/examples/stdio-custom-dependencies/McpTaskHandlers.php +++ b/examples/custom-dependencies/McpTaskHandlers.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies; +namespace Mcp\Example\CustomDependencies; use Mcp\Capability\Attribute\McpResource; use Mcp\Capability\Attribute\McpTool; -use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Psr\Log\LoggerInterface; /** diff --git a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php b/examples/custom-dependencies/Service/InMemoryTaskRepository.php similarity index 97% rename from examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php rename to examples/custom-dependencies/Service/InMemoryTaskRepository.php index 63ce8611..dbf3b6ab 100644 --- a/examples/stdio-custom-dependencies/Service/InMemoryTaskRepository.php +++ b/examples/custom-dependencies/Service/InMemoryTaskRepository.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php b/examples/custom-dependencies/Service/StatsServiceInterface.php similarity index 87% rename from examples/stdio-custom-dependencies/Service/StatsServiceInterface.php rename to examples/custom-dependencies/Service/StatsServiceInterface.php index a7e8b276..079f7e23 100644 --- a/examples/stdio-custom-dependencies/Service/StatsServiceInterface.php +++ b/examples/custom-dependencies/Service/StatsServiceInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; interface StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/SystemStatsService.php b/examples/custom-dependencies/Service/SystemStatsService.php similarity index 94% rename from examples/stdio-custom-dependencies/Service/SystemStatsService.php rename to examples/custom-dependencies/Service/SystemStatsService.php index 5a766792..5cd44880 100644 --- a/examples/stdio-custom-dependencies/Service/SystemStatsService.php +++ b/examples/custom-dependencies/Service/SystemStatsService.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; final class SystemStatsService implements StatsServiceInterface { diff --git a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php b/examples/custom-dependencies/Service/TaskRepositoryInterface.php similarity index 93% rename from examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php rename to examples/custom-dependencies/Service/TaskRepositoryInterface.php index 6c091be1..7216634c 100644 --- a/examples/stdio-custom-dependencies/Service/TaskRepositoryInterface.php +++ b/examples/custom-dependencies/Service/TaskRepositoryInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioCustomDependencies\Service; +namespace Mcp\Example\CustomDependencies\Service; /** * @phpstan-type Task array{id: int, userId: string, description: string, completed: bool, createdAt: string} diff --git a/examples/stdio-custom-dependencies/server.php b/examples/custom-dependencies/server.php similarity index 63% rename from examples/stdio-custom-dependencies/server.php rename to examples/custom-dependencies/server.php index 4ebd4cbd..cd450e51 100644 --- a/examples/stdio-custom-dependencies/server.php +++ b/examples/custom-dependencies/server.php @@ -13,14 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\StdioCustomDependencies\Service\InMemoryTaskRepository; -use Mcp\Example\StdioCustomDependencies\Service\StatsServiceInterface; -use Mcp\Example\StdioCustomDependencies\Service\SystemStatsService; -use Mcp\Example\StdioCustomDependencies\Service\TaskRepositoryInterface; +use Mcp\Example\CustomDependencies\Service\InMemoryTaskRepository; +use Mcp\Example\CustomDependencies\Service\StatsServiceInterface; +use Mcp\Example\CustomDependencies\Service\SystemStatsService; +use Mcp\Example\CustomDependencies\Service\TaskRepositoryInterface; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Dependencies (Stdio) Server...'); +logger()->info('Starting MCP Custom Dependencies Server...'); $container = container(); @@ -32,15 +32,14 @@ $server = Server::builder() ->setServerInfo('Task Manager Server', '1.0.0') - ->setLogger(logger()) ->setContainer($container) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/custom-method-handlers/server.php b/examples/custom-method-handlers/server.php index 7554a0a7..62f41df9 100644 --- a/examples/custom-method-handlers/server.php +++ b/examples/custom-method-handlers/server.php @@ -18,9 +18,9 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Custom Method Handlers (Stdio) Server...'); +logger()->info('Starting MCP Custom Method Handlers Server...'); $toolDefinitions = [ 'say_hello' => new Tool( @@ -56,16 +56,15 @@ $server = Server::builder() ->setServerInfo('Custom Handlers Server', '1.0.0') - ->setLogger(logger()) ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setLogger(logger()) ->setCapabilities($capabilities) ->addRequestHandlers([$listToolsHandler, $callToolHandler]) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/stdio-discovery-calculator/McpElements.php b/examples/discovery-calculator/McpElements.php similarity index 93% rename from examples/stdio-discovery-calculator/McpElements.php rename to examples/discovery-calculator/McpElements.php index 21330313..972534d1 100644 --- a/examples/stdio-discovery-calculator/McpElements.php +++ b/examples/discovery-calculator/McpElements.php @@ -9,11 +9,12 @@ * 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 Mcp\Schema\Icon; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -47,7 +48,10 @@ public function __construct( * * @return float the result of the calculation */ - #[McpTool(name: 'calculate')] + #[McpTool( + name: 'calculate', + icons: [new Icon('https://www.svgrepo.com/show/530644/calculator.svg', 'image/svg+xml', ['any'])], + )] public function calculate(float $a, float $b, string $operation): float { $this->logger->info(\sprintf('Calculating: %f %s %f', $a, $operation, $b)); @@ -92,6 +96,7 @@ public function calculate(float $a, float $b, string $operation): float name: 'calculator_config', description: 'Current settings for the calculator tool (precision, allow_negative).', mimeType: 'application/json', + icons: [new Icon('https://www.svgrepo.com/show/529867/settings.svg', 'image/svg+xml', ['any'])], )] public function getConfiguration(): array { diff --git a/examples/stdio-discovery-calculator/server.php b/examples/discovery-calculator/server.php similarity index 71% rename from examples/stdio-discovery-calculator/server.php rename to examples/discovery-calculator/server.php index 3f113489..c6d75e4d 100644 --- a/examples/stdio-discovery-calculator/server.php +++ b/examples/discovery-calculator/server.php @@ -14,22 +14,21 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; +use Mcp\Server\Session\FileSessionStore; -logger()->info('Starting MCP Stdio Calculator Server...'); +logger()->info('Starting MCP Calculator Server...'); $server = Server::builder() - ->setServerInfo('Stdio Calculator', '1.1.0', 'Basic Calculator over STDIO transport.') + ->setServerInfo('Calculator', '1.1.0', 'Basic Calculator') ->setInstructions('This server supports basic arithmetic operations: add, subtract, multiply, and divide. Send JSON-RPC requests to perform calculations.') ->setContainer(container()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setLogger(logger()) ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/http-discovery-userprofile/McpElements.php b/examples/discovery-userprofile/McpElements.php similarity index 97% rename from examples/http-discovery-userprofile/McpElements.php rename to examples/discovery-userprofile/McpElements.php index 933bd51e..8418f09a 100644 --- a/examples/http-discovery-userprofile/McpElements.php +++ b/examples/discovery-userprofile/McpElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Attribute\CompletionProvider; use Mcp\Capability\Attribute\McpPrompt; @@ -39,7 +39,7 @@ final class McpElements public function __construct( private readonly LoggerInterface $logger, ) { - $this->logger->debug('HttpDiscoveryUserProfile McpElements instantiated.'); + $this->logger->debug('DiscoveryUserProfile McpElements instantiated.'); } /** diff --git a/examples/http-discovery-userprofile/UserIdCompletionProvider.php b/examples/discovery-userprofile/UserIdCompletionProvider.php similarity index 92% rename from examples/http-discovery-userprofile/UserIdCompletionProvider.php rename to examples/discovery-userprofile/UserIdCompletionProvider.php index 37b1c5b7..69dfe4f0 100644 --- a/examples/http-discovery-userprofile/UserIdCompletionProvider.php +++ b/examples/discovery-userprofile/UserIdCompletionProvider.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpDiscoveryUserProfile; +namespace Mcp\Example\DiscoveryUserProfile; use Mcp\Capability\Completion\ProviderInterface; diff --git a/examples/http-discovery-userprofile/server.php b/examples/discovery-userprofile/server.php similarity index 86% rename from examples/http-discovery-userprofile/server.php rename to examples/discovery-userprofile/server.php index c958bfb9..48033716 100644 --- a/examples/http-discovery-userprofile/server.php +++ b/examples/discovery-userprofile/server.php @@ -13,14 +13,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() ->setServerInfo('HTTP User Profiles', '1.0.0') @@ -72,8 +66,6 @@ function (): array { ) ->build(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-env-variables/EnvToolHandler.php b/examples/env-variables/EnvToolHandler.php similarity index 97% rename from examples/stdio-env-variables/EnvToolHandler.php rename to examples/env-variables/EnvToolHandler.php index 49c914d5..f7cad817 100644 --- a/examples/stdio-env-variables/EnvToolHandler.php +++ b/examples/env-variables/EnvToolHandler.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioEnvVariables; +namespace Mcp\Example\EnvVariables; use Mcp\Capability\Attribute\McpTool; diff --git a/examples/stdio-env-variables/server.php b/examples/env-variables/server.php similarity index 81% rename from examples/stdio-env-variables/server.php rename to examples/env-variables/server.php index 13c36bcf..48c43825 100644 --- a/examples/stdio-env-variables/server.php +++ b/examples/env-variables/server.php @@ -14,18 +14,17 @@ chdir(__DIR__); use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; /* |-------------------------------------------------------------------------- - | MCP Stdio Environment Variable Example Server + | MCP Environment Variable Example Server |-------------------------------------------------------------------------- | | This server demonstrates how to use environment variables to modify tool | behavior. The MCP client can set the APP_MODE environment variable to | control the server's behavior. | - | Configure your MCP Client (eg. Cursor) for this server like this: + | Configure your MCP Client (e.g. Cursor) for this server like this: | | { | "mcpServers": { @@ -47,7 +46,7 @@ | */ -logger()->info('Starting MCP Stdio Environment Variable Example Server...'); +logger()->info('Starting MCP Environment Variable Example Server...'); $server = Server::builder() ->setServerInfo('Env Var Server', '1.0.0') @@ -55,10 +54,8 @@ ->setDiscovery(__DIR__) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/stdio-explicit-registration/SimpleHandlers.php b/examples/explicit-registration/SimpleHandlers.php similarity index 97% rename from examples/stdio-explicit-registration/SimpleHandlers.php rename to examples/explicit-registration/SimpleHandlers.php index 0a119e77..0fe385c1 100644 --- a/examples/stdio-explicit-registration/SimpleHandlers.php +++ b/examples/explicit-registration/SimpleHandlers.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\StdioExplicitRegistration; +namespace Mcp\Example\ExplicitRegistration; use Psr\Log\LoggerInterface; diff --git a/examples/stdio-explicit-registration/server.php b/examples/explicit-registration/server.php similarity index 80% rename from examples/stdio-explicit-registration/server.php rename to examples/explicit-registration/server.php index 2189bf16..5a61feef 100644 --- a/examples/stdio-explicit-registration/server.php +++ b/examples/explicit-registration/server.php @@ -13,15 +13,14 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Mcp\Example\StdioExplicitRegistration\SimpleHandlers; +use Mcp\Example\ExplicitRegistration\SimpleHandlers; use Mcp\Schema\ServerCapabilities; use Mcp\Server; -use Mcp\Server\Transport\StdioTransport; -logger()->info('Starting MCP Manual Registration (Stdio) Server...'); +logger()->info('Starting MCP Manual Registration Server...'); $server = Server::builder() - ->setServerInfo('Manual Reg Server', '1.0.0') + ->setServerInfo('Explicit Registration Server', '1.0.0') ->setLogger(logger()) ->setContainer(container()) ->addTool([SimpleHandlers::class, 'echoText'], 'echo_text') @@ -41,10 +40,8 @@ )) ->build(); -$transport = new StdioTransport(logger: logger()); - -$result = $server->run($transport); +$result = $server->run(transport()); logger()->info('Server listener stopped gracefully.', ['result' => $result]); -exit($result); +shutdown($result); diff --git a/examples/http-client-communication/server.php b/examples/http-client-communication/server.php deleted file mode 100644 index 8191a8e2..00000000 --- a/examples/http-client-communication/server.php +++ /dev/null @@ -1,123 +0,0 @@ -createServerRequestFromGlobals(); - -$sessionDir = __DIR__.'/sessions'; -$capabilities = new ServerCapabilities(logging: true, tools: true); -$logger = logger(); - -$server = Server::builder() - ->setServerInfo('HTTP Client Communication Demo', '1.0.0') - ->setLogger($logger) - ->setContainer(container()) - ->setSession(new FileSessionStore($sessionDir)) - ->setCapabilities($capabilities) - ->addTool( - function (string $projectName, array $milestones, ClientGateway $client): array { - $client->log(LoggingLevel::Info, sprintf('Preparing project briefing for "%s"', $projectName)); - - $totalSteps = max(1, count($milestones)); - - foreach ($milestones as $index => $milestone) { - $progress = ($index + 1) / $totalSteps; - $message = sprintf('Analyzing milestone "%s"', $milestone); - - $client->progress(progress: $progress, total: 1, message: $message); - - usleep(150_000); // Simulate work being done - } - - $prompt = sprintf( - 'Draft a concise stakeholder briefing for the project "%s". Highlight key milestones: %s. Focus on risks and next steps.', - $projectName, - implode(', ', $milestones) - ); - - $response = $client->sample( - prompt: $prompt, - maxTokens: 400, - timeout: 90, - options: ['temperature' => 0.4] - ); - - if ($response instanceof JsonRpcError) { - throw new ToolCallException(sprintf('Sampling request failed (%d): %s', $response->code, $response->message)); - } - - $result = $response->result; - $content = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; - - $client->log(LoggingLevel::Info, 'Briefing ready, returning to caller.'); - - return [ - 'project' => $projectName, - 'milestones_reviewed' => $milestones, - 'briefing' => $content, - 'model' => $result->model, - 'stop_reason' => $result->stopReason, - ]; - }, - name: 'prepare_project_briefing', - description: 'Compile a stakeholder briefing with live logging, progress updates, and LLM sampling.' - ) - ->addTool( - function (string $serviceName, ClientGateway $client): array { - $client->log(LoggingLevel::Info, sprintf('Starting maintenance checks for "%s"', $serviceName)); - - $steps = [ - 'Verifying health metrics', - 'Checking recent deployments', - 'Reviewing alert stream', - 'Summarizing findings', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / count($steps); - - $client->progress(progress: $progress, total: 1, message: $step); - - usleep(120_000); // Simulate work being done - } - - $client->log(LoggingLevel::Info, sprintf('Maintenance checks complete for "%s"', $serviceName)); - - return [ - 'service' => $serviceName, - 'status' => 'operational', - 'notes' => 'No critical issues detected during automated sweep.', - ]; - }, - name: 'run_service_maintenance', - description: 'Simulate service maintenance with logging and progress notifications.' - ) - ->build(); - -$transport = new StreamableHttpTransport($request, logger: $logger); - -$response = $server->run($transport); - -(new SapiEmitter())->emit($response); diff --git a/examples/http-schema-showcase/SchemaShowcaseElements.php b/examples/schema-showcase/SchemaShowcaseElements.php similarity index 99% rename from examples/http-schema-showcase/SchemaShowcaseElements.php rename to examples/schema-showcase/SchemaShowcaseElements.php index d19bfa1f..6c7a4b93 100644 --- a/examples/http-schema-showcase/SchemaShowcaseElements.php +++ b/examples/schema-showcase/SchemaShowcaseElements.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Mcp\Example\HttpSchemaShowcase; +namespace Mcp\Example\SchemaShowcase; use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\Schema; diff --git a/examples/http-schema-showcase/server.php b/examples/schema-showcase/server.php similarity index 59% rename from examples/http-schema-showcase/server.php rename to examples/schema-showcase/server.php index 6b38a3c2..86c2bf77 100644 --- a/examples/http-schema-showcase/server.php +++ b/examples/schema-showcase/server.php @@ -13,25 +13,24 @@ require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); -use Http\Discovery\Psr17Factory; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Mcp\Schema\Icon; use Mcp\Server; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Transport\StreamableHttpTransport; - -$psr17Factory = new Psr17Factory(); -$request = $psr17Factory->createServerRequestFromGlobals(); $server = Server::builder() - ->setServerInfo('Schema Showcase', '1.0.0') + ->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(); -$transport = new StreamableHttpTransport($request); - -$response = $server->run($transport); +$response = $server->run(transport()); -(new SapiEmitter())->emit($response); +shutdown($response); diff --git a/examples/stdio-client-communication/server.php b/examples/stdio-client-communication/server.php deleted file mode 100644 index 96d2f052..00000000 --- a/examples/stdio-client-communication/server.php +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env php -setServerInfo('STDIO Client Communication Demo', '1.0.0') - ->setLogger(logger()) - ->setContainer(container()) - ->setCapabilities($capabilities) - ->addTool( - function (string $incidentTitle, ClientGateway $client): array { - $client->log(LoggingLevel::Warning, sprintf('Incident triage started: %s', $incidentTitle)); - - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; - - foreach ($steps as $index => $step) { - $progress = ($index + 1) / count($steps); - - $client->progress(progress: $progress, total: 1, message: $step); - - usleep(180_000); // Simulate work being done - } - - $prompt = sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); - - $sampling = $client->sample( - prompt: $prompt, - maxTokens: 350, - timeout: 90, - options: ['temperature' => 0.5] - ); - - if ($sampling instanceof JsonRpcError) { - throw new RuntimeException(sprintf('Sampling request failed (%d): %s', $sampling->code, $sampling->message)); - } - - $result = $sampling->result; - $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; - - $client->log(LoggingLevel::Info, sprintf('Incident triage completed for %s', $incidentTitle)); - - return [ - 'incident' => $incidentTitle, - 'recommended_actions' => $recommendation, - 'model' => $result->model, - ]; - }, - name: 'coordinate_incident_response', - description: 'Coordinate an incident response with logging, progress, and sampling.' - ) - ->addTool( - function (string $dataset, ClientGateway $client): array { - $client->log(LoggingLevel::Info, sprintf('Running quality checks on dataset "%s"', $dataset)); - - $tasks = [ - 'Validating schema', - 'Scanning for anomalies', - 'Reviewing statistical summary', - ]; - - foreach ($tasks as $index => $task) { - $progress = ($index + 1) / count($tasks); - - $client->progress(progress: $progress, total: 1, message: $task); - - usleep(140_000); // Simulate work being done - } - - $client->log(LoggingLevel::Info, sprintf('Dataset "%s" passed automated checks.', $dataset)); - - return [ - 'dataset' => $dataset, - 'status' => 'passed', - 'notes' => 'No significant integrity issues detected during automated checks.', - ]; - }, - name: 'run_dataset_quality_checks', - description: 'Perform dataset quality checks with progress updates and logging.' - ) - ->build(); - -$transport = new StdioTransport(); - -$status = $server->run($transport); - -exit($status); diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 17f4c3b0..900f12fd 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -15,6 +15,10 @@ parameters: ignoreErrors: - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" + - + identifier: missingType.iterableValue + path: tests/ + # These errors should actually be fixed, but are ignored for now - identifier: missingType.iterableValue diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index 73677e33..0737c18d 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -11,6 +11,8 @@ namespace Mcp\Capability\Attribute; +use Mcp\Schema\Icon; + /** * Marks a PHP method as an MCP Prompt generator. * The method should return the prompt messages, potentially using arguments for templating. @@ -21,12 +23,16 @@ final class McpPrompt { /** - * @param ?string $name overrides the prompt name (defaults to method name) - * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. + * @param ?Icon[] $icons Optional list of icon URLs representing the prompt + * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?array $icons = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResource.php b/src/Capability/Attribute/McpResource.php index 873d485c..2dcf85e6 100644 --- a/src/Capability/Attribute/McpResource.php +++ b/src/Capability/Attribute/McpResource.php @@ -12,6 +12,7 @@ namespace Mcp\Capability\Attribute; use Mcp\Schema\Annotations; +use Mcp\Schema\Icon; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. @@ -23,12 +24,14 @@ final class McpResource { /** - * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. - * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. - * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. - * @param ?string $mimeType the MIME type, if known and constant for this resource - * @param ?int $size the size in bytes, if known and constant - * @param Annotations|null $annotations optional annotations describing the resource + * @param string $uri The specific URI identifying this resource instance. Must be unique within the server. + * @param ?string $name A human-readable name for this resource. If null, a default might be generated from the method name. + * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. + * @param ?string $mimeType the MIME type, if known and constant for this resource + * @param ?int $size the size in bytes, if known and constant + * @param Annotations|null $annotations optional annotations describing the resource + * @param ?Icon[] $icons Optional list of icon URLs representing the resource + * @param ?array $meta Optional metadata */ public function __construct( public string $uri, @@ -37,6 +40,8 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, + public ?array $icons = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpResourceTemplate.php b/src/Capability/Attribute/McpResourceTemplate.php index 9b8887f1..14e66c5f 100644 --- a/src/Capability/Attribute/McpResourceTemplate.php +++ b/src/Capability/Attribute/McpResourceTemplate.php @@ -23,11 +23,12 @@ final class McpResourceTemplate { /** - * @param string $uriTemplate the URI template string (RFC 6570) - * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. - * @param ?string $description Optional description. Defaults to class DocBlock summary. - * @param ?string $mimeType optional default MIME type for matching resources - * @param ?Annotations $annotations optional annotations describing the resource template + * @param string $uriTemplate the URI template string (RFC 6570) + * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. + * @param ?string $description Optional description. Defaults to class DocBlock summary. + * @param ?string $mimeType optional default MIME type for matching resources + * @param ?Annotations $annotations optional annotations describing the resource template + * @param ?array $meta Optional metadata */ public function __construct( public string $uriTemplate, @@ -35,6 +36,7 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Attribute/McpTool.php b/src/Capability/Attribute/McpTool.php index d4af3e6c..85dbc225 100644 --- a/src/Capability/Attribute/McpTool.php +++ b/src/Capability/Attribute/McpTool.php @@ -11,6 +11,7 @@ namespace Mcp\Capability\Attribute; +use Mcp\Schema\Icon; use Mcp\Schema\ToolAnnotations; /** @@ -20,14 +21,18 @@ class McpTool { /** - * @param string|null $name The name of the tool (defaults to the method name) - * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) - * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param string|null $name The name of the tool (defaults to the method name) + * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior + * @param ?Icon[] $icons Optional list of icon URLs representing the tool + * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, + public ?array $icons = null, + public ?array $meta = null, ) { } } diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index e6cc328f..88ec4117 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -222,7 +222,14 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->generate($method); - $tool = new Tool($name, $inputSchema, $description, $instance->annotations); + $tool = new Tool( + $name, + $inputSchema, + $description, + $instance->annotations, + $instance->icons, + $instance->meta, + ); $tools[$name] = new ToolReference($tool, [$className, $methodName], false); ++$discoveredCount['tools']; break; @@ -231,11 +238,18 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; - $mimeType = $instance->mimeType; - $size = $instance->size; - $annotations = $instance->annotations; - $resource = new Resource($instance->uri, $name, $description, $mimeType, $annotations, $size); + $resource = new Resource( + $instance->uri, + $name, + $description, + $instance->mimeType, + $instance->annotations, + $instance->size, + $instance->icons, + $instance->meta, + ); $resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName], false); + ++$discoveredCount['resources']; break; @@ -253,7 +267,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $prompt = new Prompt($name, $description, $arguments); + $prompt = new Prompt($name, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; @@ -265,7 +279,8 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $mimeType = $instance->mimeType; $annotations = $instance->annotations; - $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations); + $meta = $instance->meta ?? null; + $resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $description, $mimeType, $annotations, $meta); $completionProviders = $this->getCompletionProviders($method); $resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], false, $completionProviders); ++$discoveredCount['resourceTemplates']; diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 3a13c323..381af919 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -18,6 +18,7 @@ use Mcp\Capability\Registry\ResourceReference; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\Registry\ToolReference; +use Mcp\Capability\Tool\NameValidator; use Mcp\Event\PromptListChangedEvent; use Mcp\Event\ResourceListChangedEvent; use Mcp\Event\ResourceTemplateListChangedEvent; @@ -71,6 +72,7 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt public function __construct( private readonly ?EventDispatcherInterface $eventDispatcher = null, private readonly LoggerInterface $logger = new NullLogger(), + private readonly NameValidator $nameValidator = new NameValidator(), ) { } @@ -100,12 +102,18 @@ public function registerTool(Tool $tool, callable|array|string $handler, bool $i if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered tool "%s" as it conflicts with a manually registered one.', $toolName), ); return; } + if (!$this->nameValidator->isValid($toolName)) { + $this->logger->warning( + \sprintf('Tool name "%s" is invalid. Tool names should only contain letters (a-z, A-Z), numbers, dots, hyphens, underscores, and forward slashes.', $toolName), + ); + } + $this->tools[$toolName] = new ToolReference($tool, $handler, $isManual); $this->eventDispatcher?->dispatch(new ToolListChangedEvent()); @@ -118,7 +126,7 @@ public function registerResource(Resource $resource, callable|array|string $hand if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered resource "%s" as it conflicts with a manually registered one.', $uri), ); return; @@ -140,7 +148,7 @@ public function registerResourceTemplate( if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered template "%s" as it conflicts with a manually registered one.', $uriTemplate), ); return; @@ -167,7 +175,7 @@ public function registerPrompt( if ($existing && !$isManual && $existing->isManual) { $this->logger->debug( - "Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one.", + \sprintf('Ignoring discovered prompt "%s" as it conflicts with a manually registered one.', $promptName), ); return; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 7420bc75..8fcfd675 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -22,6 +22,7 @@ use Mcp\Capability\Registry\ReferenceRegistryInterface; use Mcp\Exception\ConfigurationException; use Mcp\Schema\Annotations; +use Mcp\Schema\Icon; use Mcp\Schema\Prompt; use Mcp\Schema\PromptArgument; use Mcp\Schema\Resource; @@ -45,6 +46,8 @@ final class ArrayLoader implements LoaderInterface * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * icons: ?Icon[], + * meta: ?array * }[] $tools * @param array{ * handler: Handler, @@ -54,6 +57,8 @@ final class ArrayLoader implements LoaderInterface * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * icons: ?Icon[], + * meta: ?array * }[] $resources * @param array{ * handler: Handler, @@ -62,18 +67,21 @@ final class ArrayLoader implements LoaderInterface * description: ?string, * mimeType: ?string, * annotations: ?Annotations, + * meta: ?array * }[] $resourceTemplates * @param array{ * handler: Handler, * name: ?string, * description: ?string, + * icons: ?Icon[], + * meta: ?array * }[] $prompts */ public function __construct( - private array $tools = [], - private array $resources = [], - private array $resourceTemplates = [], - private array $prompts = [], + private readonly array $tools = [], + private readonly array $resources = [], + private readonly array $resourceTemplates = [], + private readonly array $prompts = [], private LoggerInterface $logger = new NullLogger(), ) { } @@ -102,7 +110,14 @@ public function load(ReferenceRegistryInterface $registry): void $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); - $tool = new Tool($name, $inputSchema, $description, $data['annotations']); + $tool = new Tool( + name: $name, + inputSchema: $inputSchema, + description: $description, + annotations: $data['annotations'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, + ); $registry->registerTool($tool, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); @@ -133,12 +148,16 @@ public function load(ReferenceRegistryInterface $registry): void $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; } - $uri = $data['uri']; - $mimeType = $data['mimeType']; - $size = $data['size']; - $annotations = $data['annotations']; - - $resource = new Resource($uri, $name, $description, $mimeType, $annotations, $size); + $resource = new Resource( + uri: $data['uri'], + name: $name, + description: $description, + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + size: $data['size'] ?? null, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null, + ); $registry->registerResource($resource, $data['handler'], true); $handlerDesc = $this->getHandlerDescription($data['handler']); @@ -169,11 +188,14 @@ public function load(ReferenceRegistryInterface $registry): void $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; } - $uriTemplate = $data['uriTemplate']; - $mimeType = $data['mimeType']; - $annotations = $data['annotations']; - - $template = new ResourceTemplate($uriTemplate, $name, $description, $mimeType, $annotations); + $template = new ResourceTemplate( + uriTemplate: $data['uriTemplate'], + name: $name, + description: $description, + mimeType: $data['mimeType'] ?? null, + annotations: $data['annotations'] ?? null, + meta: $data['meta'] ?? null, + ); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); @@ -224,8 +246,13 @@ public function load(ReferenceRegistryInterface $registry): void !$param->isOptional() && !$param->isDefaultValueAvailable(), ); } - - $prompt = new Prompt($name, $description, $arguments); + $prompt = new Prompt( + name: $name, + description: $description, + arguments: $arguments, + icons: $data['icons'] ?? null, + meta: $data['meta'] ?? null + ); $completionProviders = $this->getCompletionProviders($reflection); $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index e3eb925f..7ce8c737 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,7 +13,9 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\Server\ClientAwareInterface; use Mcp\Server\ClientGateway; +use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; /** @@ -31,12 +33,18 @@ public function __construct( */ public function handle(ElementReference $reference, array $arguments): mixed { + $session = $arguments['_session']; + if (\is_string($reference->handler)) { if (class_exists($reference->handler) && method_exists($reference->handler, '__invoke')) { $reflection = new \ReflectionMethod($reference->handler, '__invoke'); $instance = $this->getClassInstance($reference->handler); $arguments = $this->prepareArguments($reflection, $arguments); + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + return \call_user_func($instance, ...$arguments); } @@ -49,7 +57,7 @@ public function handle(ElementReference $reference, array $arguments): mixed } if (\is_callable($reference->handler)) { - $reflection = $this->getReflectionForCallable($reference->handler); + $reflection = $this->getReflectionForCallable($reference->handler, $session); $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func($reference->handler, ...$arguments); @@ -59,6 +67,11 @@ public function handle(ElementReference $reference, array $arguments): mixed [$className, $methodName] = $reference->handler; $reflection = new \ReflectionMethod($className, $methodName); $instance = $this->getClassInstance($className); + + if ($instance instanceof ClientAwareInterface) { + $instance->setClient(new ClientGateway($session)); + } + $arguments = $this->prepareArguments($reflection, $arguments); return \call_user_func([$instance, $methodName], ...$arguments); @@ -130,7 +143,7 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array /** * Gets a ReflectionMethod or ReflectionFunction for a callable. */ - private function getReflectionForCallable(callable $handler): \ReflectionMethod|\ReflectionFunction + private function getReflectionForCallable(callable $handler, SessionInterface $session): \ReflectionMethod|\ReflectionFunction { if (\is_string($handler)) { return new \ReflectionFunction($handler); @@ -143,6 +156,10 @@ private function getReflectionForCallable(callable $handler): \ReflectionMethod| if (\is_array($handler) && 2 === \count($handler)) { [$class, $method] = $handler; + if ($class instanceof ClientAwareInterface) { + $class->setClient(new ClientGateway($session)); + } + return new \ReflectionMethod($class, $method); } diff --git a/src/Capability/Registry/ResourceReference.php b/src/Capability/Registry/ResourceReference.php index c93d5a14..d9b6a7e4 100644 --- a/src/Capability/Registry/ResourceReference.php +++ b/src/Capability/Registry/ResourceReference.php @@ -68,9 +68,11 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = return [$readResult->resource]; } + $meta = $this->schema->meta; + if (\is_array($readResult)) { if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]')]; + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; } $allAreResourceContents = true; @@ -118,14 +120,15 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - return [new TextResourceContents($uri, $mimeType, $readResult)]; + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; } if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { $result = BlobResourceContents::fromStream( $uri, $readResult, - $mimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream', + $meta ); @fclose($readResult); @@ -136,21 +139,21 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; } if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; } - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; } if (\is_array($readResult)) { @@ -159,7 +162,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = try { $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); } @@ -169,7 +172,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException(\sprintf('Failed to encode array as JSON for URI "%s": %s', $uri, $e->getMessage())); } diff --git a/src/Capability/Registry/ResourceTemplateReference.php b/src/Capability/Registry/ResourceTemplateReference.php index b71a943a..88104c9d 100644 --- a/src/Capability/Registry/ResourceTemplateReference.php +++ b/src/Capability/Registry/ResourceTemplateReference.php @@ -101,9 +101,11 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = return [$readResult->resource]; } + $meta = $this->resourceTemplate->meta; + if (\is_array($readResult)) { if (empty($readResult)) { - return [new TextResourceContents($uri, 'application/json', '[]')]; + return [new TextResourceContents($uri, 'application/json', '[]', $meta)]; } $allAreResourceContents = true; @@ -151,14 +153,15 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_string($readResult)) { $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); - return [new TextResourceContents($uri, $mimeType, $readResult)]; + return [new TextResourceContents($uri, $mimeType, $readResult, $meta)]; } if (\is_resource($readResult) && 'stream' === get_resource_type($readResult)) { $result = BlobResourceContents::fromStream( $uri, $readResult, - $mimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream', + $meta ); @fclose($readResult); @@ -169,21 +172,21 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = if (\is_array($readResult) && isset($readResult['blob']) && \is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; - return [new BlobResourceContents($uri, $mimeType, $readResult['blob'])]; + return [new BlobResourceContents($uri, $mimeType, $readResult['blob'], $meta)]; } if (\is_array($readResult) && isset($readResult['text']) && \is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; - return [new TextResourceContents($uri, $mimeType, $readResult['text'])]; + return [new TextResourceContents($uri, $mimeType, $readResult['text'], $meta)]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { if ($mimeType && str_contains(strtolower($mimeType), 'text')) { - return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + return [new TextResourceContents($uri, $mimeType, file_get_contents($readResult->getPathname()), $meta)]; } - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType, $meta)]; } if (\is_array($readResult)) { @@ -192,7 +195,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = try { $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -202,7 +205,7 @@ public function formatResult(mixed $readResult, string $uri, ?string $mimeType = $jsonString = json_encode($readResult, \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT); $mimeType = $mimeType ?? 'application/json'; - return [new TextResourceContents($uri, $mimeType, $jsonString)]; + return [new TextResourceContents($uri, $mimeType, $jsonString, $meta)]; } catch (\JsonException $e) { throw new RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } diff --git a/src/Capability/Tool/NameValidator.php b/src/Capability/Tool/NameValidator.php new file mode 100644 index 00000000..fe9bb084 --- /dev/null +++ b/src/Capability/Tool/NameValidator.php @@ -0,0 +1,20 @@ + + */ +class ClientException extends Exception +{ + public function __construct( + private readonly Error $error, + ) { + parent::__construct($error->message); + } + + public function getError(): Error + { + return $this->error; + } +} diff --git a/src/Schema/Content/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/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/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 da3be4f6..4f31e034 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -38,14 +38,16 @@ class CallToolResult implements ResultInterface /** * Create a new CallToolResult. * - * @param Content[] $content The content of the tool result - * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). - * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param Content[] $content The content of the tool result + * @param bool $isError Whether the tool execution resulted in an error. If not set, this is assumed to be false (the call was successful). + * @param mixed[] $structuredContent JSON content for `structuredContent` + * @param array|null $meta Optional metadata */ public function __construct( public readonly array $content, public readonly bool $isError = false, public readonly ?array $structuredContent = null, + public readonly ?array $meta = null, ) { foreach ($this->content as $item) { if (!$item instanceof Content) { @@ -57,27 +59,30 @@ public function __construct( /** * Create a new CallToolResult with success status. * - * @param Content[] $content The content of the tool result + * @param Content[] $content The content of the tool result + * @param array|null $meta Optional metadata */ - public static function success(array $content): self + public static function success(array $content, ?array $meta = null): self { - return new self($content, false); + return new self($content, false, null, $meta); } /** * Create a new CallToolResult with error status. * - * @param Content[] $content The content of the tool result + * @param Content[] $content The content of the tool result + * @param array|null $meta Optional metadata */ - public static function error(array $content): self + public static function error(array $content, ?array $meta = null): self { - return new self($content, true); + return new self($content, true, null, $meta); } /** * @param array{ * content: array, * isError?: bool, + * _meta?: array, * } $data */ public static function fromArray(array $data): self @@ -98,13 +103,20 @@ public static function fromArray(array $data): self }; } - return new self($contents, $data['isError'] ?? false); + return new self( + $contents, + $data['isError'] ?? false, + $data['structuredContent'] ?? null, + $data['_meta'] ?? null + ); } /** * @return array{ * content: array, * isError: bool, + * structuredContent?: array, + * _meta?: array, * } */ public function jsonSerialize(): array @@ -118,6 +130,10 @@ public function jsonSerialize(): array $result['structuredContent'] = $this->structuredContent; } + if ($this->meta) { + $result['_meta'] = $this->meta; + } + return $result; } } 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..3a4e8193 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -17,6 +17,7 @@ * Definition for a tool the client can call. * * @phpstan-import-type ToolAnnotationsData from ToolAnnotations + * @phpstan-import-type IconData from Icon * * @phpstan-type ToolInputSchema array{ * type: 'object', @@ -28,6 +29,8 @@ * inputSchema: ToolInputSchema, * description?: string|null, * annotations?: ToolAnnotationsData, + * icons?: IconData[], + * _meta?: array * } * * @author Kyrian Obikwelu @@ -35,18 +38,22 @@ class Tool implements \JsonSerializable { /** - * @param string $name the name of the tool - * @param string|null $description A human-readable description of the tool. - * This can be used by clients to improve the LLM's understanding of - * available tools. It can be thought of like a "hint" to the model. - * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool - * @param ToolAnnotations|null $annotations optional additional tool information + * @param string $name the name of the tool + * @param ?string $description A human-readable description of the tool. + * This can be used by clients to improve the LLM's understanding of + * available tools. It can be thought of like a "hint" to the model. + * @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool + * @param ?ToolAnnotations $annotations optional additional tool information + * @param ?Icon[] $icons optional icons representing the tool + * @param ?array $meta Optional metadata */ public function __construct( public readonly string $name, public readonly array $inputSchema, public readonly ?string $description, public readonly ?ToolAnnotations $annotations, + public readonly ?array $icons = null, + public readonly ?array $meta = null, ) { if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) { throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".'); @@ -75,7 +82,9 @@ public static function fromArray(array $data): self $data['name'], $data['inputSchema'], isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, - isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null + isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, + isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null ); } @@ -85,6 +94,8 @@ public static function fromArray(array $data): self * inputSchema: ToolInputSchema, * description?: string, * annotations?: ToolAnnotations, + * icons?: Icon[], + * _meta?: array * } */ public function jsonSerialize(): array @@ -99,6 +110,12 @@ public function jsonSerialize(): array if (null !== $this->annotations) { $data['annotations'] = $this->annotations; } + if (null !== $this->icons) { + $data['icons'] = $this->icons; + } + if (null !== $this->meta) { + $data['_meta'] = $this->meta; + } return $data; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 90bf53a4..b3aad3be 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -16,9 +16,12 @@ use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\ArrayLoader; use Mcp\Capability\Registry\Loader\DiscoveryLoader; +use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; +use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; use Mcp\Schema\ToolAnnotations; @@ -62,6 +65,8 @@ final class Builder private ?string $instructions = null; + private ?ProtocolVersion $protocolVersion = null; + /** * @var array> */ @@ -78,6 +83,8 @@ final class Builder * name: ?string, * description: ?string, * annotations: ?ToolAnnotations, + * icons: ?Icon[], + * meta: ?array * }[] */ private array $tools = []; @@ -91,6 +98,8 @@ final class Builder * mimeType: ?string, * size: int|null, * annotations: ?Annotations, + * icons: ?Icon[], + * meta: ?array * }[] */ private array $resources = []; @@ -103,6 +112,7 @@ final class Builder * description: ?string, * mimeType: ?string, * annotations: ?Annotations, + * meta: ?array * }[] */ private array $resourceTemplates = []; @@ -112,6 +122,8 @@ final class Builder * handler: Handler, * name: ?string, * description: ?string, + * icons: ?Icon[], + * meta: ?array * }[] */ private array $prompts = []; @@ -130,12 +142,24 @@ final class Builder 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; } @@ -282,11 +306,20 @@ 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 ?Icon[] $icons + * @param array|null $meta */ public function addTool( callable|array|string $handler, @@ -294,8 +327,18 @@ public function addTool( ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null, + ?array $icons = null, + ?array $meta = null, ): self { - $this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); + $this->tools[] = compact( + 'handler', + 'name', + 'description', + 'annotations', + 'inputSchema', + 'icons', + 'meta', + ); return $this; } @@ -303,7 +346,9 @@ public function addTool( /** * Manually registers a resource handler. * - * @param Handler $handler + * @param Handler $handler + * @param ?Icon[] $icons + * @param array|null $meta */ public function addResource( \Closure|array|string $handler, @@ -313,8 +358,20 @@ public function addResource( ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null, + ?array $icons = null, + ?array $meta = null, ): self { - $this->resources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + $this->resources[] = compact( + 'handler', + 'uri', + 'name', + 'description', + 'mimeType', + 'size', + 'annotations', + 'icons', + 'meta', + ); return $this; } @@ -322,7 +379,8 @@ public function addResource( /** * Manually registers a resource template handler. * - * @param Handler $handler + * @param Handler $handler + * @param array|null $meta */ public function addResourceTemplate( \Closure|array|string $handler, @@ -331,6 +389,7 @@ public function addResourceTemplate( ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null, + ?array $meta = null, ): self { $this->resourceTemplates[] = compact( 'handler', @@ -339,6 +398,7 @@ public function addResourceTemplate( 'description', 'mimeType', 'annotations', + 'meta', ); return $this; @@ -347,11 +407,28 @@ public function addResourceTemplate( /** * Manually registers a prompt handler. * - * @param Handler $handler + * @param Handler $handler + * @param ?Icon[] $icons + * @param array|null $meta + */ + public function addPrompt( + \Closure|array|string $handler, + ?string $name = null, + ?string $description = null, + ?array $icons = null, + ?array $meta = null, + ): self { + $this->prompts[] = compact('handler', 'name', 'description', 'icons', 'meta'); + + return $this; + } + + /** + * @param LoaderInterface[] $loaders */ - public function addPrompt(\Closure|array|string $handler, ?string $name = null, ?string $description = null): self + public function addLoaders(...$loaders): self { - $this->prompts[] = compact('handler', 'name', 'description'); + $this->loaders = [...$this->loaders, ...$loaders]; return $this; } @@ -366,6 +443,7 @@ public function build(): Server $registry = new Registry($this->eventDispatcher, $logger); $loaders = [ + ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger), ]; @@ -387,7 +465,7 @@ public function build(): Server $messageFactory = MessageFactory::make(); $capabilities = $registry->getCapabilities(); - $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions); + $configuration = new Configuration($this->serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); $referenceHandler = new ReferenceHandler($container); $requestHandlers = array_merge($this->requestHandlers, [ diff --git a/src/Server/ClientAwareInterface.php b/src/Server/ClientAwareInterface.php new file mode 100644 index 00000000..86c8c2ef --- /dev/null +++ b/src/Server/ClientAwareInterface.php @@ -0,0 +1,17 @@ +client = $client; + } + + private function notify(Notification $notification): void + { + $this->client->notify($notification); + } + + private function log(LoggingLevel $level, mixed $data, ?string $logger = null): void + { + $this->client->log($level, $data, $logger); + } + + private function progress(float $progress, ?float $total = null, ?string $message = null): void + { + $this->client->progress($progress, $total, $message); + } + + /** + * @param SampleOptions $options + */ + private function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult + { + return $this->client->sample($prompt, $maxTokens, $timeout, $options); + } +} diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 7e95fe9a..8179aee2 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -11,10 +11,17 @@ namespace Mcp\Server; +use Mcp\Exception\ClientException; +use Mcp\Exception\InvalidArgumentException; +use Mcp\Exception\RuntimeException; +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\Content; +use Mcp\Schema\Content\ImageContent; use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\Role; +use Mcp\Schema\Enum\SamplingContext; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; @@ -46,6 +53,15 @@ * } * ``` * + * @phpstan-type SampleOptions array{ + * preferences?: ModelPreferences, + * systemPrompt?: string, + * temperature?: float, + * includeContext?: SamplingContext, + * stopSequences?: string[], + * metadata?: array, + * } + * * @author Kyrian Obikwelu */ final class ClientGateway @@ -95,89 +111,77 @@ public function progress(float $progress, ?float $total = null, ?string $message } /** - * Send a request to the client and wait for a response (blocking). + * Convenience method for LLM sampling requests. * - * This suspends the Fiber and waits for the client to respond. The transport - * handles polling the session for the response and resuming the Fiber when ready. + * @param SamplingMessage[]|TextContent|AudioContent|ImageContent|string $message The message for the LLM + * @param int $maxTokens Maximum tokens to generate + * @param int $timeout The timeout in seconds + * @param SampleOptions $options Additional sampling options (temperature, etc.) * - * @param Request $request The request to send - * @param int $timeout Maximum time to wait for response (seconds) + * @return CreateSamplingMessageResult The sampling response * - * @return Response>|Error The client's response message - * - * @throws \RuntimeException If Fiber support is not available + * @throws ClientException if the client request results in an error message */ - public function request(Request $request, int $timeout = 120): Response|Error + public function sample(array|Content|string $message, int $maxTokens = 1000, int $timeout = 120, array $options = []): CreateSamplingMessageResult { - $response = \Fiber::suspend([ - 'type' => 'request', - 'request' => $request, - 'session_id' => $this->session->getId()->toRfc4122(), - 'timeout' => $timeout, - ]); + $preferences = $options['preferences'] ?? null; + if (null !== $preferences && !$preferences instanceof ModelPreferences) { + throw new InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + } - if (!$response instanceof Response && !$response instanceof Error) { - throw new \RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); + if (\is_string($message)) { + $message = new TextContent($message); + } + if (\is_object($message) && \in_array($message::class, [TextContent::class, AudioContent::class, ImageContent::class], true)) { + $message = [new SamplingMessage(Role::User, $message)]; } - return $response; - } + $request = new CreateSamplingMessageRequest( + messages: $message, + maxTokens: $maxTokens, + preferences: $preferences, + systemPrompt: $options['systemPrompt'] ?? null, + includeContext: $options['includeContext'] ?? null, + temperature: $options['temperature'] ?? null, + stopSequences: $options['stopSequences'] ?? null, + metadata: $options['metadata'] ?? null, + ); - /** - * Create and send an LLM sampling requests. - * - * @param CreateSamplingMessageRequest $request The request to send - * @param int $timeout The timeout in seconds - * - * @return Response|Error The sampling response - */ - public function createMessage(CreateSamplingMessageRequest $request, int $timeout = 120): Response|Error - { $response = $this->request($request, $timeout); if ($response instanceof Error) { - return $response; + throw new ClientException($response); } - $result = CreateSamplingMessageResult::fromArray($response->result); - - return new Response($response->getId(), $result); + return CreateSamplingMessageResult::fromArray($response->result); } /** - * Convenience method for LLM sampling requests. + * Send a request to the client and wait for a response (blocking). * - * @param string $prompt The prompt for the LLM - * @param int $maxTokens Maximum tokens to generate - * @param int $timeout The timeout in seconds - * @param array $options Additional sampling options (temperature, etc.) + * This suspends the Fiber and waits for the client to respond. The transport + * handles polling the session for the response and resuming the Fiber when ready. * - * @return Response|Error The sampling response + * @param Request $request The request to send + * @param int $timeout Maximum time to wait for response (seconds) + * + * @return Response>|Error The client's response message + * + * @throws RuntimeException If Fiber support is not available */ - public function sample(string $prompt, int $maxTokens = 1000, int $timeout = 120, array $options = []): Response|Error + private function request(Request $request, int $timeout = 120): Response|Error { - $preferences = $options['preferences'] ?? null; - if (\is_array($preferences)) { - $preferences = ModelPreferences::fromArray($preferences); - } + $response = \Fiber::suspend([ + 'type' => 'request', + 'request' => $request, + 'session_id' => $this->session->getId()->toRfc4122(), + 'timeout' => $timeout, + ]); - if (null !== $preferences && !$preferences instanceof ModelPreferences) { - throw new \InvalidArgumentException('The "preferences" option must be an array or an instance of ModelPreferences.'); + if (!$response instanceof Response && !$response instanceof Error) { + throw new RuntimeException('Transport returned an unexpected payload; expected a Response or Error message.'); } - $samplingRequest = new CreateSamplingMessageRequest( - messages: [ - new SamplingMessage(Role::User, new TextContent(text: $prompt)), - ], - maxTokens: $maxTokens, - preferences: $preferences, - systemPrompt: $options['systemPrompt'] ?? null, - includeContext: $options['includeContext'] ?? null, - temperature: $options['temperature'] ?? null, - stopSequences: $options['stopSequences'] ?? null, - metadata: $options['metadata'] ?? null, - ); - - return $this->createMessage($samplingRequest, $timeout); + return $response; } } diff --git a/src/Server/Configuration.php b/src/Server/Configuration.php index ab629b58..2f0bd7f0 100644 --- a/src/Server/Configuration.php +++ b/src/Server/Configuration.php @@ -11,6 +11,7 @@ namespace Mcp\Server; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\ServerCapabilities; @@ -34,6 +35,7 @@ public function __construct( public readonly ServerCapabilities $capabilities, public readonly int $paginationLimit = 50, public readonly ?string $instructions = null, + public readonly ?ProtocolVersion $protocolVersion = null, ) { } } diff --git a/src/Server/Handler/Request/InitializeHandler.php b/src/Server/Handler/Request/InitializeHandler.php index 32eae194..d814d9dd 100644 --- a/src/Server/Handler/Request/InitializeHandler.php +++ b/src/Server/Handler/Request/InitializeHandler.php @@ -52,6 +52,8 @@ public function handle(Request $request, SessionInterface $session): Response $this->configuration->capabilities ?? new ServerCapabilities(), $this->configuration->serverInfo ?? new Implementation(), $this->configuration?->instructions, + null, + $this->configuration?->protocolVersion, ), ); } diff --git a/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/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/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index 297cad17..28ca547e 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -208,7 +208,7 @@ protected function createStreamedResponse(): ResponseInterface return $this->withCorsHeaders($response); } - private function handleFiberTermination(): void + protected function handleFiberTermination(): void { $finalResult = $this->sessionFiber->getReturn(); @@ -227,7 +227,7 @@ private function handleFiberTermination(): void $this->sessionFiber = null; } - private function flushOutgoingMessages(?Uuid $sessionId): void + protected function flushOutgoingMessages(?Uuid $sessionId): void { $messages = $this->getOutgoingMessages($sessionId); diff --git a/src/Server/Transport/TransportInterface.php b/src/Server/Transport/TransportInterface.php index 400c453e..5a874a76 100644 --- a/src/Server/Transport/TransportInterface.php +++ b/src/Server/Transport/TransportInterface.php @@ -16,7 +16,7 @@ use Symfony\Component\Uid\Uuid; /** - * @template TResult + * @template-covariant TResult * * @phpstan-type FiberReturn (Response|Error) * @phpstan-type FiberResume (FiberReturn|null) diff --git a/tests/Inspector/Http/HttpClientCommunicationTest.php b/tests/Inspector/Http/HttpClientCommunicationTest.php index 0b396e2e..ba287fd0 100644 --- a/tests/Inspector/Http/HttpClientCommunicationTest.php +++ b/tests/Inspector/Http/HttpClientCommunicationTest.php @@ -59,6 +59,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-client-communication/server.php'; + return \dirname(__DIR__, 3).'/examples/client-communication/server.php'; } } diff --git a/tests/Inspector/Http/HttpCombinedRegistrationTest.php b/tests/Inspector/Http/HttpCombinedRegistrationTest.php index 932c200b..36f133cd 100644 --- a/tests/Inspector/Http/HttpCombinedRegistrationTest.php +++ b/tests/Inspector/Http/HttpCombinedRegistrationTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-combined-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/combined-registration/server.php'; } } diff --git a/tests/Inspector/Http/HttpComplexToolSchemaTest.php b/tests/Inspector/Http/HttpComplexToolSchemaTest.php index 93f3ca30..6c104e28 100644 --- a/tests/Inspector/Http/HttpComplexToolSchemaTest.php +++ b/tests/Inspector/Http/HttpComplexToolSchemaTest.php @@ -82,6 +82,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-complex-tool-schema/server.php'; + return \dirname(__DIR__, 3).'/examples/complex-tool-schema/server.php'; } } diff --git a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php index abffbacd..b0ffef81 100644 --- a/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php +++ b/tests/Inspector/Http/HttpDiscoveryUserProfileTest.php @@ -67,6 +67,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-discovery-userprofile/server.php'; + return \dirname(__DIR__, 3).'/examples/discovery-userprofile/server.php'; } } diff --git a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php index 41f224a9..5db629e4 100644 --- a/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php +++ b/tests/Inspector/Http/HttpInspectorSnapshotTestCase.php @@ -21,14 +21,12 @@ abstract class HttpInspectorSnapshotTestCase extends InspectorSnapshotTestCase protected function setUp(): void { - parent::setUp(); $this->startServer(); } protected function tearDown(): void { $this->stopServer(); - parent::tearDown(); } abstract protected function getServerScript(): string; diff --git a/tests/Inspector/Http/HttpSchemaShowcaseTest.php b/tests/Inspector/Http/HttpSchemaShowcaseTest.php index e4b137da..9ed61ae1 100644 --- a/tests/Inspector/Http/HttpSchemaShowcaseTest.php +++ b/tests/Inspector/Http/HttpSchemaShowcaseTest.php @@ -87,7 +87,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/http-schema-showcase/server.php'; + return \dirname(__DIR__, 3).'/examples/schema-showcase/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/InspectorSnapshotTestCase.php b/tests/Inspector/InspectorSnapshotTestCase.php index c8335308..292670ae 100644 --- a/tests/Inspector/InspectorSnapshotTestCase.php +++ b/tests/Inspector/InspectorSnapshotTestCase.php @@ -18,7 +18,7 @@ abstract class InspectorSnapshotTestCase extends TestCase { - private const INSPECTOR_VERSION = '0.16.8'; + private const INSPECTOR_VERSION = '0.17.2'; /** @param array $options */ #[DataProvider('provideMethods')] @@ -109,7 +109,8 @@ public function testOutputMatchesSnapshot( $expected = file_get_contents($snapshotFile); - $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput); + $message = \sprintf('Output does not match snapshot "%s".', $snapshotFile); + $this->assertJsonStringEqualsJsonString($expected, $normalizedOutput, $message); } protected function normalizeTestOutput(string $output, ?string $testName = null): string @@ -117,7 +118,6 @@ protected function normalizeTestOutput(string $output, ?string $testName = null) return $output; } - /** @return array> */ public static function provideMethods(): array { return [ diff --git a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php index ede61ed3..438a3f8e 100644 --- a/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php +++ b/tests/Inspector/Stdio/StdioCachedDiscoveryTest.php @@ -88,6 +88,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-cached-discovery/server.php'; + return \dirname(__DIR__, 3).'/examples/cached-discovery/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php index e4285e27..d2f64c0d 100644 --- a/tests/Inspector/Stdio/StdioCustomDependenciesTest.php +++ b/tests/Inspector/Stdio/StdioCustomDependenciesTest.php @@ -55,7 +55,7 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-custom-dependencies/server.php'; + return \dirname(__DIR__, 3).'/examples/custom-dependencies/server.php'; } protected function normalizeTestOutput(string $output, ?string $testName = null): string diff --git a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php index 8a62b2c6..87ce549c 100644 --- a/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php +++ b/tests/Inspector/Stdio/StdioDiscoveryCalculatorTest.php @@ -45,6 +45,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-discovery-calculator/server.php'; + return \dirname(__DIR__, 3).'/examples/discovery-calculator/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioEnvVariablesTest.php b/tests/Inspector/Stdio/StdioEnvVariablesTest.php index 97601d1a..c46eb753 100644 --- a/tests/Inspector/Stdio/StdioEnvVariablesTest.php +++ b/tests/Inspector/Stdio/StdioEnvVariablesTest.php @@ -50,6 +50,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-env-variables/server.php'; + return \dirname(__DIR__, 3).'/examples/env-variables/server.php'; } } diff --git a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php index 103d3e67..1cb8179b 100644 --- a/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php +++ b/tests/Inspector/Stdio/StdioExplicitRegistrationTest.php @@ -77,6 +77,6 @@ public static function provideMethods(): array protected function getServerScript(): string { - return \dirname(__DIR__, 3).'/examples/stdio-explicit-registration/server.php'; + return \dirname(__DIR__, 3).'/examples/explicit-registration/server.php'; } } diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json index a40f3489..e72548b5 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json @@ -4,6 +4,15 @@ "name": "calculator_config", "uri": "config://calculator/settings", "description": "Current settings for the calculator tool (precision, allow_negative).", + "icons": [ + { + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ], + "src": "https://www.svgrepo.com/show/529867/settings.svg" + } + ], "mimeType": "application/json" } ] diff --git a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json index d4a4531a..5f184117 100644 --- a/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json +++ b/tests/Inspector/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json @@ -3,6 +3,15 @@ { "name": "calculate", "description": "Performs a calculation based on the operation.", + "icons": [ + { + "mimeType": "image/svg+xml", + "sizes": [ + "any" + ], + "src": "https://www.svgrepo.com/show/530644/calculator.svg" + } + ], "inputSchema": { "type": "object", "properties": { diff --git a/tests/Unit/Capability/Registry/RegistryTest.php b/tests/Unit/Capability/Registry/RegistryTest.php index 33cb967e..4f3c4ddd 100644 --- a/tests/Unit/Capability/Registry/RegistryTest.php +++ b/tests/Unit/Capability/Registry/RegistryTest.php @@ -140,7 +140,7 @@ public function testRegisterToolIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered tool 'test_tool' as it conflicts with a manually registered one."); + ->with('Ignoring discovered tool "test_tool" as it conflicts with a manually registered one.'); $this->registry->registerTool($discoveredTool, fn () => 'discovered', false); @@ -181,7 +181,7 @@ public function testRegisterResourceIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered resource 'test://resource' as it conflicts with a manually registered one."); + ->with('Ignoring discovered resource "test://resource" as it conflicts with a manually registered one.'); $this->registry->registerResource($discoveredResource, fn () => 'discovered', false); @@ -210,7 +210,7 @@ public function testRegisterResourceTemplateIgnoresDiscoveredWhenManualExists(): $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered template 'test://{id}' as it conflicts with a manually registered one."); + ->with('Ignoring discovered template "test://{id}" as it conflicts with a manually registered one.'); $this->registry->registerResourceTemplate($discoveredTemplate, fn () => 'discovered', [], false); @@ -239,7 +239,7 @@ public function testRegisterPromptIgnoresDiscoveredWhenManualExists(): void $this->logger ->expects($this->once()) ->method('debug') - ->with("Ignoring discovered prompt 'test_prompt' as it conflicts with a manually registered one."); + ->with('Ignoring discovered prompt "test_prompt" as it conflicts with a manually registered one.'); $this->registry->registerPrompt($discoveredPrompt, fn () => 'discovered', [], false); diff --git a/tests/Unit/Capability/Tool/NameValidatorTest.php b/tests/Unit/Capability/Tool/NameValidatorTest.php new file mode 100644 index 00000000..269be890 --- /dev/null +++ b/tests/Unit/Capability/Tool/NameValidatorTest.php @@ -0,0 +1,56 @@ +assertTrue((new NameValidator())->isValid($name)); + } + + public static function provideValidNames(): array + { + return [ + ['my_tool'], + ['MyTool123'], + ['my.tool'], + ['my-tool'], + ['my/tool'], + ['my_tool-01.02'], + ['my_long_toolname_that_is_exactly_sixty_four_characters_long_1234'], + ]; + } + + #[DataProvider('provideInvalidNames')] + public function testInvalidNames(string $name): void + { + $this->assertFalse((new NameValidator())->isValid($name)); + } + + public static function provideInvalidNames(): array + { + return [ + [''], + ['my tool'], + ['my@tool'], + ['my!tool'], + ['my_tool#1'], + ['this_tool_name_is_way_too_long_because_it_exceeds_the_sixty_four_character_limit_set_by_the_validator'], + ]; + } +} 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/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'] + ); + } +}