diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1cce97..f829a0b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: php: [8.1, 8.2, 8.3, 8.4] - max-parallel: 4 + max-parallel: 2 name: Tests PHP${{ matrix.php }} diff --git a/README.md b/README.md index da255f1..62d2a3c 100644 --- a/README.md +++ b/README.md @@ -5,68 +5,89 @@ [![Tests](https://img.shields.io/github/actions/workflow/status/php-mcp/server/tests.yml?branch=main&style=flat-square)](https://github.com/php-mcp/server/actions/workflows/tests.yml) [![License](https://img.shields.io/packagist/l/php-mcp/server.svg?style=flat-square)](LICENSE) -**PHP MCP Server provides a robust and flexible server-side implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) for PHP applications.** +**A comprehensive and production-ready PHP implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server. Built for PHP 8.1+ with modern architecture, extensive testing, and flexible transport options.** -Easily expose parts of your application as standardized MCP **Tools**, **Resources**, and **Prompts**, allowing AI assistants (like Anthropic's Claude, Cursor IDE, etc.) to interact with your PHP backend using the MCP standard. +The PHP MCP Server enables you to expose your PHP application's functionality as standardized MCP **Tools**, **Resources**, and **Prompts**, allowing AI assistants (like Anthropic's Claude, Cursor IDE, OpenAI's ChatGPT, etc.) to interact with your backend using the MCP standard. -This package simplifies building MCP servers through: +## ๐Ÿš€ Key Features -* **Attribute-Based Definition:** Define MCP elements using PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`) on your methods or invokable classes. -* **Manual Registration:** Programmatically register elements using a fluent builder API. -* **Explicit Discovery:** Trigger attribute scanning on demand via the `$server->discover()` method. -* **Metadata Inference:** Intelligently generate MCP schemas and descriptions from PHP type hints and DocBlocks. -* **Selective Caching:** Optionally cache *discovered* element definitions to speed up startup, while always preserving manually registered elements. -* **Flexible Transports:** Supports `stdio` and `http+sse`, separating core logic from network communication. -* **PSR Compliance:** Integrates with PSR-3 (Logging), PSR-11 (Container), and PSR-16 (SimpleCache). +- **๐Ÿ—๏ธ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design +- **๐Ÿ“ก Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability +- **๐ŸŽฏ Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration +- **๐Ÿ“ Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements +- **โšก Session Management**: Advanced session handling with multiple storage backends +- **๐Ÿ”„ Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations +- **๐Ÿ“Š Batch Processing**: Full support for JSON-RPC batch requests +- **๐Ÿ’พ Smart Caching**: Intelligent caching of discovered elements with manual override precedence +- **๐Ÿงช Completion Providers**: Built-in support for argument completion in tools and prompts +- **๐Ÿ”Œ Dependency Injection**: Full PSR-11 container support with auto-wiring +- **๐Ÿ“‹ Comprehensive Testing**: Extensive test suite with integration tests for all transports -This package currently supports the `2024-11-05` version of the Model Context Protocol. +This package supports the **2025-03-26** version of the Model Context Protocol with backward compatibility. -## Requirements +## ๐Ÿ“‹ Requirements -* PHP >= 8.1 -* Composer -* *(For Http Transport)*: An event-driven PHP environment capable of handling concurrent requests (see [HTTP Transport](#http-transport-httpsse) section). +- **PHP** >= 8.1 +- **Composer** +- **For HTTP Transport**: An event-driven PHP environment (CLI recommended) +- **Extensions**: `json`, `mbstring`, `pcre` (typically enabled by default) -## Installation +## ๐Ÿ“ฆ Installation ```bash composer require php-mcp/server ``` -> **Note for Laravel Users:** While this package works standalone, consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration, and Artisan commands. +> **๐Ÿ’ก Laravel Users**: Consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration management, and Artisan commands. -## Quick Start: Standalone `stdio` Server with Discovery +## โšก Quick Start: Stdio Server with Discovery -This example creates a server using **attribute discovery** to find elements and runs via the `stdio` transport. +This example demonstrates the most common usage pattern - a `stdio` server using attribute discovery. -**1. Define Your MCP Element:** +**1. Define Your MCP Elements** -Create `src/MyMcpElements.php`: +Create `src/CalculatorElements.php`: ```php withServerInfo('My Discovery Server', '1.0.2') + ->withServerInfo('PHP Calculator Server', '1.0.0') ->build(); - // 2. **Explicitly run discovery** + // Discover MCP elements via attributes $server->discover( basePath: __DIR__, - scanDirs: ['src'], + scanDirs: ['src'] ); - // 3. Create the Stdio Transport + // Start listening via stdio transport $transport = new StdioServerTransport(); - - // 4. Start Listening (BLOCKING call) $server->listen($transport); - exit(0); - } catch (\Throwable $e) { - fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n" . $e . "\n"); + fwrite(STDERR, "[CRITICAL ERROR] " . $e->getMessage() . "\n"); exit(1); } ``` -**3. Configure Your MCP Client:** +**3. Configure Your MCP Client** -Instruct your MCP client (e.g., Cursor, Claude Desktop) to use the `stdio` transport by running your script. Make sure to use the **absolute path**: +Add to your client configuration (e.g., `.cursor/mcp.json`): ```json -// Example: .cursor/mcp.json { "mcpServers": { - "my-php-stdio": { + "php-calculator": { "command": "php", - "args": ["/full/path/to/your/project/mcp-server.php"] + "args": ["/absolute/path/to/your/mcp-server.php"] } } } ``` -**Flow:** +**4. Test the Server** + +Your AI assistant can now call: +- `add_numbers` - Add two integers +- `calculate_power` - Calculate power with validation constraints + +## ๐Ÿ—๏ธ Architecture Overview + +The PHP MCP Server uses a modern, decoupled architecture: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Client โ”‚โ—„โ”€โ”€โ–บโ”‚ Transport โ”‚โ—„โ”€โ”€โ–บโ”‚ Protocol โ”‚ +โ”‚ (Claude, etc.) โ”‚ โ”‚ (Stdio/HTTP/SSE) โ”‚ โ”‚ (JSON-RPC) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ Session Manager โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค + โ”‚ (Multi-backend) โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ Dispatcher โ”‚โ—„โ”€โ”€โ”€โ”‚ Server Core โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ (Method Router) โ”‚ โ”‚ Configuration โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ + โ–ผ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ Registry โ”‚ โ”‚ Elements โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”‚ (Element Store)โ”‚โ—„โ”€โ”€โ–บโ”‚ (Tools/Resources โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ Prompts/etc.) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Core Components + +- **`ServerBuilder`**: Fluent configuration interface (`Server::make()->...->build()`) +- **`Server`**: Central coordinator containing all configured components +- **`Protocol`**: JSON-RPC 2.0 handler bridging transports and core logic +- **`SessionManager`**: Multi-backend session storage (array, cache, custom) +- **`Dispatcher`**: Method routing and request processing +- **`Registry`**: Element storage with smart caching and precedence rules +- **`Elements`**: Registered MCP components (Tools, Resources, Prompts, Templates) + +### Transport Options + +1. **`StdioServerTransport`**: Standard I/O for direct client launches +2. **`HttpServerTransport`**: HTTP + Server-Sent Events for web integration +3. **`StreamableHttpServerTransport`**: Enhanced HTTP with resumability and event sourcing + +## โš™๏ธ Server Configuration + +### Basic Configuration + +```php +use PhpMcp\Server\Server; +use PhpMcp\Schema\ServerCapabilities; + +$server = Server::make() + ->withServerInfo('My App Server', '2.1.0') + ->withCapabilities(ServerCapabilities::make( + resources: true, + resourcesSubscribe: true, + prompts: true, + tools: true + )) + ->withPaginationLimit(100) + ->build(); +``` + +### Advanced Configuration with Dependencies -1. `Server::make()->...->build()`: Creates the `Server` instance, resolves dependencies, performs *manual* registrations (if any), and implicitly attempts to load *discovered* elements from cache (if configured and cache exists). -2. `$server->discover(__DIR__, ['src'])`: Explicitly triggers a filesystem scan within `src/`. Clears previously discovered/cached elements from the registry, finds `MyMcpElements::add`, creates its `ToolDefinition`, and registers it. If caching is enabled and `saveToCache` is true, saves this discovered definition to the cache. -3. `$server->listen($transport)`: Binds the transport, checks if *any* elements are registered (in this case, yes), starts the transport listener, and runs the event loop. +```php +use Psr\Log\Logger; +use Psr\SimpleCache\CacheInterface; +use Psr\Container\ContainerInterface; -## Core Architecture +$server = Server::make() + ->withServerInfo('Production Server', '1.0.0') + ->withLogger($myPsrLogger) // PSR-3 Logger + ->withCache($myPsrCache) // PSR-16 Cache + ->withContainer($myPsrContainer) // PSR-11 Container + ->withSession('cache', 7200) // Cache-backed sessions, 2hr TTL + ->withPaginationLimit(50) // Limit list responses + ->build(); +``` -The server uses a decoupled architecture: +### Session Management Options -* **`ServerBuilder`:** Fluent interface (`Server::make()->...`) for configuration. Collects server identity, dependencies (Logger, Cache, Container, Loop), capabilities, and **manual** element registrations. Calls `build()` to create the `Server` instance. -* **`Configuration`:** A value object holding the resolved configuration and dependencies. -* **`Server`:** The central object holding the configured state and core logic components (`Registry`, `Protocol`, `Configuration`). It's transport-agnostic. Provides methods to `discover()` elements and `listen()` via a specific transport. -* **`Protocol`:** Internal bridge listening to transport events and processes JSON-RPC messages from the transport. -* **`Registry`:** Stores MCP element definitions. **Distinguishes between manually registered and discovered elements.** Handles optional caching of *discovered* elements only. Loads cached discovered elements upon instantiation if available. -* **`ServerTransportInterface`:** Event-driven interface for server-side transports (`StdioServerTransport`, `HttpServerTransport`). Handles communication, emits events. +```php +// In-memory sessions (default, not persistent) +->withSession('array', 3600) + +// Cache-backed sessions (persistent across restarts) +->withSession('cache', 7200) + +// Custom session handler (implement SessionHandlerInterface) +->withSessionHandler(new MyCustomSessionHandler(), 1800) +``` + +## ๐ŸŽฏ Defining MCP Elements -## Defining MCP Elements +The server provides two powerful ways to define MCP elements: **Attribute-Based Discovery** (recommended) and **Manual Registration**. Both can be combined, with manual registrations taking precedence. -Expose your application's functionality as MCP Tools, Resources, or Prompts using attributes or manual registration. +### Element Types -### 1. Using Attributes (`#[Mcp*]`) +- **๐Ÿ”ง Tools**: Executable functions/actions (e.g., `calculate`, `send_email`, `query_database`) +- **๐Ÿ“„ Resources**: Static content/data (e.g., `config://settings`, `file://readme.txt`) +- **๐Ÿ“‹ Resource Templates**: Dynamic resources with URI patterns (e.g., `user://{id}/profile`) +- **๐Ÿ’ฌ Prompts**: Conversation starters/templates (e.g., `summarize`, `translate`) -Decorate public, non-static methods or invokable classes with `#[Mcp*]` attributes to mark them as MCP Elements. After building the server, you **must** call `$server->discover(...)` at least once with the correct paths to find and register these elements. It will also cache the discovered elements if set, so that you can skip discovery on subsequent runs. +### 1. ๐Ÿท๏ธ Attribute-Based Discovery (Recommended) + +Use PHP 8 attributes to mark methods or invokable classes as MCP elements. The server will discover them via filesystem scanning. ```php -$server = ServerBuilder::make()->...->build(); -// Scan 'src/Handlers' relative to the project root -$server->discover(basePath: __DIR__, scanDirs: ['src/Handlers']); +use PhpMcp\Server\Attributes\{McpTool, McpResource, McpResourceTemplate, McpPrompt}; + +class UserManager +{ + /** + * Creates a new user account. + */ + #[McpTool(name: 'create_user')] + public function createUser(string $email, string $password, string $role = 'user'): array + { + // Create user logic + return ['id' => 123, 'email' => $email, 'role' => $role]; + } + + /** + * Get user configuration. + */ + #[McpResource( + uri: 'config://user/settings', + mimeType: 'application/json' + )] + public function getUserConfig(): array + { + return ['theme' => 'dark', 'notifications' => true]; + } + + /** + * Get user profile by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId): array + { + return ['id' => $userId, 'name' => 'John Doe']; + } + + /** + * Generate welcome message prompt. + */ + #[McpPrompt(name: 'welcome_user')] + public function welcomeUserPrompt(string $username, string $role): array + { + return [ + ['role' => 'user', 'content' => "Create a welcome message for {$username} with role {$role}"] + ]; + } +} ``` -Attributes: +**Discovery Process:** + +```php +// Build server first +$server = Server::make() + ->withServerInfo('My App Server', '1.0.0') + ->build(); + +// Then discover elements +$server->discover( + basePath: __DIR__, + scanDirs: ['src/Handlers', 'src/Services'], // Directories to scan + excludeDirs: ['src/Tests'], // Directories to skip + saveToCache: true // Cache results (default: true) +); +``` -* **`#[McpTool(name?, description?)`**: Defines an action. Parameters/return types/DocBlocks define the MCP schema. Use on public, non-static methods or invokable classes. -* **`#[McpResource(uri, name?, description?, mimeType?, size?, annotations?)]`**: Defines a static resource instance. Use on public, non-static methods or invokable classes. Method returns resource content. -* **`#[McpResourceTemplate(uriTemplate, name?, description?, mimeType?, annotations?)]`**: Defines a handler for templated URIs (e.g., `item://{id}`). Use on public, non-static methods or invokable classes. Method parameters must match template variables. Method returns content for the resolved instance. -* **`#[McpPrompt(name?, description?)`**: Defines a prompt generator. Use on public, non-static methods or invokable classes. Method parameters are prompt arguments. Method returns prompt messages. +**Available Attributes:** -*(See [Attribute Details](#attribute-details-and-return-formatting) below for more on parameters and return value formatting)* +- **`#[McpTool]`**: Executable actions +- **`#[McpResource]`**: Static content accessible via URI +- **`#[McpResourceTemplate]`**: Dynamic resources with URI templates +- **`#[McpPrompt]`**: Conversation templates and prompt generators -### 2. Manual Registration (`ServerBuilder->with*`) +### 2. ๐Ÿ”ง Manual Registration -Use `withTool`, `withResource`, `withResourceTemplate`, `withPrompt` on the `ServerBuilder` *before* calling `build()`. +Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration or when you prefer explicit control. ```php -use App\Handlers\MyToolHandler; -use App\Handlers\MyResourceHandler; +use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler}; +use PhpMcp\Schema\{ToolAnnotations, Annotations}; $server = Server::make() - ->withServerInfo(...) + ->withServerInfo('Manual Registration Server', '1.0.0') + + // Register a tool with handler method ->withTool( - [MyToolHandler::class, 'processData'], // Handler: [class, method] - 'data_processor' // MCP Name (Optional) + [EmailHandler::class, 'sendEmail'], // Handler: [class, method] + name: 'send_email', // Tool name (optional) + description: 'Send email to user', // Description (optional) + annotations: ToolAnnotations::make( // Annotations (optional) + title: 'Send Email Tool' + ) ) + + // Register invokable class as tool + ->withTool(UserHandler::class) // Handler: Invokable class + + // Register a resource ->withResource( - MyResourceHandler::class, // Handler: Invokable class - 'config://app/name' // URI (Required) + [ConfigHandler::class, 'getConfig'], + uri: 'config://app/settings', // URI (required) + mimeType: 'application/json' // MIME type (optional) + ) + + // Register a resource template + ->withResourceTemplate( + [UserHandler::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile' // URI template (required) ) - // ->withResourceTemplate(...) - // ->withPrompt(...) + + // Register a prompt + ->withPrompt( + [PromptHandler::class, 'generateSummary'], + name: 'summarize_text' // Prompt name (optional) + ) + ->build(); ``` -* **Handlers:** Can be `[ClassName::class, 'methodName']` or `InvokableHandler::class`. Dependencies are resolved via the configured PSR-11 Container. -* Metadata (name, description) is inferred from the handler if not provided explicitly. -* These elements are registered **immediately** when `build()` is called. -* They are **never cached** by the Registry's caching mechanism. -* They are **not removed** when `$registry->clearDiscoveredElements()` is called (e.g., at the start of `$server->discover()`). - -### Precedence: Manual vs. Discovered/Cached - -If an element is registered both manually (via the builder) and is also found via attribute discovery (or loaded from cache) with the **same identifying key** (tool name, resource URI, prompt name, template URI): +**Key Features:** -* **The manually registered element always takes precedence.** -* The discovered/cached version will be ignored, and a debug message will be logged. +- **Handler Formats**: Use `[ClassName::class, 'methodName']` or `InvokableClass::class` +- **Dependency Injection**: Handlers resolved via configured PSR-11 container +- **Immediate Registration**: Elements registered when `build()` is called +- **No Caching**: Manual elements are never cached (always fresh) +- **Precedence**: Manual registrations override discovered elements with same identifier -This ensures explicit manual configuration overrides any potentially outdated discovered or cached definitions. +### ๐Ÿ† Element Precedence & Discovery -## Discovery and Caching +**Precedence Rules:** +- Manual registrations **always** override discovered/cached elements with the same identifier +- Discovered elements are cached for performance (configurable) +- Cache is automatically invalidated on fresh discovery runs -Attribute discovery is an **explicit step** performed on a built `Server` instance. +**Discovery Process:** -* **`$server->discover(string $basePath, array $scanDirs = [...], array $excludeDirs = [...], bool $force = false, bool $saveToCache = true)`** - * `$basePath`, `$scanDirs`, `$excludeDirs`: Define where to scan. - * `$force`: If `true`, forces a re-scan even if discovery ran earlier in the same script execution. Default is `false`. - * `$saveToCache`: If `true` (default) and a PSR-16 cache was provided to the builder, the results of *this scan* (discovered elements only) will be saved to the cache, overwriting previous cache content. If `false` or no cache is configured, results are not saved. -* **Default Behavior:** Calling `discover()` performs a fresh scan. It first clears previously discovered items from the cache `$saveToCache` is true), then scans the filesystem, registers found elements (marking them as discovered), and finally saves the newly discovered elements to cache if `$saveToCache` is true. -* **Implicit Cache Loading:** When `ServerBuilder::build()` creates the `Registry`, the `Registry` constructor automatically attempts to load *discovered* elements from the cache (if a cache was configured and the cache key exists). Manually registered elements are added *after* this potential cache load. -* **Cache Content:** Only elements found via discovery are stored in the cache. Manually registered elements are never cached. - -## Configuration (`ServerBuilder`) - -You can get a server builder instance by either calling `new ServerBuilder` or more conveniently using `Server::make()`. The available methods for configuring your server instance include: +```php +$server->discover( + basePath: __DIR__, + scanDirs: ['src/Handlers', 'src/Services'], // Scan these directories + excludeDirs: ['tests', 'vendor'], // Skip these directories + force: false, // Force re-scan (default: false) + saveToCache: true // Save to cache (default: true) +); +``` -* **`withServerInfo(string $name, string $version)`**: **Required.** Server identity. -* **`withLogger(LoggerInterface $logger)`**: Optional. PSR-3 logger. Defaults to `NullLogger`. -* **`withCache(CacheInterface $cache, int $ttl = 3600)`**: Optional. PSR-16 cache for registry and client state. Defaults to `ArrayCache` only for the client state manager. -* **`withContainer(ContainerInterface $container)`**: Optional. PSR-11 container for resolving *your handler classes*. Defaults to `BasicContainer`. -* **`withLoop(LoopInterface $loop)`**: Optional. ReactPHP event loop. Defaults to `Loop::get()`. -* **`withCapabilities(Capabilities $capabilities)`**: Optional. Configure advertised capabilities (e.g., resource subscriptions). Use `Capabilities::forServer(...)`. -* **`withPaginationLimit(int $paginationLimit)`: Optional. Configures the server's pagination limit for list requests. -* `withTool(...)`, `withResource(...)`, etc.: Optional manual registration. +**Caching Behavior:** +- Only **discovered** elements are cached (never manual registrations) +- Cache loaded automatically during `build()` if available +- Fresh `discover()` calls clear and rebuild cache +- Use `force: true` to bypass discovery-already-ran check -## Running the Server (Transports) +## ๐Ÿš€ Running the Server (Transports) -The core `Server` object doesn't handle network I/O directly. You activate it using a specific transport implementation passed to `$server->listen($transport)`. +The server core is transport-agnostic. Choose a transport based on your deployment needs: -### Stdio Transport +### 1. ๐Ÿ“Ÿ Stdio Transport -Handles communication over Standard Input/Output. Ideal for servers launched directly by an MCP client (like Cursor). +**Best for**: Direct client execution, command-line tools, simple deployments ```php use PhpMcp\Server\Transports\StdioServerTransport; -// ... build $server ... +$server = Server::make() + ->withServerInfo('Stdio Server', '1.0.0') + ->build(); + +$server->discover(__DIR__, ['src']); +// Create stdio transport (uses STDIN/STDOUT by default) $transport = new StdioServerTransport(); -// This blocks until the transport is closed (e.g., SIGINT/SIGTERM) +// Start listening (blocking call) $server->listen($transport); ``` -> **Warning:** When using `StdioServerTransport`, your application code (including tool/resource handlers) **MUST NOT** write arbitrary output to `STDOUT` (using `echo`, `print`, `var_dump`, etc.). `STDOUT` is reserved for sending framed JSON-RPC messages back to the client. Use `STDERR` for logging or debugging output: -> ```php -> fwrite(STDERR, "Debug: Processing tool X\n"); -> // Or use a PSR-3 logger configured to write to STDERR: -> // $logger->debug("Processing tool X", ['param' => $value]); -> ``` +**Client Configuration:** +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` -### HTTP Transport (HTTP+SSE) +> โš ๏ธ **Important**: When using stdio transport, **never** write to `STDOUT` in your handlers (use `STDERR` for debugging). `STDOUT` is reserved for JSON-RPC communication. -Listens for HTTP connections, handling client messages via POST and sending server messages/notifications via Server-Sent Events (SSE). +### 2. ๐ŸŒ HTTP + Server-Sent Events Transport (Deprecated) + +> โš ๏ธ **Note**: This transport is deprecated in the latest MCP protocol version but remains available for backwards compatibility. For new projects, use the [StreamableHttpServerTransport](#3--streamable-http-transport-new) which provides enhanced features and better protocol compliance. + +**Best for**: Legacy applications requiring backwards compatibility ```php use PhpMcp\Server\Transports\HttpServerTransport; -// ... build $server ... +$server = Server::make() + ->withServerInfo('HTTP Server', '1.0.0') + ->withLogger($logger) // Recommended for HTTP + ->build(); + +$server->discover(__DIR__, ['src']); +// Create HTTP transport $transport = new HttpServerTransport( - host: '127.0.0.1', // Listen on all interfaces - port: 8080, // Port to listen on - mcpPathPrefix: 'mcp' // Base path for endpoints (/mcp/sse, /mcp/message) - // sslContext: [...] // Optional: ReactPHP socket context for HTTPS + host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 + port: 8080, // Port number + mcpPathPrefix: 'mcp' // URL prefix (/mcp/sse, /mcp/message) ); -// This blocks, starting the HTTP server and running the event loop $server->listen($transport); ``` -**Concurrency Requirement:** The `HttpServerTransport` relies on ReactPHP's non-blocking I/O model. It's designed to handle multiple concurrent SSE connections efficiently. Running this transport requires a PHP environment that supports an event loop and non-blocking operations. **It will generally NOT work correctly with traditional synchronous web servers like Apache+mod_php or the built-in PHP development server.** You should run the `listen()` command using the PHP CLI in a persistent process (potentially managed by Supervisor, Docker, etc.). +**Client Configuration:** +```json +{ + "mcpServers": { + "my-http-server": { + "url": "http://localhost:8080/mcp/sse" + } + } +} +``` **Endpoints:** -* **SSE:** `GET /{mcpPathPrefix}/sse` (e.g., `GET /mcp/sse`) - Client connects here. -* **Messages:** `POST /{mcpPathPrefix}/message?clientId={clientId}` (e.g., `POST /mcp/message?clientId=sse_abc123`) - Client sends requests here. The `clientId` query parameter is essential for the server to route the message correctly to the state associated with the SSE connection. The server sends the POST path (including the generated `clientId`) via the initial `endpoint` SSE event to the client, so you will never have to manually handle this. +- **SSE Connection**: `GET /mcp/sse` +- **Message Sending**: `POST /mcp/message?clientId={clientId}` -## Connecting MCP Clients +### 3. ๐Ÿ”„ Streamable HTTP Transport (Recommended) -Instruct clients how to connect to your server: +**Best for**: Production deployments, remote MCP servers, multiple clients, resumable connections -* **`stdio`:** Provide the full command to execute your server script (e.g., `php /path/to/mcp-server.php`). The client needs execute permissions. -* **`http`:** Provide the full URL to your SSE endpoint (e.g., `http://your.domain:8080/mcp/sse`). Ensure the server process running `listen()` is accessible. +```php +use PhpMcp\Server\Transports\StreamableHttpServerTransport; -Refer to specific client documentation (Cursor, Claude Desktop, etc.) for their configuration format. +$server = Server::make() + ->withServerInfo('Streamable Server', '1.0.0') + ->withLogger($logger) + ->withCache($cache) // Required for resumability + ->build(); -## Attribute Details & Return Formatting {#attribute-details-and-return-formatting} +$server->discover(__DIR__, ['src']); -These attributes mark classes or methods to be found by the `->discover()` process. +// Create streamable transport with resumability +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 + port: 8080, + mcpPathPrefix: 'mcp', + enableJsonResponse: false // Use SSE streaming (default) +); -#### `#[McpTool]` +$server->listen($transport); +``` -Marks a method **or an invokable class** as an MCP Tool. Tools represent actions or functions the client can invoke, often with parameters. +**JSON Response Mode:** -**Usage:** +The `enableJsonResponse` option controls how responses are delivered: -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The `__invoke` method will be treated as the tool's handler. +- **`false` (default)**: Uses Server-Sent Events (SSE) streams for responses. Best for tools that may take time to process. +- **`true`**: Returns immediate JSON responses without opening SSE streams. Use this when your tools execute quickly and don't need streaming. -The attribute accepts the following parameters: +```php +// For fast-executing tools, enable JSON mode +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', + port: 8080, + enableJsonResponse: true // Immediate JSON responses +); +``` -* `name` (optional): The name of the tool exposed to the client. - * When on a method, defaults to the method name (e.g., `addNumbers` becomes `addNumbers`). - * When on an invokable class, defaults to the class's short name (e.g., `class AdderTool` becomes `AdderTool`). -* `description` (optional): A description for the tool. Defaults to the method's DocBlock summary (or the `__invoke` method's summary if on a class). +**Features:** +- **Resumable connections** - clients can reconnect and replay missed events +- **Event sourcing** - all events are stored for replay +- **JSON mode** - optional JSON-only responses for fast tools +- **Enhanced session management** - persistent session state +- **Multiple client support** - designed for concurrent clients -The parameters (including name, type hints, and defaults) of the target method (or `__invoke`) define the tool's input schema. The return type hint defines the output schema. DocBlock `@param` and `@return` descriptions are used for parameter/output descriptions. +## ๐Ÿ“‹ Schema Generation and Validation -**Return Value Formatting** +The server automatically generates JSON schemas for tool parameters using a sophisticated priority system that combines PHP type hints, docblock information, and the optional `#[Schema]` attribute. These generated schemas are used both for input validation and for providing schema information to MCP clients. -The value returned by your method determines the content sent back to the client. The library automatically formats common types: +### Schema Generation Priority -* `null`: Returns empty content (if return type hint is `void`) or `TextContent` with `(null)`. -* `string`, `int`, `float`, `bool`: Automatically wrapped in `PhpMcp\Server\JsonRpc\Contents\TextContent`. -* `array`, `object`: Automatically JSON-encoded (pretty-printed) and wrapped in `TextContent`. -* `PhpMcp\Server\JsonRpc\Contents\Content` object(s): If you return an instance of `Content` (e.g., `TextContent`, `ImageContent`, `AudioContent`, `ResourceContent`) or an array of `Content` objects, they are used directly. This gives you full control over the output format. *Example:* `return TextContent::code('echo \'Hello\';', 'php');` -* Exceptions: If your method throws an exception, a `TextContent` containing the error message and type is returned. +The server follows this order of precedence when generating schemas: -The method's return type hint (`@return` tag in DocBlock) is used to generate the tool's output schema, but the actual formatting depends on the *value* returned at runtime. +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest precedence) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific schema enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide schema configuration +4. **PHP type hints + docblocks** - Automatic inference from code (lowest precedence) -**Schema Generation** +When a `definition` is provided in the Schema attribute, all automatic inference is bypassed and the complete definition is used as-is. -The server automatically generates JSON Schema for tool parameters based on: +### Parameter-Level Schema Attributes -1. PHP type hints -2. DocBlock annotations -3. Schema attributes (for enhanced validation) +```php +use PhpMcp\Server\Attributes\{McpTool, Schema}; + +#[McpTool(name: 'validate_user')] +public function validateUser( + #[Schema(format: 'email')] // PHP already knows it's string + string $email, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized name' + )] + string $name, + + #[Schema(minimum: 18, maximum: 120)] // PHP already knows it's integer + int $age +): bool { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; +} +``` -**Examples:** +### Method-Level Schema ```php /** - * Fetches user details by ID. - * - * @param int $userId The ID of the user to fetch. - * @param bool $includeEmail Include the email address? - * @return array{id: int, name: string, email?: string} User details. + * Process user data with nested validation. */ -#[McpTool(name: 'get_user')] -public function getUserById(int $userId, bool $includeEmail = false): array +#[McpTool(name: 'create_user')] +#[Schema( + properties: [ + 'profile' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'age' => ['type' => 'integer', 'minimum' => 18], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['profile'] +)] +public function createUser(array $userData): array +{ + // PHP type hint provides base 'array' type + // Method-level Schema adds object structure validation + return ['id' => 123, 'status' => 'created']; +} +``` + +### Complete Schema Override (Method-Level Only) + +```php +#[McpTool(name: 'process_api_request')] +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => ['type' => 'string', 'format' => 'uri'], + 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], + 'headers' => [ + 'type' => 'object', + 'patternProperties' => [ + '^[A-Za-z0-9-]+$' => ['type' => 'string'] + ] + ] + ], + 'required' => ['endpoint', 'method'] +])] +public function processApiRequest(string $endpoint, string $method, array $headers): array { - // ... implementation returning an array ... + // PHP type hints are completely ignored when definition is provided + // The schema definition above takes full precedence + return ['status' => 'processed', 'endpoint' => $endpoint]; } +``` -/** - * Process user data with nested structures. - * - * @param array{name: string, contact: array{email: string, phone?: string}} $userData - * @param string[] $tags Tags associated with the user - * @return array{success: bool, message: string} - */ -#[McpTool] -public function processUserData(array $userData, array $tags): array { - // Implementation +> โš ๏ธ **Important**: Complete schema definition override should rarely be used. It bypasses all automatic schema inference and requires you to define the entire JSON schema manually. Only use this if you're well-versed with JSON Schema specification and have complex validation requirements that cannot be achieved through the priority system. In most cases, parameter-level and method-level `#[Schema]` attributes provide sufficient flexibility. + +## ๐ŸŽจ Return Value Formatting + +The server automatically formats return values from your handlers into appropriate MCP content types: + +### Automatic Formatting + +```php +// Simple values are auto-wrapped in TextContent +public function getString(): string { return "Hello World"; } // โ†’ TextContent +public function getNumber(): int { return 42; } // โ†’ TextContent +public function getBool(): bool { return true; } // โ†’ TextContent +public function getArray(): array { return ['key' => 'value']; } // โ†’ TextContent (JSON) + +// Null handling +public function getNull(): ?string { return null; } // โ†’ TextContent("(null)") +public function returnVoid(): void { /* no return */ } // โ†’ Empty content +``` + +### Advanced Content Types + +```php +use PhpMcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceContent}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'File content here', + 'mimeType' => 'text/plain' + ]; - #[Schema(minItems: 2, uniqueItems: true)] - array $tags -): bool { - // Implementation + // Or for binary data: + // return [ + // 'blob' => base64_encode($binaryData), + // 'mimeType' => 'application/octet-stream' + // ]; } ``` -The Schema attribute adds JSON Schema constraints like string formats, numeric ranges, array constraints, and object property validations. +## ๐Ÿ”„ Batch Processing -#### `#[McpResource]` +The server automatically handles JSON-RPC batch requests: -Marks a method **or an invokable class** as representing a specific, static MCP Resource instance. Resources represent pieces of content or data identified by a URI. The target method (or `__invoke`) will typically be called when a client performs a `resources/read` for the specified URI. +```php +// Client can send multiple requests in a single HTTP call: +[ + {"jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": {...}}, + {"jsonrpc": "2.0", "method": "notifications/ping"}, // notification + {"jsonrpc": "2.0", "id": "2", "method": "tools/call", "params": {...}} +] + +// Server returns batch response (excluding notifications): +[ + {"jsonrpc": "2.0", "id": "1", "result": {...}}, + {"jsonrpc": "2.0", "id": "2", "result": {...}} +] +``` + +## ๐Ÿ”ง Advanced Features -**Usage:** +### Completion Providers -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The `__invoke` method will be treated as the resource handler. +Completion providers enable MCP clients to offer auto-completion suggestions in their user interfaces. They are specifically designed for **Resource Templates** and **Prompts** to help users discover available options for dynamic parts like template variables or prompt arguments. -The attribute accepts the following parameters: +> **Note**: Tools and resources can be discovered via standard MCP commands (`tools/list`, `resources/list`), so completion providers are not needed for them. Completion providers are used only for resource templates (URI variables) and prompt arguments. -* `uri` (required): The unique URI for this resource instance (e.g., `config://app/settings`, `file:///data/status.txt`). Must conform to [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -* `name` (optional): Human-readable name. Defaults inferred from method name or class short name. -* `description` (optional): Description. Defaults to DocBlock summary of the method or `__invoke`. -* `mimeType` (optional): The resource's MIME type (e.g., `text/plain`, `application/json`). -* `size` (optional): Resource size in bytes, if known and static. -* `annotations` (optional): Array of MCP annotations (e.g., `['audience' => ['user']]`). +Completion providers must implement the `CompletionProviderInterface`: + +```php +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider}; + +class UserIdCompletionProvider implements CompletionProviderInterface +{ + public function getCompletions(string $currentValue, SessionInterface $session): array + { + // Return completion suggestions based on current input + $allUsers = ['user_1', 'user_2', 'user_3', 'admin_user']; + + // Filter based on what user has typed so far + return array_filter($allUsers, fn($user) => str_starts_with($user, $currentValue)); + } +} + +class UserService +{ + #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] + public function getUserProfile( + #[CompletionProvider(UserIdCompletionProvider::class)] + string $userId + ): array { + // Always validate input even with completion providers + // Users can still pass any value regardless of completion suggestions + if (!$this->isValidUserId($userId)) { + throw new \InvalidArgumentException('Invalid user ID provided'); + } + + return ['id' => $userId, 'name' => 'John Doe']; + } +} +``` -The target method (or `__invoke`) should return the content of the resource. +> **Important**: Completion providers only offer suggestions to users in the MCP client interface. Users can still input any value, so always validate parameters in your handlers regardless of completion provider constraints. -**Return Value Formatting** +### Custom Dependency Injection -The return value determines the resource content: +Your MCP element handlers can use constructor dependency injection to access services like databases, APIs, or other business logic. When handlers have constructor dependencies, you must provide a pre-configured PSR-11 container that contains those dependencies. -* `string`: Treated as text content. MIME type is taken from the attribute or guessed (`text/plain`, `application/json`, `text/html`). -* `array`: If the attribute's `mimeType` is `application/json` (or contains `json`), the array is JSON-encoded. Otherwise, it attempts JSON encoding with a warning. -* `stream resource`: Content is read from the stream. `mimeType` must be provided in the attribute or defaults to `application/octet-stream`. -* `SplFileInfo` object: Content is read from the file. `mimeType` is taken from the attribute or guessed. -* `PhpMcp\Server\JsonRpc\Contents\EmbeddedResource`: Used directly. Gives full control over URI, MIME type, text/blob content. -* `PhpMcp\Server\JsonRpc\Contents\ResourceContent`: The inner `EmbeddedResource` is extracted and used. -* `array{'blob': string, 'mimeType'?: string}`: Creates a blob resource. -* `array{'text': string, 'mimeType'?: string}`: Creates a text resource. +By default, the server uses a `BasicContainer` - a simple implementation that attempts to auto-wire dependencies by instantiating classes with parameterless constructors. For dependencies that require configuration (like database connections), you can either manually add them to the BasicContainer or use a more advanced PSR-11 container like PHP-DI or Laravel's container. ```php -#[McpResource(uri: 'status://system/load', mimeType: 'text/plain')] -public function getSystemLoad(): string +use Psr\Container\ContainerInterface; + +class DatabaseService { - return file_get_contents('/proc/loadavg'); + public function __construct(private \PDO $pdo) {} + + #[McpTool(name: 'query_users')] + public function queryUsers(): array + { + $stmt = $this->pdo->query('SELECT * FROM users'); + return $stmt->fetchAll(); + } } -/** - * An invokable class providing system load resource. - */ -#[McpResource(uri: 'status://system/load/invokable', mimeType: 'text/plain')] -class SystemLoadResource { - public function __invoke(): string { - return file_get_contents('/proc/loadavg'); - } +// Option 1: Use the basic container and manually add dependencies +$basicContainer = new \PhpMcp\Server\Defaults\BasicContainer(); +$basicContainer->set(\PDO::class, new \PDO('sqlite::memory:')); + +// Option 2: Use any PSR-11 compatible container (PHP-DI, Laravel, etc.) +$container = new \DI\Container(); +$container->set(\PDO::class, new \PDO('mysql:host=localhost;dbname=app', $user, $pass)); + +$server = Server::make() + ->withContainer($basicContainer) // Handlers get dependencies auto-injected + ->build(); +``` + +### Resource Subscriptions + +```php +use PhpMcp\Schema\ServerCapabilities; + +$server = Server::make() + ->withCapabilities(ServerCapabilities::make( + resourcesSubscribe: true, // Enable resource subscriptions + prompts: true, + tools: true + )) + ->build(); + +// In your resource handler, you can notify clients of changes: +#[McpResource(uri: 'file://config.json')] +public function getConfig(): array +{ + // When config changes, notify subscribers + $this->notifyResourceChange('file://config.json'); + return ['setting' => 'value']; } ``` -#### `#[McpResourceTemplate]` +### Resumability and Event Store -Marks a method **or an invokable class** that can generate resource instances based on a template URI. This is useful for resources whose URI contains variable parts (like user IDs or document IDs). The target method (or `__invoke`) will be called when a client performs a `resources/read` matching the template. +For production deployments using `StreamableHttpServerTransport`, you can implement resumability with event sourcing by providing a custom event store: -**Usage:** +```php +use PhpMcp\Server\Contracts\EventStoreInterface; +use PhpMcp\Server\Defaults\InMemoryEventStore; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. +// Use the built-in in-memory event store (for development/testing) +$eventStore = new InMemoryEventStore(); -The attribute accepts the following parameters: +// Or implement your own persistent event store +class DatabaseEventStore implements EventStoreInterface +{ + public function storeEvent(string $streamId, string $message): string + { + // Store event in database and return unique event ID + return $this->database->insert('events', [ + 'stream_id' => $streamId, + 'message' => $message, + 'created_at' => now() + ]); + } -* `uriTemplate` (required): The URI template string, conforming to [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) (e.g., `user://{userId}/profile`, `document://{docId}?format={fmt}`). -* `name`, `description`, `mimeType`, `annotations` (optional): Similar to `#[McpResource]`, but describe the template itself. Defaults inferred from method/class name and DocBlocks. + public function replayEventsAfter(string $lastEventId, callable $sendCallback): void + { + // Replay events for resumability + $events = $this->database->getEventsAfter($lastEventId); + foreach ($events as $event) { + $sendCallback($event['id'], $event['message']); + } + } +} -The parameters of the target method (or `__invoke`) *must* match the variables defined in the `uriTemplate`. The method should return the content for the resolved resource instance. +// Configure transport with event store +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', + port: 8080, + eventStore: new DatabaseEventStore() // Enable resumability +); +``` -**Return Value Formatting** +### Custom Session Handlers -Same as `#[McpResource]` (see above). The returned value represents the content of the *resolved* resource instance. +Implement custom session storage by creating a class that implements `SessionHandlerInterface`: ```php -/** - * Gets a user's profile data. - * - * @param string $userId The user ID from the URI. - * @return array The user profile. - */ -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile', name: 'user_profile', mimeType: 'application/json')] -public function getUserProfile(string $userId): array +use PhpMcp\Server\Contracts\SessionHandlerInterface; + +class DatabaseSessionHandler implements SessionHandlerInterface { - // Fetch user profile for $userId - return ['id' => $userId, /* ... */ ]; -} + public function __construct(private \PDO $db) {} -/** - * An invokable class providing user profiles via template. - */ -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile/invokable', name: 'user_profile_invokable', mimeType: 'application/json')] -class UserProfileTemplate { - /** - * Gets a user's profile data. - * @param string $userId The user ID from the URI. - * @return array The user profile. - */ - public function __invoke(string $userId): array { - // Fetch user profile for $userId - return ['id' => $userId, 'source' => 'invokable', /* ... */ ]; + public function read(string $id): string|false + { + $stmt = $this->db->prepare('SELECT data FROM sessions WHERE id = ?'); + $stmt->execute([$id]); + $session = $stmt->fetch(\PDO::FETCH_ASSOC); + return $session ? $session['data'] : false; + } + + public function write(string $id, string $data): bool + { + $stmt = $this->db->prepare( + 'INSERT OR REPLACE INTO sessions (id, data, updated_at) VALUES (?, ?, ?)' + ); + return $stmt->execute([$id, $data, time()]); + } + + public function destroy(string $id): bool + { + $stmt = $this->db->prepare('DELETE FROM sessions WHERE id = ?'); + return $stmt->execute([$id]); + } + + public function gc(int $maxLifetime): array + { + $cutoff = time() - $maxLifetime; + $stmt = $this->db->prepare('DELETE FROM sessions WHERE updated_at < ?'); + $stmt->execute([$cutoff]); + return []; // Return array of cleaned session IDs if needed } } + +// Use custom session handler +$server = Server::make() + ->withSessionHandler(new DatabaseSessionHandler(), 3600) + ->build(); ``` -#### `#[McpPrompt]` +### SSL Context Configuration -Marks a method **or an invokable class** as an MCP Prompt generator. Prompts are pre-defined templates or functions that generate conversational messages (like user or assistant turns) based on input parameters. +For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options: -**Usage:** +```php +$sslContext = [ + 'ssl' => [ + 'local_cert' => '/path/to/certificate.pem', + 'local_pk' => '/path/to/private-key.pem', + 'verify_peer' => false, + 'allow_self_signed' => true, + ] +]; + +$transport = new StreamableHttpServerTransport( + host: '0.0.0.0', + port: 8443, + sslContext: $sslContext +); +``` -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. +> **SSL Context Reference**: For complete SSL context options, see the [PHP SSL Context Options documentation](https://www.php.net/manual/en/context.ssl.php). +## ๐Ÿ” Error Handling & Debugging -The attribute accepts the following parameters: +The server provides comprehensive error handling and debugging capabilities: -* `name` (optional): The prompt name. Defaults to method name or class short name. -* `description` (optional): Description. Defaults to DocBlock summary of the method or `__invoke`. +### Exception Handling -Method parameters (or `__invoke` parameters) define the prompt's input arguments. The method should return the prompt content, typically an array conforming to the MCP message structure. +Tool handlers can throw any PHP exception when errors occur. The server automatically converts these exceptions into proper JSON-RPC error responses for MCP clients. -**Return Value Formatting** +```php +#[McpTool(name: 'divide_numbers')] +public function divideNumbers(float $dividend, float $divisor): float +{ + if ($divisor === 0.0) { + // Any exception with descriptive message will be sent to client + throw new \InvalidArgumentException('Division by zero is not allowed'); + } + + return $dividend / $divisor; +} -Your method should return the prompt messages in one of these formats: +#[McpTool(name: 'calculate_factorial')] +public function calculateFactorial(int $number): int +{ + if ($number < 0) { + throw new \InvalidArgumentException('Factorial is not defined for negative numbers'); + } + + if ($number > 20) { + throw new \OverflowException('Number too large, factorial would cause overflow'); + } + + // Implementation continues... + return $this->factorial($number); +} +``` + +The server will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand and display to users. -* **Array of `PhpMcp\Server\JsonRpc\Contents\PromptMessage` objects**: The recommended way for full control. - * `PromptMessage::user(string|Content $content)` - * `PromptMessage::assistant(string|Content $content)` - * The `$content` can be a simple string (becomes `TextContent`) or any `Content` object (`TextContent`, `ImageContent`, `ResourceContent`, etc.). -* **Simple list array:** `[['role' => 'user', 'content' => 'Some text'], ['role' => 'assistant', 'content' => $someContentObject]]` - * `role` must be `'user'` or `'assistant'`. - * `content` can be a string (becomes `TextContent`) or a `Content` object. - * `content` can also be an array structure like `['type' => 'image', 'data' => '...', 'mimeType' => '...']`, `['type' => 'text', 'text' => '...']`, or `['type' => 'resource', 'resource' => ['uri' => '...', 'text|blob' => '...']]`. -* **Simple associative array:** `['user' => 'User prompt text', 'assistant' => 'Optional assistant prefix']` (converted to one or two `PromptMessage`s with `TextContent`). +### Logging and Debugging ```php -/** - * Generates a prompt to summarize text. - * - * @param string $textToSummarize The text to summarize. - * @return array The prompt messages. - */ -#[McpPrompt(name: 'summarize')] -public function generateSummaryPrompt(string $textToSummarize): array +use Psr\Log\LoggerInterface; + +class DebugAwareHandler { - return [ - ['role' => 'user', 'content' => "Summarize the following text:\n\n{$textToSummarize}"], - ]; + public function __construct(private LoggerInterface $logger) {} + + #[McpTool(name: 'debug_tool')] + public function debugTool(string $data): array + { + $this->logger->info('Processing debug tool', ['input' => $data]); + + // For stdio transport, use STDERR for debug output + fwrite(STDERR, "Debug: Processing data length: " . strlen($data) . "\n"); + + return ['processed' => true]; + } } +``` -/** - * An invokable class generating a summary prompt. - */ -#[McpPrompt(name: 'summarize_invokable')] -class SummarizePrompt { - /** - * Generates a prompt to summarize text. - * @param string $textToSummarize The text to summarize. - * @return array The prompt messages. - */ - public function __invoke(string $textToSummarize): array { - return [ - ['role' => 'user', 'content' => "[Invokable] Summarize: +## ๐Ÿš€ Production Deployment -{$textToSummarize}"], - ]; +Since `$server->listen()` runs a persistent process, you can deploy it using any strategy that suits your infrastructure needs. The server can be deployed on VPS, cloud instances, containers, or any environment that supports long-running processes. + +Here are two popular deployment approaches to consider: + +### Option 1: VPS with Supervisor + Nginx (Recommended) + +**Best for**: Most production deployments, cost-effective, full control + +```bash +# 1. Install your application on VPS +git clone https://github.com/yourorg/your-mcp-server.git /var/www/mcp-server +cd /var/www/mcp-server +composer install --no-dev --optimize-autoloader + +# 2. Install Supervisor +sudo apt-get install supervisor + +# 3. Create Supervisor configuration +sudo nano /etc/supervisor/conf.d/mcp-server.conf +``` + +**Supervisor Configuration:** +```ini +[program:mcp-server] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/mcp-server/server.php --transport=http --host=127.0.0.1 --port=8080 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/log/mcp-server.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=3 +``` + +**Nginx Configuration with SSL:** +```nginx +# /etc/nginx/sites-available/mcp-server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name mcp.yourdomain.com; + + # SSL configuration + ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # MCP Server proxy + location / { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Important for SSE connections + proxy_buffering off; + proxy_cache off; + + proxy_pass http://127.0.0.1:8080/; } } + +# Redirect HTTP to HTTPS +server { + listen 80; + listen [::]:80; + server_name mcp.yourdomain.com; + return 301 https://$server_name$request_uri; +} ``` -## Error Handling +**Start Services:** +```bash +# Enable and start supervisor +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start mcp-server:* + +# Enable and start nginx +sudo systemctl enable nginx +sudo systemctl restart nginx + +# Check status +sudo supervisorctl status +``` -The server uses specific exceptions inheriting from `PhpMcp\Server\Exception\McpServerException`. The `Protocol` catches these and `Throwable` during message processing, converting them to appropriate JSON-RPC error responses. Transport-level errors are emitted via the transport's `error` event. +**Client Configuration:** +```json +{ + "mcpServers": { + "my-server": { + "url": "https://mcp.yourdomain.com/mcp" + } + } +} +``` -## Examples +### Option 2: Docker Deployment -See the [`examples/`](./examples/) directory: +**Best for**: Containerized environments, Kubernetes, cloud platforms -* **`01-discovery-stdio-calculator/`**: Basic `stdio` server demonstrating attribute discovery for a simple calculator. -* **`02-discovery-http-userprofile/`**: `http+sse` server using discovery for a user profile service. -* **`03-manual-registration-stdio/`**: `stdio` server showcasing only manual element registration. -* **`04-combined-registration-http/`**: `http+sse` server combining manual and discovered elements, demonstrating precedence. -* **`05-stdio-env-variables/`**: `stdio` server with a tool that uses environment variables passed by the MCP client. -* **`06-custom-dependencies-stdio/`**: `stdio` server showing DI container usage for injecting services into MCP handlers (Task Manager example). -* **`07-complex-tool-schema-http/`**: `http+sse` server with a tool demonstrating complex input schemas (optionals, defaults, enums). +**Production Dockerfile:** +```dockerfile +FROM php:8.3-fpm-alpine -## Testing +# Install system dependencies +RUN apk --no-cache add \ + nginx \ + supervisor \ + && docker-php-ext-enable opcache +# Install PHP extensions for MCP +RUN docker-php-ext-install pdo_mysql pdo_sqlite opcache + +# Create application directory +WORKDIR /var/www/mcp + +# Copy application code +COPY . /var/www/mcp +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/supervisord.conf /etc/supervisord.conf +COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini + +# Install Composer dependencies +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Set permissions +RUN chown -R www-data:www-data /var/www/mcp + +# Expose port +EXPOSE 80 + +# Start supervisor +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] +``` + +**docker-compose.yml:** +```yaml +services: + mcp-server: + build: . + ports: + - "8080:80" + environment: + - MCP_ENV=production + - MCP_LOG_LEVEL=info + volumes: + - ./storage:/var/www/mcp/storage + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Optional: Add database if needed + database: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: secure_password + MYSQL_DATABASE: mcp_server + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + +volumes: + mysql_data: +``` + +### Security Best Practices + +1. **Firewall Configuration:** ```bash +# Only allow necessary ports +sudo ufw allow ssh +sudo ufw allow 80 +sudo ufw allow 443 +sudo ufw deny 8080 # MCP port should not be publicly accessible +sudo ufw enable +``` + +2. **SSL/TLS Setup:** +```bash +# Install Certbot for Let's Encrypt +sudo apt install certbot python3-certbot-nginx + +# Generate SSL certificate +sudo certbot --nginx -d mcp.yourdomain.com +``` + +## ๐Ÿ“š Examples & Use Cases + +Explore comprehensive examples in the [`examples/`](./examples/) directory: + +### Available Examples + +- **`01-discovery-stdio-calculator/`** - Basic stdio calculator with attribute discovery +- **`02-discovery-http-userprofile/`** - HTTP server with user profile management +- **`03-manual-registration-stdio/`** - Manual element registration patterns +- **`04-combined-registration-http/`** - Combining manual and discovered elements +- **`05-stdio-env-variables/`** - Environment variable handling +- **`06-custom-dependencies-stdio/`** - Dependency injection with task management +- **`07-complex-tool-schema-http/`** - Advanced schema validation examples +- **`08-schema-showcase-streamable/`** - Comprehensive schema feature showcase + +### Running Examples + +```bash +# Navigate to an example directory +cd examples/01-discovery-stdio-calculator/ + +# Make the server executable +chmod +x server.php + +# Run the server (or configure it in your MCP client) +./server.php +``` + +## ๐Ÿšง Migration from v2.x + +If migrating from version 2.x, note these key changes: + +### Schema Updates +- Uses `php-mcp/schema` package for DTOs instead of internal classes +- Content types moved to `PhpMcp\Schema\Content\*` namespace +- Updated method signatures for better type safety + +### Session Management +- New session management with multiple backends +- Use `->withSession()` or `->withSessionHandler()` for configuration +- Sessions are now persistent across reconnections (with cache backend) + +### Transport Changes +- New `StreamableHttpServerTransport` with resumability +- Enhanced error handling and event sourcing +- Better batch request processing + +## ๐Ÿงช Testing + +```bash +# Install development dependencies composer install --dev + +# Run the test suite composer test -composer test:coverage # Requires Xdebug + +# Run tests with coverage (requires Xdebug) +composer test:coverage + +# Run code style checks +composer lint ``` -## Contributing +## ๐Ÿค Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## ๐Ÿ“„ License + +The MIT License (MIT). See [LICENSE](LICENSE) for details. + +## ๐Ÿ™ Acknowledgments -Please see [CONTRIBUTING.md](CONTRIBUTING.md). +- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification +- Powered by [ReactPHP](https://reactphp.org/) for async operations +- Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability -## License +--- -The MIT License (MIT). See [LICENSE](LICENSE). +**Ready to build powerful MCP servers with PHP?** Start with our [Quick Start](#-quick-start-stdio-server-with-discovery) guide! ๐Ÿš€ diff --git a/composer.json b/composer.json index 3040a55..4698ebb 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,10 @@ "require": { "php": ">=8.1", "opis/json-schema": "^2.4", + "php-mcp/schema": "^1.0", "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", - "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", @@ -29,6 +30,7 @@ "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", "react/async": "^4.0", + "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php index 59cb20c..51cc47b 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/02-discovery-http-userprofile/McpElements.php @@ -2,6 +2,7 @@ namespace Mcp\HttpUserProfileExample; +use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; @@ -40,8 +41,11 @@ public function __construct(LoggerInterface $logger) description: 'Get profile information for a specific user ID.', mimeType: 'application/json' )] - public function getUserProfile(string $userId): array - { + + public function getUserProfile( + #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + string $userId + ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); if (! isset($this->users[$userId])) { // Throwing an exception that Processor can turn into an error response @@ -87,7 +91,7 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null $user = $this->users[$userId]; $message = "Welcome, {$user['name']}!"; if ($customMessage) { - $message .= ' '.$customMessage; + $message .= ' ' . $customMessage; } // Simulate sending $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); @@ -105,8 +109,11 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null * @throws McpServerException If user not found. */ #[McpPrompt(name: 'generate_bio_prompt')] - public function generateBio(string $userId, string $tone = 'professional'): array - { + public function generateBio( + #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + string $userId, + string $tone = 'professional' + ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (! isset($this->users[$userId])) { throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php new file mode 100644 index 0000000..983494a --- /dev/null +++ b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php @@ -0,0 +1,19 @@ + str_contains($userId, $currentValue)); + + return $filteredUserIds; + } +} diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 35dc495..8912623 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -39,10 +39,13 @@ chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once 'McpElements.php'; +require_once 'UserIdCompletionProvider.php'; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -64,27 +67,24 @@ public function log($level, \Stringable|string $message, array $context = []): v $server = Server::make() ->withServerInfo('HTTP User Profiles', '1.0.0') + ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) ->withLogger($logger) ->withContainer($container) ->build(); $server->discover(__DIR__, ['.']); - $transport = new HttpServerTransport( - host: '127.0.0.1', - port: 8080, - mcpPathPrefix: 'mcp' - ); + $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); + // $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); - } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); - fwrite(STDERR, 'Error: '.$e->getMessage()."\n"); - fwrite(STDERR, 'File: '.$e->getFile().':'.$e->getLine()."\n"); - fwrite(STDERR, $e->getTraceAsString()."\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index 4348920..f8a630f 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -94,8 +94,10 @@ public function log($level, \Stringable|string $message, array $context = []): v $logger->info('Server listener stopped gracefully.'); exit(0); - } catch (\Throwable $e) { - fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php new file mode 100644 index 0000000..899f7fd --- /dev/null +++ b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php @@ -0,0 +1,434 @@ + strtoupper($text), + 'lowercase' => strtolower($text), + 'title' => ucwords(strtolower($text)), + 'sentence' => ucfirst(strtolower($text)), + default => $text + }; + + return [ + 'original' => $text, + 'formatted' => $formatted, + 'length' => strlen($text), + 'format_applied' => $format + ]; + } + + /** + * Performs mathematical operations with numeric constraints. + * + * Demonstrates: METHOD-LEVEL Schema + */ + #[McpTool(name: 'calculate_range')] + #[Schema( + type: 'object', + properties: [ + 'first' => [ + 'type' => 'number', + 'description' => 'First number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000 + ], + 'second' => [ + 'type' => 'number', + 'description' => 'Second number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000 + ], + 'operation' => [ + 'type' => 'string', + 'description' => 'Operation to perform', + 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'] + ], + 'precision' => [ + 'type' => 'integer', + 'description' => 'Decimal precision (must be multiple of 2, between 0-10)', + 'minimum' => 0, + 'maximum' => 10, + 'multipleOf' => 2 + ] + ], + required: ['first', 'second', 'operation'], + )] + public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array + { + fwrite(STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); + + $result = match ($operation) { + 'add' => $first + $second, + 'subtract' => $first - $second, + 'multiply' => $first * $second, + 'divide' => $second != 0 ? $first / $second : null, + 'power' => pow($first, $second), + default => null + }; + + if ($result === null) { + return [ + 'error' => $operation === 'divide' ? 'Division by zero' : 'Invalid operation', + 'inputs' => compact('first', 'second', 'operation', 'precision') + ]; + } + + return [ + 'result' => round($result, $precision), + 'operation' => "$first $operation $second", + 'precision' => $precision, + 'within_bounds' => $result >= 0 && $result <= 1000000 + ]; + } + + /** + * Processes user profile data with object schema validation. + * Demonstrates: object properties, required fields, additionalProperties. + */ + #[McpTool( + name: 'validate_profile', + description: 'Validates and processes user profile data with strict schema requirements.' + )] + public function validateProfile( + #[Schema( + type: 'object', + description: 'User profile information', + properties: [ + 'name' => [ + 'type' => 'string', + 'minLength' => 2, + 'maxLength' => 50, + 'description' => 'Full name' + ], + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'description' => 'Valid email address' + ], + 'age' => [ + 'type' => 'integer', + 'minimum' => 13, + 'maximum' => 120, + 'description' => 'Age in years' + ], + 'role' => [ + 'type' => 'string', + 'enum' => ['user', 'admin', 'moderator', 'guest'], + 'description' => 'User role' + ], + 'preferences' => [ + 'type' => 'object', + 'properties' => [ + 'notifications' => ['type' => 'boolean'], + 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']] + ], + 'additionalProperties' => false + ] + ], + required: ['name', 'email', 'age'], + additionalProperties: true + )] + array $profile + ): array { + fwrite(STDERR, "Validate profile tool called with: " . json_encode($profile) . "\n"); + + $errors = []; + $warnings = []; + + // Additional business logic validation + if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') { + $errors[] = 'Admin role requires age 18 or older'; + } + + if (isset($profile['email']) && !filter_var($profile['email'], FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email format'; + } + + if (!isset($profile['role'])) { + $warnings[] = 'No role specified, defaulting to "user"'; + $profile['role'] = 'user'; + } + + return [ + 'valid' => empty($errors), + 'profile' => $profile, + 'errors' => $errors, + 'warnings' => $warnings, + 'processed_at' => date('Y-m-d H:i:s') + ]; + } + + /** + * Manages a list of items with array constraints. + * Demonstrates: array items, minItems, maxItems, uniqueItems. + */ + #[McpTool( + name: 'manage_list', + description: 'Manages a list of items with size and uniqueness constraints.' + )] + public function manageList( + #[Schema( + type: 'array', + description: 'List of items to manage (2-10 unique strings)', + items: [ + 'type' => 'string', + 'minLength' => 1, + 'maxLength' => 30 + ], + minItems: 2, + maxItems: 10, + uniqueItems: true + )] + array $items, + + #[Schema( + type: 'string', + description: 'Action to perform on the list', + enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long'] + )] + string $action = 'sort' + ): array { + fwrite(STDERR, "Manage list tool called with " . count($items) . " items, action: $action\n"); + + $original = $items; + $processed = $items; + + switch ($action) { + case 'sort': + sort($processed); + break; + case 'reverse': + $processed = array_reverse($processed); + break; + case 'shuffle': + shuffle($processed); + break; + case 'deduplicate': + $processed = array_unique($processed); + break; + case 'filter_short': + $processed = array_filter($processed, fn($item) => strlen($item) <= 10); + break; + case 'filter_long': + $processed = array_filter($processed, fn($item) => strlen($item) > 10); + break; + } + + return [ + 'original_count' => count($original), + 'processed_count' => count($processed), + 'action' => $action, + 'original' => $original, + 'processed' => array_values($processed), // Re-index array + 'stats' => [ + 'average_length' => count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / count($processed), 2) : 0, + 'shortest' => count($processed) > 0 ? min(array_map('strlen', $processed)) : 0, + 'longest' => count($processed) > 0 ? max(array_map('strlen', $processed)) : 0, + ] + ]; + } + + /** + * Generates configuration with format validation. + * Demonstrates: format constraints (date-time, uri, etc). + */ + #[McpTool( + name: 'generate_config', + description: 'Generates configuration with format-validated inputs.' + )] + public function generateConfig( + #[Schema( + type: 'string', + description: 'Application name (alphanumeric with hyphens)', + pattern: '^[a-zA-Z0-9\-]+$', + minLength: 3, + maxLength: 20 + )] + string $appName, + + #[Schema( + type: 'string', + description: 'Valid URL for the application', + format: 'uri' + )] + string $baseUrl, + + #[Schema( + type: 'string', + description: 'Environment type', + enum: ['development', 'staging', 'production'] + )] + string $environment = 'development', + + #[Schema( + type: 'boolean', + description: 'Enable debug mode' + )] + bool $debug = true, + + #[Schema( + type: 'integer', + description: 'Port number (1024-65535)', + minimum: 1024, + maximum: 65535 + )] + int $port = 8080 + ): array { + fwrite(STDERR, "Generate config tool called for app: $appName\n"); + + $config = [ + 'app' => [ + 'name' => $appName, + 'env' => $environment, + 'debug' => $debug, + 'url' => $baseUrl, + 'port' => $port, + ], + 'generated_at' => date('c'), // ISO 8601 format + 'version' => '1.0.0', + 'features' => [ + 'logging' => $environment !== 'production' || $debug, + 'caching' => $environment === 'production', + 'analytics' => $environment === 'production', + 'rate_limiting' => $environment !== 'development', + ] + ]; + + return [ + 'success' => true, + 'config' => $config, + 'validation' => [ + 'app_name_valid' => preg_match('/^[a-zA-Z0-9\-]+$/', $appName) === 1, + 'url_valid' => filter_var($baseUrl, FILTER_VALIDATE_URL) !== false, + 'port_in_range' => $port >= 1024 && $port <= 65535, + ] + ]; + } + + /** + * Processes time-based data with date-time format validation. + * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. + */ + #[McpTool( + name: 'schedule_event', + description: 'Schedules an event with time validation and constraints.' + )] + public function scheduleEvent( + #[Schema( + type: 'string', + description: 'Event title (3-50 characters)', + minLength: 3, + maxLength: 50 + )] + string $title, + + #[Schema( + type: 'string', + description: 'Event start time in ISO 8601 format', + format: 'date-time' + )] + string $startTime, + + #[Schema( + type: 'number', + description: 'Duration in hours (minimum 0.5, maximum 24)', + minimum: 0.5, + maximum: 24, + multipleOf: 0.5 + )] + float $durationHours, + + #[Schema( + type: 'string', + description: 'Event priority level', + enum: ['low', 'medium', 'high', 'urgent'] + )] + string $priority = 'medium', + + #[Schema( + type: 'array', + description: 'List of attendee email addresses', + items: [ + 'type' => 'string', + 'format' => 'email' + ], + maxItems: 20 + )] + array $attendees = [] + ): array { + fwrite(STDERR, "Schedule event tool called: $title at $startTime\n"); + + $start = DateTime::createFromFormat(DateTime::ISO8601, $startTime); + if (!$start) { + $start = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime); + } + + if (!$start) { + return [ + 'success' => false, + 'error' => 'Invalid date-time format. Use ISO 8601 format.', + 'example' => '2024-01-15T14:30:00Z' + ]; + } + + $end = clone $start; + $end->add(new DateInterval('PT' . ($durationHours * 60) . 'M')); + + $event = [ + 'id' => uniqid('event_'), + 'title' => $title, + 'start_time' => $start->format('c'), + 'end_time' => $end->format('c'), + 'duration_hours' => $durationHours, + 'priority' => $priority, + 'attendees' => $attendees, + 'created_at' => date('c') + ]; + + return [ + 'success' => true, + 'event' => $event, + 'info' => [ + 'attendee_count' => count($attendees), + 'is_all_day' => $durationHours >= 24, + 'is_future' => $start > new DateTime(), + 'timezone_note' => 'Times are in UTC' + ] + ]; + } +} diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php new file mode 100644 index 0000000..45761d7 --- /dev/null +++ b/examples/08-schema-showcase-streamable/server.php @@ -0,0 +1,77 @@ +#!/usr/bin/env php +info('Starting MCP Schema Showcase Server...'); + + $server = Server::make() + ->withServerInfo('Schema Showcase', '1.0.0') + ->withLogger($logger) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); + + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/src/Attributes/CompletionProvider.php b/src/Attributes/CompletionProvider.php new file mode 100644 index 0000000..3bf5223 --- /dev/null +++ b/src/Attributes/CompletionProvider.php @@ -0,0 +1,19 @@ + $providerClass FQCN of the completion provider class. + */ + public function __construct(public string $providerClass) + { + } +} diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index f4b0774..f367301 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Schema\Annotations; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. @@ -17,7 +18,7 @@ final class McpResource * @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 array $annotations Optional annotations following the MCP spec (e.g., ['audience' => ['user'], 'priority' => 0.5]). + * @param Annotations|null $annotations Optional annotations describing the resource. */ public function __construct( public string $uri, @@ -25,7 +26,7 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, - public array $annotations = [], + public ?Annotations $annotations = null, ) { } } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index 95a7c88..9448d26 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Schema\Annotations; /** * Marks a PHP class definition as representing an MCP Resource Template. @@ -16,14 +17,14 @@ final class McpResourceTemplate * @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 array $annotations Optional annotations following the MCP spec. + * @param ?Annotations $annotations Optional annotations describing the resource template. */ public function __construct( public string $uriTemplate, public ?string $name = null, public ?string $description = null, public ?string $mimeType = null, - public array $annotations = [], + public ?Annotations $annotations = null, ) { } } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 3298aa0..d062002 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Schema\ToolAnnotations; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class McpTool @@ -10,10 +11,12 @@ 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 */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?ToolAnnotations $annotations = null, ) { } } diff --git a/src/Attributes/Schema.php b/src/Attributes/Schema.php index f5fc809..5573b79 100644 --- a/src/Attributes/Schema.php +++ b/src/Attributes/Schema.php @@ -5,69 +5,153 @@ namespace PhpMcp\Server\Attributes; use Attribute; -use PhpMcp\Server\Attributes\Schema\ArrayItems; -use PhpMcp\Server\Attributes\Schema\Property; -#[Attribute(Attribute::TARGET_PARAMETER)] +/** + * Defines a JSON Schema for a method's input or an individual parameter. + * + * When used at the method level, it describes an object schema where properties + * correspond to the method's parameters. + * + * When used at the parameter level, it describes the schema for that specific parameter. + * If 'type' is omitted at the parameter level, it will be inferred. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] class Schema { - /** @var Property[] */ - protected array $properties = []; + /** + * The complete JSON schema array. + * If provided, it takes precedence over individual properties like $type, $properties, etc. + */ + public ?array $definition = null; /** - * @param string|null $format String format (email, date-time, uri, etc.) - * @param int|null $minLength Minimum string length - * @param int|null $maxLength Maximum string length - * @param string|null $pattern Regular expression pattern - * @param int|float|null $minimum Minimum numeric value - * @param int|float|null $maximum Maximum numeric value - * @param bool|null $exclusiveMinimum Whether minimum is exclusive - * @param bool|null $exclusiveMaximum Whether maximum is exclusive - * @param int|float|null $multipleOf Value must be multiple of this number - * @param ArrayItems|null $items Schema for array items - * @param int|null $minItems Minimum array items - * @param int|null $maxItems Maximum array items - * @param bool|null $uniqueItems Whether array items must be unique - * @param Property[] $properties Properties for object validation - * @param string[]|null $required Required properties for objects - * @param bool|Schema|null $additionalProperties Whether additional properties are allowed - * @param mixed|null $enum List of allowed values - * @param mixed|null $default Default value + * Alternatively, provide individual top-level schema keywords. + * These are used if $definition is null. + */ + public ?string $type = null; + public ?string $description = null; + public mixed $default = null; + public ?array $enum = null; // list of allowed values + public ?string $format = null; // e.g., 'email', 'date-time' + + // Constraints for string + public ?int $minLength = null; + public ?int $maxLength = null; + public ?string $pattern = null; + + // Constraints for number/integer + public int|float|null $minimum = null; + public int|float|null $maximum = null; + public ?bool $exclusiveMinimum = null; + public ?bool $exclusiveMaximum = null; + public int|float|null $multipleOf = null; + + // Constraints for array + public ?array $items = null; // JSON schema for array items + public ?int $minItems = null; + public ?int $maxItems = null; + public ?bool $uniqueItems = null; + + // Constraints for object (primarily used when Schema is on a method or an object-typed parameter) + public ?array $properties = null; // [propertyName => [schema array], ...] + public ?array $required = null; // [propertyName, ...] + public bool|array|null $additionalProperties = null; // true, false, or a schema array + + /** + * @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored. + * @param Type|null $type The JSON schema type. + * @param string|null $description Description of the element. + * @param array|null $enum Allowed enum values. + * @param string|null $format String format (e.g., 'date-time', 'email'). + * @param int|null $minLength Minimum length for strings. + * @param int|null $maxLength Maximum length for strings. + * @param string|null $pattern Regex pattern for strings. + * @param int|float|null $minimum Minimum value for numbers/integers. + * @param int|float|null $maximum Maximum value for numbers/integers. + * @param bool|null $exclusiveMinimum Exclusive minimum. + * @param bool|null $exclusiveMaximum Exclusive maximum. + * @param int|float|null $multipleOf Must be a multiple of this value. + * @param array|null $items JSON Schema for items if type is 'array'. + * @param int|null $minItems Minimum items for an array. + * @param int|null $maxItems Maximum items for an array. + * @param bool|null $uniqueItems Whether array items must be unique. + * @param array|null $properties Property definitions if type is 'object'. [name => schema_array]. + * @param array|null $required List of required properties for an object. + * @param bool|array|null $additionalProperties Policy for additional properties in an object. */ public function __construct( - public ?string $format = null, - public ?int $minLength = null, - public ?int $maxLength = null, - public ?string $pattern = null, - public int|float|null $minimum = null, - public int|float|null $maximum = null, - public ?bool $exclusiveMinimum = null, - public ?bool $exclusiveMaximum = null, - public int|float|null $multipleOf = null, - public ?ArrayItems $items = null, - public ?int $minItems = null, - public ?int $maxItems = null, - public ?bool $uniqueItems = null, - array $properties = [], - public ?array $required = null, - public bool|Schema|null $additionalProperties = null, - public mixed $enum = null, - public mixed $default = null, + ?array $definition = null, + ?string $type = null, + ?string $description = null, + ?array $enum = null, + ?string $format = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + int|float|null $minimum = null, + int|float|null $maximum = null, + ?bool $exclusiveMinimum = null, + ?bool $exclusiveMaximum = null, + int|float|null $multipleOf = null, + ?array $items = null, + ?int $minItems = null, + ?int $maxItems = null, + ?bool $uniqueItems = null, + ?array $properties = null, + ?array $required = null, + bool|array|null $additionalProperties = null ) { - $this->properties = $properties; + if ($definition !== null) { + $this->definition = $definition; + } else { + $this->type = $type; + $this->description = $description; + $this->enum = $enum; + $this->format = $format; + $this->minLength = $minLength; + $this->maxLength = $maxLength; + $this->pattern = $pattern; + $this->minimum = $minimum; + $this->maximum = $maximum; + $this->exclusiveMinimum = $exclusiveMinimum; + $this->exclusiveMaximum = $exclusiveMaximum; + $this->multipleOf = $multipleOf; + $this->items = $items; + $this->minItems = $minItems; + $this->maxItems = $maxItems; + $this->uniqueItems = $uniqueItems; + $this->properties = $properties; + $this->required = $required; + $this->additionalProperties = $additionalProperties; + } } /** - * Convert to JSON Schema array + * Converts the attribute's definition to a JSON schema array. */ public function toArray(): array { - $schema = []; + if ($this->definition !== null) { + return [ + 'definition' => $this->definition, + ]; + } - // String constraints + $schema = []; + if ($this->type !== null) { + $schema['type'] = $this->type; + } + if ($this->description !== null) { + $schema['description'] = $this->description; + } + if ($this->enum !== null) { + $schema['enum'] = $this->enum; + } if ($this->format !== null) { $schema['format'] = $this->format; } + + // String if ($this->minLength !== null) { $schema['minLength'] = $this->minLength; } @@ -78,7 +162,7 @@ public function toArray(): array $schema['pattern'] = $this->pattern; } - // Numeric constraints + // Numeric if ($this->minimum !== null) { $schema['minimum'] = $this->minimum; } @@ -95,9 +179,9 @@ public function toArray(): array $schema['multipleOf'] = $this->multipleOf; } - // Array constraints + // Array if ($this->items !== null) { - $schema['items'] = $this->items->toArray(); + $schema['items'] = $this->items; } if ($this->minItems !== null) { $schema['minItems'] = $this->minItems; @@ -109,33 +193,15 @@ public function toArray(): array $schema['uniqueItems'] = $this->uniqueItems; } - // Object constraints - if (!empty($this->properties)) { - $props = []; - foreach ($this->properties as $property) { - $props[$property->name] = $property->toArray(); - } - $schema['properties'] = $props; + // Object + if ($this->properties !== null) { + $schema['properties'] = $this->properties; } - if ($this->required !== null) { $schema['required'] = $this->required; } - if ($this->additionalProperties !== null) { - if ($this->additionalProperties instanceof self) { - $schema['additionalProperties'] = $this->additionalProperties->toArray(); - } else { - $schema['additionalProperties'] = $this->additionalProperties; - } - } - - // General constraints - if ($this->enum !== null) { - $schema['enum'] = $this->enum; - } - if ($this->default !== null) { - $schema['default'] = $this->default; + $schema['additionalProperties'] = $this->additionalProperties; } return $schema; diff --git a/src/Attributes/Schema/ArrayItems.php b/src/Attributes/Schema/ArrayItems.php deleted file mode 100644 index 7eeaf08..0000000 --- a/src/Attributes/Schema/ArrayItems.php +++ /dev/null @@ -1,58 +0,0 @@ - Resolves on successful send/queue, rejects on specific send error. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface; + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface; /** * Stops the transport listener gracefully and closes all active connections. diff --git a/src/Contracts/SessionHandlerInterface.php b/src/Contracts/SessionHandlerInterface.php new file mode 100644 index 0000000..e6fe8dc --- /dev/null +++ b/src/Contracts/SessionHandlerInterface.php @@ -0,0 +1,38 @@ + + */ + public function dequeueMessages(): array; + + /** + * Check if there are any messages in the queue. + */ + public function hasQueuedMessages(): bool; +} diff --git a/src/Defaults/DefaultUuidSessionIdGenerator.php b/src/Defaults/DefaultUuidSessionIdGenerator.php new file mode 100644 index 0000000..52c0621 --- /dev/null +++ b/src/Defaults/DefaultUuidSessionIdGenerator.php @@ -0,0 +1,20 @@ + + * Example: [eventId1 => ['streamId' => 'abc', 'message' => '...']] + */ + private array $events = []; + + private function generateEventId(string $streamId): string + { + return $streamId . '_' . (int)(microtime(true) * 1000) . '_' . bin2hex(random_bytes(4)); + } + + private function getStreamIdFromEventId(string $eventId): ?string + { + $parts = explode('_', $eventId); + return $parts[0] ?? null; + } + + public function storeEvent(string $streamId, string $message): string + { + $eventId = $this->generateEventId($streamId); + + $this->events[$eventId] = [ + 'streamId' => $streamId, + 'message' => $message, + ]; + + return $eventId; + } + + public function replayEventsAfter(string $lastEventId, callable $sendCallback): void + { + if (!isset($this->events[$lastEventId])) { + return; + } + + $streamId = $this->getStreamIdFromEventId($lastEventId); + if ($streamId === null) { + return; + } + + $foundLastEvent = false; + + // Sort by eventId for deterministic ordering + ksort($this->events); + + foreach ($this->events as $eventId => ['streamId' => $eventStreamId, 'message' => $message]) { + if ($eventStreamId !== $streamId) { + continue; + } + + if ($eventId === $lastEventId) { + $foundLastEvent = true; + continue; + } + + if ($foundLastEvent) { + $sendCallback($eventId, $message); + } + } + } +} diff --git a/src/Defaults/SystemClock.php b/src/Defaults/SystemClock.php new file mode 100644 index 0000000..a18c4ce --- /dev/null +++ b/src/Defaults/SystemClock.php @@ -0,0 +1,16 @@ +name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function isRequired(): bool - { - return $this->required; - } - - /** - * Formats the definition into the structure expected by MCP's 'Prompt.arguments'. - * - * @return array{name: string, description?: string, required?: bool} - */ - public function toArray(): array - { - $data = [ - 'name' => $this->name, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - // 'required' defaults to false, only include if true for brevity? Schema doesn't specify default. Let's include it. - $data['required'] = $this->required; - - return $data; - } - - /** - * Reconstruct a PromptArgumentDefinition from its array representation. - * - * @param array $data The array representation of a PromptArgumentDefinition - * @return static The reconstructed PromptArgumentDefinition - */ - public static function fromArray(array $data): static - { - return new self( - name: $data['name'], - description: $data['description'] ?? null, - required: $data['required'] ?? false - ); - } - - /** - * Create a PromptArgumentDefinition from reflection data. - * - * @param \ReflectionParameter $parameter The reflection parameter. - * @param \phpDocumentor\Reflection\DocBlock\Tags\Param|null $paramTag The corresponding parsed @param tag, or null. - */ - public static function fromReflection(ReflectionParameter $parameter, ?Param $paramTag = null): self - { - $name = $parameter->getName(); - $description = $paramTag ? trim((string) $paramTag->getDescription()) : null; - - return new self( - name: $name, - description: $description, - required: ! $parameter->isOptional() && ! $parameter->isDefaultValueAvailable() - ); - } -} diff --git a/src/Definitions/PromptDefinition.php b/src/Definitions/PromptDefinition.php deleted file mode 100644 index 61317c8..0000000 --- a/src/Definitions/PromptDefinition.php +++ /dev/null @@ -1,171 +0,0 @@ -validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the prompt name is invalid - */ - private function validate(): void - { - if (! preg_match(self::PROMPT_NAME_PATTERN, $this->promptName)) { - throw new \InvalidArgumentException( - "Prompt name '{$this->promptName}' is invalid. Prompt names must match the pattern " . self::PROMPT_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getName(): string - { - return $this->promptName; - } - - public function getDescription(): ?string - { - return $this->description; - } - - /** - * @return list - */ - public function getArguments(): array - { - return $this->arguments; - } - - public function isTemplate(): bool - { - return ! empty($this->arguments); - } - - /** - * Formats the definition into the structure expected by MCP's 'prompts/list'. - * - * @return array{name: string, description?: string, arguments?: list} - */ - public function toArray(): array - { - $data = [ - 'name' => $this->promptName, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if (! empty($this->arguments)) { - $data['arguments'] = array_map( - fn(PromptArgumentDefinition $arg) => $arg->toArray(), - $this->arguments - ); - } - - return $data; - } - - /** - * Reconstruct a PromptDefinition from its array representation. - * - * @param array $data The array representation of a PromptDefinition - * @return static The reconstructed PromptDefinition - */ - public static function fromArray(array $data): static - { - $arguments = []; - if (isset($data['arguments']) && is_array($data['arguments'])) { - foreach ($data['arguments'] as $argData) { - $arguments[] = PromptArgumentDefinition::fromArray($argData); - } - } - - return new self( - className: $data['className'], - methodName: $data['methodName'], - promptName: $data['promptName'], - description: $data['description'], - arguments: $arguments - ); - } - - /** - * Create a PromptDefinition from reflection data. - */ - public static function fromReflection( - \ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - DocBlockParser $docBlockParser - ): self { - $className = $method->getDeclaringClass()->getName(); - $methodName = $method->getName(); - $promptName = $overrideName ?? ($methodName === '__invoke' ? $method->getDeclaringClass()->getShortName() : $methodName); - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $arguments = []; - $paramTags = $docBlockParser->getParamTags($docBlock); // Get all param tags first - foreach ($method->getParameters() as $param) { - $reflectionType = $param->getType(); - - // Basic DI check (heuristic) - if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { - continue; - } - - // Correctly get the specific Param tag using the '$' prefix - $paramTag = $paramTags['$' . $param->getName()] ?? null; - $arguments[] = PromptArgumentDefinition::fromReflection($param, $paramTag); - } - - return new self( - className: $className, - methodName: $methodName, - promptName: $promptName, - description: $description, - arguments: $arguments - ); - } -} diff --git a/src/Definitions/ResourceDefinition.php b/src/Definitions/ResourceDefinition.php deleted file mode 100644 index 4f1aee7..0000000 --- a/src/Definitions/ResourceDefinition.php +++ /dev/null @@ -1,196 +0,0 @@ - $annotations Optional annotations (audience, priority). - * - * @throws \InvalidArgumentException If the URI doesn't match the required pattern. - */ - public function __construct( - public readonly string $className, - public readonly string $methodName, - public readonly string $uri, - public readonly string $name, - public readonly ?string $description, - public readonly ?string $mimeType, - public readonly ?int $size, - public readonly array $annotations = [] // Follows Annotated base type - ) { - $this->validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the URI is invalid - */ - private function validate(): void - { - if (! preg_match(self::URI_PATTERN, $this->uri)) { - throw new \InvalidArgumentException( - "Resource URI '{$this->uri}' is invalid. URIs must match the pattern " . self::URI_PATTERN - . ' (valid scheme followed by :// and optional path).' - ); - } - - if (! preg_match(self::RESOURCE_NAME_PATTERN, $this->name)) { - throw new \InvalidArgumentException( - "Resource name '{$this->name}' is invalid. Resource names must match the pattern " . self::RESOURCE_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getUri(): string - { - return $this->uri; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function getMimeType(): ?string - { - return $this->mimeType; - } - - public function getSize(): ?int - { - return $this->size; - } - - public function getAnnotations(): array - { - return $this->annotations; - } - - /** - * Formats the definition into the structure expected by MCP's 'resources/list'. - * - * @return array{uri: string, name: string, description?: string, mimeType?: string, size?: int, annotations?: array} - */ - public function toArray(): array - { - $data = [ - 'uri' => $this->uri, - 'name' => $this->name, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if ($this->mimeType !== null) { - $data['mimeType'] = $this->mimeType; - } - if ($this->size !== null) { - $data['size'] = $this->size; - } - if (! empty($this->annotations)) { - $data['annotations'] = $this->annotations; - } - - return $data; - } - - /** - * Reconstruct a ResourceDefinition from its array representation. - * - * @param array $data The array representation of a ResourceDefinition - * @return static The reconstructed ResourceDefinition - */ - public static function fromArray(array $data): static - { - return new self( - className: $data['className'], - methodName: $data['methodName'], - uri: $data['uri'], - name: $data['name'], - description: $data['description'], - mimeType: $data['mimeType'], - size: $data['size'], - annotations: $data['annotations'] ?? [] - ); - } - - /** - * Create a ResourceDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method marked with McpResource. - * @param McpResource $attribute The attribute instance. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - string $uri, - ?string $mimeType, - ?int $size, - ?array $annotations, - DocBlockParser $docBlockParser - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $name = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - uri: $uri, - name: $name, - description: $description, - mimeType: $mimeType, - size: $size, - annotations: $annotations - ); - } -} diff --git a/src/Definitions/ResourceTemplateDefinition.php b/src/Definitions/ResourceTemplateDefinition.php deleted file mode 100644 index f617293..0000000 --- a/src/Definitions/ResourceTemplateDefinition.php +++ /dev/null @@ -1,186 +0,0 @@ - $annotations Optional annotations (audience, priority). - * - * @throws \InvalidArgumentException If the URI template doesn't match the required pattern. - */ - public function __construct( - public readonly string $className, - public readonly string $methodName, - public readonly string $uriTemplate, - public readonly string $name, - public readonly ?string $description, - public readonly ?string $mimeType, - public readonly array $annotations = [] - ) { - $this->validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the URI template is invalid - */ - private function validate(): void - { - if (! preg_match(self::URI_TEMPLATE_PATTERN, $this->uriTemplate)) { - throw new \InvalidArgumentException( - "Resource URI template '{$this->uriTemplate}' is invalid. URI templates must match the pattern " - . self::URI_TEMPLATE_PATTERN . ' (valid scheme followed by :// and path with placeholder(s) in curly braces).' - ); - } - - if (! preg_match(self::RESOURCE_NAME_PATTERN, $this->name)) { - throw new \InvalidArgumentException( - "Resource name '{$this->name}' is invalid. Resource names must match the pattern " . self::RESOURCE_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getUriTemplate(): string - { - return $this->uriTemplate; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function getMimeType(): ?string - { - return $this->mimeType; - } - - public function getAnnotations(): array - { - return $this->annotations; - } - - /** - * Formats the definition into the structure expected by MCP's 'resources/templates/list'. - * - * @return array{uriTemplate: string, name: string, description?: string, mimeType?: string, annotations?: array} - */ - public function toArray(): array - { - $data = [ - 'uriTemplate' => $this->uriTemplate, - 'name' => $this->name, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if ($this->mimeType !== null) { - $data['mimeType'] = $this->mimeType; - } - if (! empty($this->annotations)) { - $data['annotations'] = $this->annotations; - } - - return $data; - } - - /** - * Reconstruct a ResourceTemplateDefinition from its array representation. - * - * @param array $data The array representation of a ResourceTemplateDefinition - * @return static The reconstructed ResourceTemplateDefinition - */ - public static function fromArray(array $data): static - { - return new self( - className: $data['className'], - methodName: $data['methodName'], - uriTemplate: $data['uriTemplate'], - name: $data['name'], - description: $data['description'] ?? null, - mimeType: $data['mimeType'] ?? null, - annotations: $data['annotations'] ?? [] - ); - } - - /** - * Create a ResourceTemplateDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method marked with McpResourceTemplate. - * @param string|null $overrideName The name for the resource. - * @param string|null $overrideDescription The description for the resource. - * @param string $uriTemplate The URI template for the resource. - * @param string|null $mimeType The MIME type for the resource. - * @param array|null $annotations The annotations for the resource. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - string $uriTemplate, - ?string $mimeType, - ?array $annotations, - DocBlockParser $docBlockParser - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $name = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - uriTemplate: $uriTemplate, - name: $name, - description: $description, - mimeType: $mimeType, - annotations: $annotations - ); - } -} diff --git a/src/Definitions/ToolDefinition.php b/src/Definitions/ToolDefinition.php deleted file mode 100644 index 3a574a3..0000000 --- a/src/Definitions/ToolDefinition.php +++ /dev/null @@ -1,152 +0,0 @@ - $inputSchema A JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool. Complies with MCP 'Tool.inputSchema'. - * - * @throws \InvalidArgumentException If the tool name doesn't match the required pattern. - */ - public function __construct( - public readonly string $className, - public readonly string $methodName, - public readonly string $toolName, - public readonly ?string $description, - public readonly array $inputSchema, - ) { - $this->validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the tool name is invalid - */ - private function validate(): void - { - if (! preg_match(self::TOOL_NAME_PATTERN, $this->toolName)) { - throw new \InvalidArgumentException( - "Tool name '{$this->toolName}' is invalid. Tool names must match the pattern " . self::TOOL_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getName(): string - { - return $this->toolName; - } - - public function getDescription(): ?string - { - return $this->description; - } - - /** - * Gets the JSON schema defining the tool's input arguments. - * - * @return array - */ - public function getInputSchema(): array - { - return $this->inputSchema; - } - - /** - * Convert the tool definition to MCP format. - */ - public function toArray(): array - { - $result = [ - 'name' => $this->toolName, - ]; - - if ($this->description) { - $result['description'] = $this->description; - } - - if ($this->inputSchema) { - $result['inputSchema'] = $this->inputSchema; - } - - return $result; - } - - /** - * Reconstruct a ToolDefinition from its array representation. - * - * @param array $data The array representation of a ToolDefinition - * @return static The reconstructed ToolDefinition - */ - public static function fromArray(array $data): static - { - return new self( - className: $data['className'], - methodName: $data['methodName'], - toolName: $data['toolName'], - description: $data['description'], - inputSchema: $data['inputSchema'], - ); - } - - /** - * Create a ToolDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method for the tool. - * @param McpTool $attribute The attribute instance. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - * @param SchemaGenerator $schemaGenerator Utility to generate JSON schema. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - DocBlockParser $docBlockParser, - SchemaGenerator $schemaGenerator - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - $inputSchema = $schemaGenerator->fromMethodParameters($method); - - $toolName = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - toolName: $toolName, - description: $description, - inputSchema: $inputSchema, - ); - } -} diff --git a/src/Dispatcher.php b/src/Dispatcher.php new file mode 100644 index 0000000..a5bab0f --- /dev/null +++ b/src/Dispatcher.php @@ -0,0 +1,424 @@ +container = $this->configuration->container; + $this->logger = $this->configuration->logger; + + $this->schemaValidator ??= new SchemaValidator($this->logger); + } + + public function handleRequest(Request $request, SessionInterface $session): Result + { + switch ($request->method) { + case 'initialize': + $request = InitializeRequest::fromRequest($request); + return $this->handleInitialize($request, $session); + case 'ping': + $request = PingRequest::fromRequest($request); + return $this->handlePing($request); + case 'tools/list': + $request = ListToolsRequest::fromRequest($request); + return $this->handleToolList($request); + case 'tools/call': + $request = CallToolRequest::fromRequest($request); + return $this->handleToolCall($request); + case 'resources/list': + $request = ListResourcesRequest::fromRequest($request); + return $this->handleResourcesList($request); + case 'resources/templates/list': + $request = ListResourceTemplatesRequest::fromRequest($request); + return $this->handleResourceTemplateList($request); + case 'resources/read': + $request = ReadResourceRequest::fromRequest($request); + return $this->handleResourceRead($request); + case 'resources/subscribe': + $request = ResourceSubscribeRequest::fromRequest($request); + return $this->handleResourceSubscribe($request, $session); + case 'resources/unsubscribe': + $request = ResourceUnsubscribeRequest::fromRequest($request); + return $this->handleResourceUnsubscribe($request, $session); + case 'prompts/list': + $request = ListPromptsRequest::fromRequest($request); + return $this->handlePromptsList($request); + case 'prompts/get': + $request = GetPromptRequest::fromRequest($request); + return $this->handlePromptGet($request); + case 'logging/setLevel': + $request = SetLogLevelRequest::fromRequest($request); + return $this->handleLoggingSetLevel($request, $session); + case 'completion/complete': + $request = CompletionCompleteRequest::fromRequest($request); + return $this->handleCompletionComplete($request, $session); + default: + throw McpServerException::methodNotFound("Method '{$request->method}' not found."); + } + } + + public function handleNotification(Notification $notification, SessionInterface $session): void + { + switch ($notification->method) { + case 'notifications/initialized': + $notification = InitializedNotification::fromNotification($notification); + $this->handleNotificationInitialized($notification, $session); + } + } + + public function handleInitialize(InitializeRequest $request, SessionInterface $session): InitializeResult + { + if (! in_array($request->protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { + $this->logger->warning("Unsupported protocol version: {$request->protocolVersion}", [ + 'supportedVersions' => Protocol::SUPPORTED_PROTOCOL_VERSIONS, + ]); + } + + $protocolVersion = Protocol::LATEST_PROTOCOL_VERSION; + + $session->set('client_info', $request->clientInfo); + + + $serverInfo = $this->configuration->serverInfo; + $capabilities = $this->configuration->capabilities; + + return new InitializeResult($protocolVersion, $capabilities, $serverInfo); + } + + public function handlePing(PingRequest $request): EmptyResult + { + return new EmptyResult(); + } + + public function handleToolList(ListToolsRequest $request): ListToolsResult + { + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getTools(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListToolsResult(array_values($pagedItems), $nextCursor); + } + + public function handleToolCall(CallToolRequest $request): CallToolResult + { + $toolName = $request->name; + $arguments = $request->arguments; + + $registeredTool = $this->registry->getTool($toolName); + if (! $registeredTool) { + throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); + } + + $inputSchema = $registeredTool->schema->inputSchema; + + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); + + if (! empty($validationErrors)) { + $errorMessages = []; + + foreach ($validationErrors as $errorDetail) { + $pointer = $errorDetail['pointer'] ?? ''; + $message = $errorDetail['message'] ?? 'Unknown validation error'; + $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; + } + + $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); + + if (count($errorMessages) > 3) { + $summaryMessage .= '; ...and more errors.'; + } + + throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); + } + + try { + $result = $registeredTool->call($this->container, $arguments); + + return new CallToolResult($result, false); + } catch (JsonException $e) { + $this->logger->warning('Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); + $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; + + return new CallToolResult([new TextContent($errorMessage)], true); + } catch (Throwable $toolError) { + $this->logger->error('Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); + $errorMessage = "Tool execution failed: {$toolError->getMessage()}"; + + return new CallToolResult([new TextContent($errorMessage)], true); + } + } + + public function handleResourcesList(ListResourcesRequest $request): ListResourcesResult + { + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getResources(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListResourcesResult(array_values($pagedItems), $nextCursor); + } + + public function handleResourceTemplateList(ListResourceTemplatesRequest $request): ListResourceTemplatesResult + { + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getResourceTemplates(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); + } + + public function handleResourceRead(ReadResourceRequest $request): ReadResourceResult + { + $uri = $request->uri; + + $registeredResource = $this->registry->getResource($uri); + + if (! $registeredResource) { + throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); + } + + try { + $result = $registeredResource->read($this->container, $uri); + + return new ReadResourceResult($result); + } catch (JsonException $e) { + $this->logger->warning('Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); + throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); + } catch (McpServerException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('Resource read failed.', ['uri' => $uri, 'exception' => $e]); + throw McpServerException::resourceReadFailed($uri, $e); + } + } + + public function handleResourceSubscribe(ResourceSubscribeRequest $request, SessionInterface $session): EmptyResult + { + $this->subscriptionManager->subscribe($session->getId(), $request->uri); + return new EmptyResult(); + } + + public function handleResourceUnsubscribe(ResourceUnsubscribeRequest $request, SessionInterface $session): EmptyResult + { + $this->subscriptionManager->unsubscribe($session->getId(), $request->uri); + return new EmptyResult(); + } + + public function handlePromptsList(ListPromptsRequest $request): ListPromptsResult + { + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getPrompts(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListPromptsResult(array_values($pagedItems), $nextCursor); + } + + public function handlePromptGet(GetPromptRequest $request): GetPromptResult + { + $promptName = $request->name; + $arguments = $request->arguments; + + $registeredPrompt = $this->registry->getPrompt($promptName); + if (! $registeredPrompt) { + throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); + } + + $arguments = (array) $arguments; + + foreach ($registeredPrompt->schema->arguments as $argDef) { + if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { + throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); + } + } + + try { + $result = $registeredPrompt->get($this->container, $arguments); + + return new GetPromptResult($result, $registeredPrompt->schema->description); + } catch (JsonException $e) { + $this->logger->warning('Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); + throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); + } catch (McpServerException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); + throw McpServerException::promptGenerationFailed($promptName, $e); + } + } + + public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterface $session): EmptyResult + { + $level = $request->level; + + $session->set('log_level', $level->value); + + $this->logger->info("Log level set to '{$level->value}'.", ['sessionId' => $session->getId()]); + + return new EmptyResult(); + } + + public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult + { + $ref = $request->ref; + $argumentName = $request->argument['name']; + $currentValue = $request->argument['value']; + + $identifier = null; + + if ($ref->type === 'ref/prompt') { + $identifier = $ref->name; + $registeredPrompt = $this->registry->getPrompt($identifier); + if (! $registeredPrompt) { + throw McpServerException::invalidParams("Prompt '{$identifier}' not found."); + } + + $foundArg = false; + foreach ($registeredPrompt->schema->arguments as $arg) { + if ($arg->name === $argumentName) { + $foundArg = true; + break; + } + } + if (! $foundArg) { + throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); + } + + $providerClass = $registeredPrompt->getCompletionProvider($argumentName); + } elseif ($ref->type === 'ref/resource') { + $identifier = $ref->uri; + $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); + if (! $registeredResourceTemplate) { + throw McpServerException::invalidParams("Resource template '{$identifier}' not found."); + } + + $foundArg = false; + foreach ($registeredResourceTemplate->getVariableNames() as $uriVariableName) { + if ($uriVariableName === $argumentName) { + $foundArg = true; + break; + } + } + + if (! $foundArg) { + throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); + } + + $providerClass = $registeredResourceTemplate->getCompletionProvider($argumentName); + } else { + throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); + } + + if (! $providerClass) { + $this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'."); + return new CompletionCompleteResult([]); + } + + $provider = $this->container->get($providerClass); + + $completions = $provider->getCompletions($currentValue, $session); + + $total = count($completions); + $hasMore = $total > 100; + + $pagedCompletions = array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); + } + + public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult + { + $session->set('initialized', true); + + return new EmptyResult(); + } + + private function decodeCursor(?string $cursor): int + { + if ($cursor === null) { + return 0; + } + + $decoded = base64_decode($cursor, true); + if ($decoded === false) { + $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); + + return 0; + } + + if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { + return (int) $matches[1]; + } + + $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); + + return 0; + } + + private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string + { + $nextOffset = $currentOffset + $returnedCount; + if ($returnedCount > 0 && $nextOffset < $totalCount) { + return base64_encode("offset={$nextOffset}"); + } + + return null; + } +} diff --git a/src/Elements/RegisteredElement.php b/src/Elements/RegisteredElement.php new file mode 100644 index 0000000..18a7846 --- /dev/null +++ b/src/Elements/RegisteredElement.php @@ -0,0 +1,218 @@ +get($this->handlerClass); + $arguments = $this->prepareArguments($instance, $arguments); + $method = $this->handlerMethod; + + return $instance->$method(...$arguments); + } + + protected function prepareArguments(object $instance, array $arguments): array + { + if (! method_exists($instance, $this->handlerMethod)) { + throw new ReflectionException("Method does not exist: {$this->handlerClass}::{$this->handlerMethod}"); + } + + $reflectionMethod = new ReflectionMethod($instance, $this->handlerMethod); + + $finalArgs = []; + + foreach ($reflectionMethod->getParameters() as $parameter) { + // TODO: Handle variadic parameters. + $paramName = $parameter->getName(); + $paramPosition = $parameter->getPosition(); + + if (isset($arguments[$paramName])) { + $argument = $arguments[$paramName]; + try { + $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); + } catch (InvalidArgumentException $e) { + throw McpServerException::invalidParams($e->getMessage(), $e); + } catch (Throwable $e) { + throw McpServerException::internalError( + "Error processing parameter `{$paramName}`: {$e->getMessage()}", + $e + ); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $finalArgs[$paramPosition] = $parameter->getDefaultValue(); + } elseif ($parameter->allowsNull()) { + $finalArgs[$paramPosition] = null; + } elseif ($parameter->isOptional()) { + continue; + } else { + throw McpServerException::internalError( + "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->handlerMethod}." + ); + } + } + + return array_values($finalArgs); + } + + /** + * Attempts type casting based on ReflectionParameter type hints. + * + * @throws InvalidArgumentException If casting is impossible for the required type. + * @throws TypeError If internal PHP casting fails unexpectedly. + */ + private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if ($argument === null) { + if ($type && $type->allowsNull()) { + return null; + } + } + + if (! $type instanceof ReflectionNamedType) { + return $argument; + } + + $typeName = $type->getName(); + + if (enum_exists($typeName)) { + if (is_object($argument) && $argument instanceof $typeName) { + return $argument; + } + + if (is_subclass_of($typeName, \BackedEnum::class)) { + $value = $typeName::tryFrom($argument); + if ($value === null) { + throw new InvalidArgumentException( + "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.", + ); + } + return $value; + } else { + if (is_string($argument)) { + foreach ($typeName::cases() as $case) { + if ($case->name === $argument) { + return $case; + } + } + $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); + throw new InvalidArgumentException( + "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "." + ); + } else { + throw new InvalidArgumentException( + "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name." + ); + } + } + } + + try { + return match (strtolower($typeName)) { + 'int', 'integer' => $this->castToInt($argument), + 'string' => (string) $argument, + 'bool', 'boolean' => $this->castToBoolean($argument), + 'float', 'double' => $this->castToFloat($argument), + 'array' => $this->castToArray($argument), + default => $argument, + }; + } catch (TypeError $e) { + throw new InvalidArgumentException( + "Value cannot be cast to required type `{$typeName}`.", + 0, + $e + ); + } + } + + /** Helper to cast strictly to boolean */ + private function castToBoolean(mixed $argument): bool + { + if (is_bool($argument)) { + return $argument; + } + if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') { + return true; + } + if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') { + return false; + } + throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); + } + + /** Helper to cast strictly to integer */ + private function castToInt(mixed $argument): int + { + if (is_int($argument)) { + return $argument; + } + if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) { + return (int) $argument; + } + if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) { + return (int) $argument; + } + throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); + } + + /** Helper to cast strictly to float */ + private function castToFloat(mixed $argument): float + { + if (is_float($argument)) { + return $argument; + } + if (is_int($argument)) { + return (float) $argument; + } + if (is_numeric($argument)) { + return (float) $argument; + } + throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); + } + + /** Helper to cast strictly to array */ + private function castToArray(mixed $argument): array + { + if (is_array($argument)) { + return $argument; + } + throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); + } + + public function toArray(): array + { + return [ + 'handlerClass' => $this->handlerClass, + 'handlerMethod' => $this->handlerMethod, + 'isManual' => $this->isManual, + ]; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php new file mode 100644 index 0000000..b9bc8e9 --- /dev/null +++ b/src/Elements/RegisteredPrompt.php @@ -0,0 +1,293 @@ +handle($container, $arguments); + + return $this->formatResult($result); + } + + public function getCompletionProvider(string $argumentName): ?string + { + return $this->completionProviders[$argumentName] ?? null; + } + + /** + * Formats the raw result of a prompt generator into an array of MCP PromptMessages. + * + * @param mixed $promptGenerationResult Expected: array of message structures. + * @return PromptMessage[] Array of PromptMessage objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * @throws \JsonException If JSON encoding fails. + */ + protected function formatResult(mixed $promptGenerationResult): array + { + if ($promptGenerationResult instanceof PromptMessage) { + return [$promptGenerationResult]; + } + + if (! is_array($promptGenerationResult)) { + throw new \RuntimeException('Prompt generator method must return an array of messages.'); + } + + if (empty($promptGenerationResult)) { + return []; + } + + if (is_array($promptGenerationResult)) { + $allArePromptMessages = true; + $hasPromptMessages = false; + + foreach ($promptGenerationResult as $item) { + if ($item instanceof PromptMessage) { + $hasPromptMessages = true; + } else { + $allArePromptMessages = false; + } + } + + if ($allArePromptMessages && $hasPromptMessages) { + return $promptGenerationResult; + } + + if ($hasPromptMessages) { + $result = []; + foreach ($promptGenerationResult as $index => $item) { + if ($item instanceof PromptMessage) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } + return $result; + } + + if (! array_is_list($promptGenerationResult)) { + if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { + $result = []; + if (isset($promptGenerationResult['user'])) { + $userContent = $this->formatContent($promptGenerationResult['user']); + $result[] = PromptMessage::make(Role::User, $userContent); + } + if (isset($promptGenerationResult['assistant'])) { + $assistantContent = $this->formatContent($promptGenerationResult['assistant']); + $result[] = PromptMessage::make(Role::Assistant, $assistantContent); + } + return $result; + } + + if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { + return [$this->formatMessage($promptGenerationResult)]; + } + + throw new \RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); + } + + $formattedMessages = []; + foreach ($promptGenerationResult as $index => $message) { + if ($message instanceof PromptMessage) { + $formattedMessages[] = $message; + } else { + $formattedMessages[] = $this->formatMessage($message, $index); + } + } + return $formattedMessages; + } + + throw new \RuntimeException('Invalid prompt generation result format.'); + } + + /** + * Formats a single message into a PromptMessage. + */ + private function formatMessage(mixed $message, ?int $index = null): PromptMessage + { + $indexStr = $index !== null ? " at index {$index}" : ''; + + if (! is_array($message) || ! array_key_exists('role', $message) || ! array_key_exists('content', $message)) { + throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); + } + + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); + if ($role === null) { + throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); + } + + $content = $this->formatContent($message['content'], $index); + + return new PromptMessage($role, $content); + } + + /** + * Formats content into a proper Content object. + */ + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = $index !== null ? " at index {$index}" : ''; + + if ($content instanceof Content) { + if ( + $content instanceof TextContent || $content instanceof ImageContent || + $content instanceof AudioContent || $content instanceof EmbeddedResource + ) { + return $content; + } + throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + } + + if (is_string($content)) { + return TextContent::make($content); + } + + if (is_array($content) && isset($content['type'])) { + return $this->formatTypedContent($content, $index); + } + + if (is_scalar($content) || $content === null) { + $stringContent = $content === null ? '(null)' : (is_bool($content) ? ($content ? 'true' : 'false') : (string)$content); + return TextContent::make($stringContent); + } + + $jsonContent = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + return TextContent::make($jsonContent); + } + + /** + * Formats typed content arrays into Content objects. + */ + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = $index !== null ? " at index {$index}" : ''; + $type = $content['type']; + + return match ($type) { + 'text' => $this->formatTextContent($content, $indexStr), + 'image' => $this->formatImageContent($content, $indexStr), + 'audio' => $this->formatAudioContent($content, $indexStr), + 'resource' => $this->formatResourceContent($content, $indexStr), + default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}.") + }; + } + + private function formatTextContent(array $content, string $indexStr): TextContent + { + if (! isset($content['text']) || ! is_string($content['text'])) { + throw new \RuntimeException("Invalid 'text' content{$indexStr}: Missing or invalid 'text' string."); + } + return TextContent::make($content['text']); + } + + private function formatImageContent(array $content, string $indexStr): ImageContent + { + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + return ImageContent::make($content['data'], $content['mimeType']); + } + + private function formatAudioContent(array $content, string $indexStr): AudioContent + { + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + return AudioContent::make($content['data'], $content['mimeType']); + } + + private function formatResourceContent(array $content, string $indexStr): EmbeddedResource + { + if (! isset($content['resource']) || ! is_array($content['resource'])) { + throw new \RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); + } + + $resource = $content['resource']; + if (! isset($resource['uri']) || ! is_string($resource['uri'])) { + throw new \RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); + } + + if (isset($resource['text']) && is_string($resource['text'])) { + $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + } elseif (isset($resource['blob']) && is_string($resource['blob'])) { + $resourceObj = BlobResourceContents::make( + $resource['uri'], + $resource['mimeType'] ?? 'application/octet-stream', + $resource['blob'] + ); + } else { + throw new \RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); + } + + return new EmbeddedResource($resourceObj); + } + + public function toArray(): array + { + return [ + 'schema' => $this->schema->toArray(), + 'completionProviders' => $this->completionProviders, + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Prompt::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + $data['completionProviders'] ?? [], + ); + } catch (Throwable $e) { + return false; + } + } +} diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php new file mode 100644 index 0000000..b196e80 --- /dev/null +++ b/src/Elements/RegisteredResource.php @@ -0,0 +1,231 @@ + Array of ResourceContents objects. + */ + public function read(ContainerInterface $container, string $uri): array + { + $result = $this->handle($container, ['uri' => $uri]); + + return $this->formatResult($result, $uri, $this->schema->mimeType); + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult The raw result from the resource handler method. + * @param string $uri The URI of the resource that was read. + * @param ?string $mimeType The MIME type from the ResourceDefinition. + * @return array Array of ResourceContents objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (is_array($readResult)) { + if (empty($readResult)) { + return [TextResourceContents::make($uri, 'application/json', '[]')]; + } + + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn ($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + return $result; + } + } + + if (is_string($readResult)) { + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); + + return [TextResourceContents::make($uri, $mimeType, $readResult)]; + } + + if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $mimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; + + return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; + } + + if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; + + return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + } + + if (is_array($readResult)) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') || + $mimeType === 'application/json')) { + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $mimeType = $mimeType ?? 'application/json'; + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + if (str_contains($trimmed, ' $this->schema->toArray(), + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Resource::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + ); + } catch (Throwable $e) { + return false; + } + } +} diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php new file mode 100644 index 0000000..50ebd4f --- /dev/null +++ b/src/Elements/RegisteredResourceTemplate.php @@ -0,0 +1,290 @@ +compileTemplate(); + } + + public static function make(ResourceTemplate $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self + { + return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders); + } + + /** + * Gets the resource template. + * + * @return array Array of ResourceContents objects. + */ + public function read(ContainerInterface $container, string $uri): array + { + $arguments = array_merge($this->uriVariables, ['uri' => $uri]); + + $result = $this->handle($container, $arguments); + + return $this->formatResult($result, $uri, $this->schema->mimeType); + } + + public function getCompletionProvider(string $argumentName): ?string + { + return $this->completionProviders[$argumentName] ?? null; + } + + public function getVariableNames(): array + { + return $this->variableNames; + } + + public function matches(string $uri): bool + { + if (preg_match($this->uriTemplateRegex, $uri, $matches)) { + $variables = []; + foreach ($this->variableNames as $varName) { + if (isset($matches[$varName])) { + $variables[$varName] = $matches[$varName]; + } + } + + $this->uriVariables = $variables; + + return true; + } + + return false; + } + + private function compileTemplate(): void + { + $this->variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $this->variableNames[] = $varName; + $regexParts[] = '(?P<' . $varName . '>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#'; + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult The raw result from the resource handler method. + * @param string $uri The URI of the resource that was read. + * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. + * @return array Array of ResourceContents objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (is_array($readResult)) { + if (empty($readResult)) { + return [TextResourceContents::make($uri, 'application/json', '[]')]; + } + + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn ($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + return $result; + } + } + + if (is_string($readResult)) { + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); + + return [TextResourceContents::make($uri, $mimeType, $readResult)]; + } + + if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $mimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; + + return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; + } + + if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; + + return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; + } + + if (is_array($readResult)) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') || + $mimeType === 'application/json')) { + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $mimeType = $mimeType ?? 'application/json'; + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + if (str_contains($trimmed, ' $this->schema->toArray(), + 'completionProviders' => $this->completionProviders, + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + ResourceTemplate::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + $data['completionProviders'] ?? [], + ); + } catch (Throwable $e) { + return false; + } + } +} diff --git a/src/Elements/RegisteredTool.php b/src/Elements/RegisteredTool.php new file mode 100644 index 0000000..d16e99b --- /dev/null +++ b/src/Elements/RegisteredTool.php @@ -0,0 +1,138 @@ +handle($container, $arguments); + + return $this->formatResult($result); + } + + /** + * Formats the result of a tool execution into an array of MCP Content items. + * + * - If the result is already a Content object, it's wrapped in an array. + * - If the result is an array: + * - If all elements are Content objects, the array is returned as is. + * - If it's a mixed array (Content and non-Content items), non-Content items are + * individually formatted (scalars to TextContent, others to JSON TextContent). + * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. + * - Scalars (string, int, float, bool) are wrapped in TextContent. + * - null is represented as TextContent('(null)'). + * - Other objects are JSON-encoded and wrapped in TextContent. + * + * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. + * @return Content[] The content items for CallToolResult. + * @throws JsonException if JSON encoding fails for non-Content array/object results. + */ + protected function formatResult(mixed $toolExecutionResult): array + { + if ($toolExecutionResult instanceof Content) { + return [$toolExecutionResult]; + } + + if (is_array($toolExecutionResult)) { + if (empty($toolExecutionResult)) { + return [TextContent::make('[]')]; + } + + $allAreContent = true; + $hasContent = false; + + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $hasContent = true; + } else { + $allAreContent = false; + } + } + + if ($allAreContent && $hasContent) { + return $toolExecutionResult; + } + + if ($hasContent) { + $result = []; + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } + return $result; + } + } + + if ($toolExecutionResult === null) { + return [TextContent::make('(null)')]; + } + + if (is_bool($toolExecutionResult)) { + return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; + } + + if (is_scalar($toolExecutionResult)) { + return [TextContent::make($toolExecutionResult)]; + } + + $jsonResult = json_encode( + $toolExecutionResult, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE + ); + + return [TextContent::make($jsonResult)]; + } + + public function toArray(): array + { + return [ + 'schema' => $this->schema->toArray(), + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Tool::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + ); + } catch (Throwable $e) { + return false; + } + } +} diff --git a/src/Exception/DefinitionException.php b/src/Exception/DefinitionException.php deleted file mode 100644 index 16f2653..0000000 --- a/src/Exception/DefinitionException.php +++ /dev/null @@ -1,15 +0,0 @@ -code >= -32768 && $this->code <= -32000) ? $this->code : self::CODE_INTERNAL_ERROR; - - return new JsonRpcError($code, $this->getMessage(), $this->getData()); + $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR; + + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: $code, + message: $this->getMessage(), + data: $this->getData() + ); } - // --- Static Factory Methods for Common JSON-RPC Errors --- - public static function parseError(string $details, ?Throwable $previous = null): self { - return new ProtocolException('Parse error: '.$details, self::CODE_PARSE_ERROR, null, $previous); + return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous); } public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self { - return new ProtocolException($details, self::CODE_INVALID_REQUEST, null, $previous); + return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous); } - public static function methodNotFound(string $methodName, ?Throwable $previous = null): self + public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self { - return new ProtocolException("Method not found: {$methodName}", self::CODE_METHOD_NOT_FOUND, null, $previous); + return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous); } public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self { // Pass data (e.g., validation errors) through - return new ProtocolException($message, self::CODE_INVALID_PARAMS, $data, $previous); + return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous); } public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self { $message = 'Internal error'; if ($details && is_string($details)) { - $message .= ': '.$details; + $message .= ': ' . $details; } elseif ($previous && $details === null) { $message .= ' (See server logs)'; } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self { $message = "Execution failed for tool '{$toolName}'"; if ($previous) { - $message .= ': '.$previous->getMessage(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self { $message = "Failed to read resource '{$uri}'"; if ($previous) { - $message .= ': '.$previous->getMessage(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self { $message = "Failed to generate prompt '{$promptName}'"; if ($previous) { - $message .= ': '.$previous->getMessage(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } } diff --git a/src/Exception/ProtocolException.php b/src/Exception/ProtocolException.php index 95f76cc..32cf04c 100644 --- a/src/Exception/ProtocolException.php +++ b/src/Exception/ProtocolException.php @@ -4,6 +4,8 @@ namespace PhpMcp\Server\Exception; +use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; + /** * Exception related to violations of the JSON-RPC 2.0 or MCP structure * in incoming messages or outgoing responses (e.g., missing required fields, @@ -11,17 +13,16 @@ */ class ProtocolException extends McpServerException { - // This exception often corresponds directly to JSON-RPC error codes. - // The factory methods in McpServerException can assign appropriate codes. - - public function toJsonRpcError(): \PhpMcp\Server\JsonRpc\Error + public function toJsonRpcError(string|int $id): JsonRpcError { $code = ($this->code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST; - return new \PhpMcp\Server\JsonRpc\Error( - $code, - $this->getMessage(), - $this->getData() + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: $code, + message: $this->getMessage(), + data: $this->getData() ); } } diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php index 695bc7a..293cda3 100644 --- a/src/Exception/TransportException.php +++ b/src/Exception/TransportException.php @@ -4,20 +4,22 @@ namespace PhpMcp\Server\Exception; +use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; + /** * Exception related to errors in the underlying transport layer * (e.g., socket errors, process management issues, SSE stream errors). */ class TransportException extends McpServerException { - // Usually indicates an internal server error if it prevents request processing. - public function toJsonRpcError(): \PhpMcp\Server\JsonRpc\Error + public function toJsonRpcError(string|int $id): JsonRpcError { - // Override to ensure it maps to internal error for JSON-RPC responses - return new \PhpMcp\Server\JsonRpc\Error( - self::CODE_INTERNAL_ERROR, - 'Transport layer error: '.$this->getMessage(), - null + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: JsonRpcError::CODE_INTERNAL_ERROR, + message: 'Transport layer error: ' . $this->getMessage(), + data: null ); } } diff --git a/src/JsonRpc/Batch.php b/src/JsonRpc/Batch.php deleted file mode 100644 index 988bb4d..0000000 --- a/src/JsonRpc/Batch.php +++ /dev/null @@ -1,116 +0,0 @@ - - */ - private array $requests = []; - - /** - * Create a new JSON-RPC 2.0 batch of requests/notifications. - * - * @param array $requests Optional array of requests to initialize with - */ - public function __construct(array $requests = []) - { - foreach ($requests as $request) { - $this->addRequest($request); - } - } - - /** - * Create a Batch object from an array representation. - * - * @param array $data Raw decoded JSON-RPC batch data - * - * @throws McpError If the data doesn't conform to JSON-RPC 2.0 batch structure - */ - public static function fromArray(array $data): self - { - if (empty($data)) { - throw ProtocolException::invalidRequest('A batch must contain at least one request.'); - } - - $batch = new self(); - - foreach ($data as $item) { - if (! is_array($item)) { - throw ProtocolException::invalidRequest('Each item in a batch must be a valid JSON-RPC object.'); - } - - // Determine if the item is a notification (no id) or a request - if (! isset($item['id'])) { - $batch->addRequest(Notification::fromArray($item)); - } else { - $batch->addRequest(Request::fromArray($item)); - } - } - - return $batch; - } - - /** - * Add a request or notification to the batch. - * - * @param Request|Notification $request The request to add - */ - public function addRequest(Request|Notification $request): self - { - $this->requests[] = $request; - - return $this; - } - - /** - * Get all requests in this batch. - * - * @return array - */ - public function getRequests(): array - { - return $this->requests; - } - - /** - * Get only the requests with IDs (excludes notifications). - * - * @return array - */ - public function getRequestsWithIds(): array - { - return array_filter($this->requests, fn ($r) => ! $r instanceof Notification); - } - - /** - * Get only the notifications (requests without IDs). - * - * @return array - */ - public function getNotifications(): array - { - return array_filter($this->requests, fn ($r) => $r instanceof Notification); - } - - /** - * Count the total number of requests in this batch. - */ - public function count(): int - { - return count($this->requests); - } - - /** - * Convert the batch to an array. - */ - public function toArray(): array - { - return array_map(fn ($r) => $r->toArray(), $this->requests); - } -} diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php deleted file mode 100644 index cb065ad..0000000 --- a/src/JsonRpc/Contents/AudioContent.php +++ /dev/null @@ -1,90 +0,0 @@ -data; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'audio'; - } - - /** - * Convert the content to an array. - * - * @return array{type: string, data: string, mimeType: string} - */ - public function toArray(): array - { - return [ - 'type' => 'audio', - 'data' => $this->data, - 'mimeType' => $this->mimeType, - ]; - } - - /** - * Create a new AudioContent from a file path. - * - * @param string $path Path to the audio file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $path, ?string $mimeType = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("Audio file not found: {$path}"); - } - - $data = base64_encode(file_get_contents($path)); - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'audio/mpeg'; - - return new static($data, $detectedMime); - } - - /** - * Create a new AudioContent from raw binary data. - * - * @param string $binaryData Raw binary audio data - * @param string $mimeType MIME type of the audio - */ - public static function fromBinary(string $binaryData, string $mimeType): static - { - return new static(base64_encode($binaryData), $mimeType); - } -} diff --git a/src/JsonRpc/Contents/Content.php b/src/JsonRpc/Contents/Content.php deleted file mode 100644 index dc60d3c..0000000 --- a/src/JsonRpc/Contents/Content.php +++ /dev/null @@ -1,26 +0,0 @@ -toArray(); - } -} diff --git a/src/JsonRpc/Contents/EmbeddedResource.php b/src/JsonRpc/Contents/EmbeddedResource.php deleted file mode 100644 index fca392f..0000000 --- a/src/JsonRpc/Contents/EmbeddedResource.php +++ /dev/null @@ -1,176 +0,0 @@ -uri; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the text content. - */ - public function getText(): ?string - { - return $this->text; - } - - /** - * Get the binary data. - */ - public function getBlob(): ?string - { - return $this->blob; - } - - /** - * Check if the resource has text content. - */ - public function hasText(): bool - { - return $this->text !== null; - } - - /** - * Check if the resource has binary content. - */ - public function hasBlob(): bool - { - return $this->blob !== null; - } - - /** - * Convert the resource to an array. - */ - public function toArray(): array - { - $resource = [ - 'uri' => $this->uri, - 'mimeType' => $this->mimeType, - ]; - - if ($this->text !== null) { - $resource['text'] = $this->text; - } elseif ($this->blob !== null) { - $resource['blob'] = $this->blob; - } - - return $resource; - } - - /** - * Determines if the given MIME type is likely to be text-based. - * - * @param string $mimeType The MIME type to check - */ - private static function isTextMimeType(string $mimeType): bool - { - return str_starts_with($mimeType, 'text/') || - in_array($mimeType, ['application/json', 'application/xml', 'application/javascript', 'application/yaml']); - } - - /** - * Create a new EmbeddedResource from a file path. - * - * @param string $uri The URI for the resource - * @param string $path Path to the file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $uri, string $path, ?string $mimeType = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("File not found: {$path}"); - } - - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'application/octet-stream'; - $content = file_get_contents($path); - - // Decide if we should use text or blob based on the mime type - if (self::isTextMimeType($detectedMime)) { - return new static($uri, $detectedMime, $content); - } else { - return new static($uri, $detectedMime, null, base64_encode($content)); - } - } - - /** - * Create from a stream resource. - * - * @param string $uri The URI for the resource - * @param resource $stream The stream resource - * @param string $mimeType MIME type of the content - * - * @throws \InvalidArgumentException If the parameter is not a stream resource - */ - public static function fromStream(string $uri, $stream, string $mimeType): static - { - if (! is_resource($stream) || get_resource_type($stream) !== 'stream') { - throw new \InvalidArgumentException('Expected a stream resource'); - } - - $content = stream_get_contents($stream); - - // Determine if this is text based on mime type - if (self::isTextMimeType($mimeType)) { - return new static($uri, $mimeType, $content); - } else { - return new static($uri, $mimeType, null, base64_encode($content)); - } - } - - /** - * Create from an SplFileInfo object. - * - * @param string $uri The URI for the resource - * @param \SplFileInfo $file The file object - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file is not readable - */ - public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static - { - if (! $file->isReadable()) { - throw new \InvalidArgumentException("File is not readable: {$file->getPathname()}"); - } - - return self::fromFile($uri, $file->getPathname(), $mimeType); - } -} diff --git a/src/JsonRpc/Contents/ImageContent.php b/src/JsonRpc/Contents/ImageContent.php deleted file mode 100644 index 2059c99..0000000 --- a/src/JsonRpc/Contents/ImageContent.php +++ /dev/null @@ -1,90 +0,0 @@ -data; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'image'; - } - - /** - * Convert the content to an array. - * - * @return array{type: string, data: string, mimeType: string} - */ - public function toArray(): array - { - return [ - 'type' => 'image', - 'data' => $this->data, - 'mimeType' => $this->mimeType, - ]; - } - - /** - * Create a new ImageContent from a file path. - * - * @param string $path Path to the image file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $path, ?string $mimeType = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("Image file not found: {$path}"); - } - - $data = base64_encode(file_get_contents($path)); - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'image/png'; - - return new static($data, $detectedMime); - } - - /** - * Create a new ImageContent from raw binary data. - * - * @param string $binaryData Raw binary image data - * @param string $mimeType MIME type of the image - */ - public static function fromBinary(string $binaryData, string $mimeType): static - { - return new static(base64_encode($binaryData), $mimeType); - } -} diff --git a/src/JsonRpc/Contents/PromptMessage.php b/src/JsonRpc/Contents/PromptMessage.php deleted file mode 100644 index e4e3608..0000000 --- a/src/JsonRpc/Contents/PromptMessage.php +++ /dev/null @@ -1,94 +0,0 @@ -role; - } - - /** - * Get the content. - */ - public function getContent(): Content - { - return $this->content; - } - - /** - * Convert the message to an array. - * - * @return array{role: string, content: array} - */ - public function toArray(): array - { - return [ - 'role' => $this->role, - 'content' => $this->content->toArray(), - ]; - } - - /** - * Create a new user message with text content. - * - * @param string $text The message text - */ - public static function user(string $text): static - { - return new static('user', new TextContent($text)); - } - - /** - * Create a new assistant message with text content. - * - * @param string $text The message text - */ - public static function assistant(string $text): static - { - return new static('assistant', new TextContent($text)); - } - - /** - * Create a new user message with any content type. - * - * @param Content $content The message content - */ - public static function userWithContent(Content $content): static - { - return new static('user', $content); - } - - /** - * Create a new assistant message with any content type. - * - * @param Content $content The message content - */ - public static function assistantWithContent(Content $content): static - { - return new static('assistant', $content); - } -} diff --git a/src/JsonRpc/Contents/ResourceContent.php b/src/JsonRpc/Contents/ResourceContent.php deleted file mode 100644 index 60ef418..0000000 --- a/src/JsonRpc/Contents/ResourceContent.php +++ /dev/null @@ -1,114 +0,0 @@ -resource; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'resource'; - } - - /** - * Convert the content to an array. - * - * @return array{type: string, resource: array} - */ - public function toArray(): array - { - return [ - 'type' => 'resource', - 'resource' => $this->resource->toArray(), - ]; - } - - /** - * Create a new ResourceContent from a file path. - * - * @param string $uri The URI for the resource - * @param string $path Path to the file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $uri, string $path, ?string $mimeType = null): static - { - return new static(EmbeddedResource::fromFile($uri, $path, $mimeType)); - } - - /** - * Create from a stream resource. - * - * @param string $uri The URI for the resource - * @param resource $stream The stream resource - * @param string $mimeType MIME type of the content - * - * @throws \InvalidArgumentException If the parameter is not a stream resource - */ - public static function fromStream(string $uri, $stream, string $mimeType): static - { - return new static(EmbeddedResource::fromStream($uri, $stream, $mimeType)); - } - - /** - * Create from an SplFileInfo object. - * - * @param string $uri The URI for the resource - * @param \SplFileInfo $file The file object - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file is not readable - */ - public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static - { - return new static(EmbeddedResource::fromSplFileInfo($uri, $file, $mimeType)); - } - - /** - * Create a text resource content. - * - * @param string $uri The URI for the resource - * @param string $text The text content - * @param string $mimeType MIME type of the content - */ - public static function text(string $uri, string $text, string $mimeType = 'text/plain'): static - { - return new static(new EmbeddedResource($uri, $mimeType, $text)); - } - - /** - * Create a binary resource content. - * - * @param string $uri The URI for the resource - * @param string $binaryData The binary data (will be base64 encoded) - * @param string $mimeType MIME type of the content - */ - public static function binary(string $uri, string $binaryData, string $mimeType): static - { - return new static(new EmbeddedResource($uri, $mimeType, null, base64_encode($binaryData))); - } -} diff --git a/src/JsonRpc/Contents/TextContent.php b/src/JsonRpc/Contents/TextContent.php deleted file mode 100644 index bf3a2d2..0000000 --- a/src/JsonRpc/Contents/TextContent.php +++ /dev/null @@ -1,75 +0,0 @@ -text; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'text'; - } - - /** - * Convert the content to an array. - * - * @return array{type: string, text: string} - */ - public function toArray(): array - { - return [ - 'type' => 'text', - 'text' => $this->text, - ]; - } - - /** - * Create a new TextContent from any simple value. - * - * @param mixed $value The value to convert to text - */ - public static function make(mixed $value): static - { - if (is_array($value) || is_object($value)) { - $text = json_encode($value, JSON_PRETTY_PRINT); - - return new static($text); - } - - return new static((string) $value); - } - - /** - * Create a new TextContent with markdown formatted code. - * - * @param string $code The code to format - * @param string $language The language for syntax highlighting - */ - public static function code(string $code, string $language = ''): static - { - return new static("```{$language}\n{$code}\n```"); - } -} diff --git a/src/JsonRpc/Error.php b/src/JsonRpc/Error.php deleted file mode 100644 index 2cf28b0..0000000 --- a/src/JsonRpc/Error.php +++ /dev/null @@ -1,57 +0,0 @@ - $this->code, - 'message' => $this->message, - ]; - - if ($this->data !== null) { - $result['data'] = $this->data; - } - - return $result; - } -} diff --git a/src/JsonRpc/Message.php b/src/JsonRpc/Message.php deleted file mode 100644 index 70e75f2..0000000 --- a/src/JsonRpc/Message.php +++ /dev/null @@ -1,25 +0,0 @@ - $this->jsonrpc, - ]; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Notification.php b/src/JsonRpc/Notification.php deleted file mode 100644 index cea9ceb..0000000 --- a/src/JsonRpc/Notification.php +++ /dev/null @@ -1,76 +0,0 @@ - $this->jsonrpc, - 'method' => $this->method, - ]; - - if (! empty($this->params)) { - $result['params'] = $this->params; - } - - return $result; - } -} diff --git a/src/JsonRpc/Request.php b/src/JsonRpc/Request.php deleted file mode 100644 index 5083c89..0000000 --- a/src/JsonRpc/Request.php +++ /dev/null @@ -1,83 +0,0 @@ - $this->jsonrpc, - 'id' => $this->id, - 'method' => $this->method, - ]; - - if (! empty($this->params)) { - $result['params'] = $this->params; - } - - return $result; - } -} diff --git a/src/JsonRpc/Response.php b/src/JsonRpc/Response.php deleted file mode 100644 index 6cea195..0000000 --- a/src/JsonRpc/Response.php +++ /dev/null @@ -1,175 +0,0 @@ -id !== null && $this->result !== null && $this->error !== null) { - throw new \InvalidArgumentException('A JSON-RPC response with an ID cannot have both result and error.'); - } - - // A response with an ID MUST have either result or error - if ($this->id !== null && $this->result === null && $this->error === null) { - throw new \InvalidArgumentException('A JSON-RPC response with an ID must have either result or error.'); - } - - // A response with null ID MUST have an error and MUST NOT have result - if ($this->id === null && $this->error === null) { - throw new \InvalidArgumentException('A JSON-RPC response with null ID must have an error object.'); - } - - if ($this->id === null && $this->result !== null) { - throw new \InvalidArgumentException('A JSON-RPC response with null ID cannot have a result field.'); - } - } - - /** - * Create a Response object from an array representation. - * - * @param array $data Raw decoded JSON-RPC response data - * - * @throws ProtocolException If the data doesn't conform to JSON-RPC 2.0 structure - */ - public static function fromArray(array $data): self - { - if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw new ProtocolException('Invalid or missing "jsonrpc" version. Must be "2.0".'); - } - - // ID must exist for valid responses, but can be null for specific error cases - // We rely on the constructor validation logic for the result/error/id combinations - $id = $data['id'] ?? null; // Default to null if missing - if (! (is_string($id) || is_int($id) || $id === null)) { - throw new ProtocolException('Invalid "id" field type in response.'); - } - - $hasResult = array_key_exists('result', $data); - $hasError = array_key_exists('error', $data); - - if ($id !== null) { // If ID is present, standard validation applies - if ($hasResult && $hasError) { - throw new ProtocolException('Invalid response: contains both "result" and "error".'); - } - if (! $hasResult && ! $hasError) { - throw new ProtocolException('Invalid response: must contain either "result" or "error" when ID is present.'); - } - } else { // If ID is null, error MUST be present, result MUST NOT - if (! $hasError) { - throw new ProtocolException('Invalid response: must contain "error" when ID is null.'); - } - if ($hasResult) { - throw new ProtocolException('Invalid response: must not contain "result" when ID is null.'); - } - } - - $error = null; - $result = null; // Keep result structure flexible (any JSON type) - - if ($hasError) { - if (! is_array($data['error'])) { // Error MUST be an object - throw new ProtocolException('Invalid "error" field in response: must be an object.'); - } - try { - $error = Error::fromArray($data['error']); - } catch (ProtocolException $e) { - // Wrap error from Error::fromArray for context - throw new ProtocolException('Invalid "error" object structure in response: '.$e->getMessage(), 0, $e); - } - } elseif ($hasResult) { - $result = $data['result']; // Result can be anything - } - - try { - // The constructor now handles the final validation of id/result/error combinations - return new self('2.0', $id, $result, $error); - } catch (\InvalidArgumentException $e) { - // Convert constructor validation error to ProtocolException - throw new ProtocolException('Invalid response structure: '.$e->getMessage()); - } - } - - /** - * Create a successful response. - * - * @param Result $result Method result - can be a Result object or array - * @param mixed $id Request ID - */ - public static function success(Result $result, mixed $id): self - { - return new self(jsonrpc: '2.0', result: $result, id: $id); - } - - /** - * Create an error response. - * - * @param Error $error Error object - * @param string|int|null $id Request ID (can be null for parse errors) - */ - public static function error(Error $error, string|int|null $id): self - { - return new self(jsonrpc: '2.0', error: $error, id: $id); - } - - /** - * Check if this response is a success response. - */ - public function isSuccess(): bool - { - return $this->error === null; - } - - /** - * Check if this response is an error response. - */ - public function isError(): bool - { - return $this->error !== null; - } - - /** - * Convert the response to an array. - */ - public function toArray(): array - { - $result = [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - ]; - - if ($this->isSuccess()) { - $result['result'] = is_array($this->result) ? $this->result : $this->result->toArray(); - } else { - $result['error'] = $this->error->toArray(); - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Result.php b/src/JsonRpc/Result.php deleted file mode 100644 index bb1effd..0000000 --- a/src/JsonRpc/Result.php +++ /dev/null @@ -1,26 +0,0 @@ -toArray(); - } - - /** - * Convert the result object to an array. - * - * @return array - */ - abstract public function toArray(): array; -} diff --git a/src/JsonRpc/Results/CallToolResult.php b/src/JsonRpc/Results/CallToolResult.php deleted file mode 100644 index 37f1fe3..0000000 --- a/src/JsonRpc/Results/CallToolResult.php +++ /dev/null @@ -1,50 +0,0 @@ -content; - } - - /** - * Check if the tool execution resulted in an error. - */ - public function isError(): bool - { - return $this->isError; - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - return [ - 'content' => array_map(fn ($item) => $item->toArray(), $this->content), - 'isError' => $this->isError, - ]; - } -} diff --git a/src/JsonRpc/Results/EmptyResult.php b/src/JsonRpc/Results/EmptyResult.php deleted file mode 100644 index 0d650ae..0000000 --- a/src/JsonRpc/Results/EmptyResult.php +++ /dev/null @@ -1,26 +0,0 @@ -messages; - } - - /** - * Get the description of the prompt. - */ - public function getDescription(): ?string - { - return $this->description; - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - $result = [ - 'messages' => array_map(fn ($message) => $message->toArray(), $this->messages), - ]; - - if ($this->description !== null) { - $result['description'] = $this->description; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/InitializeResult.php b/src/JsonRpc/Results/InitializeResult.php deleted file mode 100644 index 39d08fe..0000000 --- a/src/JsonRpc/Results/InitializeResult.php +++ /dev/null @@ -1,42 +0,0 @@ - $this->serverInfo, - 'protocolVersion' => $this->protocolVersion, - 'capabilities' => $this->capabilities, - ]; - - if ($this->instructions !== null) { - $result['instructions'] = $this->instructions; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/ListPromptsResult.php b/src/JsonRpc/Results/ListPromptsResult.php deleted file mode 100644 index 212354c..0000000 --- a/src/JsonRpc/Results/ListPromptsResult.php +++ /dev/null @@ -1,32 +0,0 @@ - $prompts The list of prompt definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $prompts, - public readonly ?string $nextCursor = null - ) { - } - - public function toArray(): array - { - $result = [ - 'prompts' => array_map(fn (PromptDefinition $p) => $p->toArray(), $this->prompts), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/ListResourceTemplatesResult.php b/src/JsonRpc/Results/ListResourceTemplatesResult.php deleted file mode 100644 index 3494c62..0000000 --- a/src/JsonRpc/Results/ListResourceTemplatesResult.php +++ /dev/null @@ -1,35 +0,0 @@ - $resourceTemplates The list of resource template definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $resourceTemplates, - public readonly ?string $nextCursor = null - ) { - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - $result = [ - 'resourceTemplates' => array_map(fn (ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/ListResourcesResult.php b/src/JsonRpc/Results/ListResourcesResult.php deleted file mode 100644 index 4e29368..0000000 --- a/src/JsonRpc/Results/ListResourcesResult.php +++ /dev/null @@ -1,35 +0,0 @@ - $resources The list of resource definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $resources, - public readonly ?string $nextCursor = null - ) { - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - $result = [ - 'resources' => array_map(fn (ResourceDefinition $r) => $r->toArray(), $this->resources), - ]; - - if ($this->nextCursor !== null) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/ListToolsResult.php b/src/JsonRpc/Results/ListToolsResult.php deleted file mode 100644 index 90404ee..0000000 --- a/src/JsonRpc/Results/ListToolsResult.php +++ /dev/null @@ -1,32 +0,0 @@ - $tools The list of tool definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $tools, - public readonly ?string $nextCursor = null - ) { - } - - public function toArray(): array - { - $result = [ - 'tools' => array_map(fn (ToolDefinition $t) => $t->toArray(), $this->tools), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } -} diff --git a/src/JsonRpc/Results/ReadResourceResult.php b/src/JsonRpc/Results/ReadResourceResult.php deleted file mode 100644 index 495fe33..0000000 --- a/src/JsonRpc/Results/ReadResourceResult.php +++ /dev/null @@ -1,39 +0,0 @@ -contents; - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - return [ - 'contents' => array_map(fn ($resource) => $resource->toArray(), $this->contents), - ]; - } -} diff --git a/src/Model/Capabilities.php b/src/Model/Capabilities.php deleted file mode 100644 index 1b8583f..0000000 --- a/src/Model/Capabilities.php +++ /dev/null @@ -1,112 +0,0 @@ -|null $experimental Optional experimental capabilities declared by the server. - */ - public static function forServer( - bool $toolsEnabled = true, - bool $toolsListChanged = false, - bool $resourcesEnabled = true, - bool $resourcesSubscribe = false, - bool $resourcesListChanged = false, - bool $promptsEnabled = true, - bool $promptsListChanged = false, - bool $loggingEnabled = false, - ?string $instructions = null, - ?array $experimental = null - ): self { - return new self( - toolsEnabled: $toolsEnabled, - toolsListChanged: $toolsListChanged, - resourcesEnabled: $resourcesEnabled, - resourcesSubscribe: $resourcesSubscribe, - resourcesListChanged: $resourcesListChanged, - promptsEnabled: $promptsEnabled, - promptsListChanged: $promptsListChanged, - loggingEnabled: $loggingEnabled, - instructions: $instructions, - experimental: $experimental - ); - } - - /** - * Converts server capabilities to the array format expected in the - * 'initialize' response payload. Returns stdClass if all are disabled/default. - */ - public function toInitializeResponseArray(): array|stdClass - { - $data = []; - - // Only include capability keys if the main capability is enabled - if ($this->toolsEnabled) { - $data['tools'] = $this->toolsListChanged ? ['listChanged' => true] : new stdClass(); - } - if ($this->resourcesEnabled) { - $resCaps = []; - if ($this->resourcesSubscribe) { - $resCaps['subscribe'] = true; - } - if ($this->resourcesListChanged) { - $resCaps['listChanged'] = true; - } - $data['resources'] = ! empty($resCaps) ? $resCaps : new stdClass(); - } - if ($this->promptsEnabled) { - $data['prompts'] = $this->promptsListChanged ? ['listChanged' => true] : new stdClass(); - } - if ($this->loggingEnabled) { - $data['logging'] = new stdClass(); - } - if ($this->experimental !== null && ! empty($this->experimental)) { - $data['experimental'] = $this->experimental; - } - - // Return empty object if no capabilities are effectively enabled/declared - // This might deviate slightly from spec if e.g. only 'tools' is true but listChanged is false, - // spec implies {'tools': {}} should still be sent. Let's keep it simple for now. - // Correction: Spec implies the key should exist if the capability is enabled. - // Let's ensure keys are present if the *Enabled flag is true. - return empty($data) ? new stdClass() : $data; - } -} diff --git a/src/Protocol.php b/src/Protocol.php index 643f8f0..daf2194 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -4,23 +4,32 @@ namespace PhpMcp\Server; -use JsonException; +use PhpMcp\Schema\Constants; use PhpMcp\Server\Contracts\ServerTransportInterface; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\Exception\ProtocolException; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\RequestProcessor; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\BatchResponse; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Notification\PromptListChangedNotification; +use PhpMcp\Schema\Notification\ResourceListChangedNotification; +use PhpMcp\Schema\Notification\ResourceUpdatedNotification; +use PhpMcp\Schema\Notification\RootsListChangedNotification; +use PhpMcp\Schema\Notification\ToolListChangedNotification; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Session\SubscriptionManager; use Psr\Log\LoggerInterface; use React\Promise\PromiseInterface; use Throwable; use function React\Promise\reject; +use function React\Promise\resolve; /** - * Bridges the core MCP Processor/Registry/State logic with a ServerTransportInterface + * Bridges the core MCP Processor logic with a ServerTransportInterface * by listening to transport events and processing incoming messages. * * This handler manages the JSON-RPC parsing, processing delegation, and response sending @@ -28,25 +37,34 @@ */ class Protocol { + public const LATEST_PROTOCOL_VERSION = '2025-03-26'; + public const SUPPORTED_PROTOCOL_VERSIONS = [self::LATEST_PROTOCOL_VERSION, '2024-11-05']; + protected ?ServerTransportInterface $transport = null; + protected LoggerInterface $logger; /** Stores listener references for proper removal */ protected array $listeners = []; public function __construct( - protected readonly Configuration $configuration, - protected readonly Registry $registry, - protected readonly ClientStateManager $clientStateManager, - protected ?RequestProcessor $requestProcessor = null, + protected Configuration $configuration, + protected Registry $registry, + protected SessionManager $sessionManager, + protected ?Dispatcher $dispatcher = null, + protected ?SubscriptionManager $subscriptionManager = null, ) { - $this->requestProcessor ??= new RequestProcessor( - $configuration, - $registry, - $clientStateManager, - ); + $this->logger = $this->configuration->logger; + $this->subscriptionManager ??= new SubscriptionManager($this->logger); + $this->dispatcher ??= new Dispatcher($this->configuration, $this->registry, $this->subscriptionManager); - $this->logger = $configuration->logger; + $this->sessionManager->on('session_deleted', function (string $sessionId) { + $this->subscriptionManager->cleanupSession($sessionId); + }); + + $this->registry->on('list_changed', function (string $listType) { + $this->handleListChanged($listType); + }); } /** @@ -62,7 +80,7 @@ public function bindTransport(ServerTransportInterface $transport): void $this->transport = $transport; $this->listeners = [ - 'message' => [$this, 'handleRawMessage'], + 'message' => [$this, 'processMessage'], 'client_connected' => [$this, 'handleClientConnected'], 'client_disconnected' => [$this, 'handleClientDisconnected'], 'error' => [$this, 'handleTransportError'], @@ -91,145 +109,346 @@ public function unbindTransport(): void } /** - * Handles a raw message frame received from the transport. + * Handles a message received from the transport. * - * Parses JSON, validates structure, processes via Processor, sends Response/Error. + * Processes via Processor, sends Response/Error. + */ + public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $context = []): void + { + $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]); + + $session = $this->sessionManager->getSession($sessionId); + + if ($session === null) { + $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id); + $context['status_code'] = 404; + + $this->transport->sendMessage($error, $sessionId, $context) + ->then(function () use ($sessionId, $error, $context) { + $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $context]); + }) + ->catch(function (Throwable $e) use ($sessionId, $error, $context) { + $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + }); + + return; + } + + $response = null; + + if ($message instanceof BatchRequest) { + $response = $this->processBatchRequest($message, $session); + } elseif ($message instanceof Request) { + $response = $this->processRequest($message, $session); + } elseif ($message instanceof Notification) { + $this->processNotification($message, $session); + } + + $session->save(); + + if ($response === null) { + return; + } + + $this->transport->sendMessage($response, $sessionId, $context) + ->then(function () use ($sessionId, $response) { + $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $response]); + }) + ->catch(function (Throwable $e) use ($sessionId) { + $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + }); + } + + /** + * Process a batch message */ - public function handleRawMessage(string $rawJsonRpcFrame, string $clientId): void + private function processBatchRequest(BatchRequest $batch, SessionInterface $session): ?BatchResponse { - $this->logger->debug('Received message', ['clientId' => $clientId, 'frame' => $rawJsonRpcFrame]); - $responseToSend = null; - $parsedMessage = null; - $messageData = null; + $items = []; + + foreach ($batch->getNotifications() as $notification) { + $this->processNotification($notification, $session); + } + + foreach ($batch->getRequests() as $request) { + $items[] = $this->processRequest($request, $session); + } + + return empty($items) ? null : new BatchResponse($items); + } + /** + * Process a request message + */ + private function processRequest(Request $request, SessionInterface $session): Response|Error + { try { - $messageData = json_decode($rawJsonRpcFrame, true, 512, JSON_THROW_ON_ERROR); - if (! is_array($messageData)) { - throw new ProtocolException('Invalid JSON received (not an object/array).'); + if ($request->method !== 'initialize') { + $this->assertSessionInitialized($session); } - $parsedMessage = $this->parseMessageData($messageData); + $this->assertRequestCapability($request->method); - if ($parsedMessage === null) { - throw McpServerException::invalidRequest('Invalid MCP/JSON-RPC message structure.'); - } + $result = $this->dispatcher->handleRequest($request, $session); - $responseToSend = $this->requestProcessor->process($parsedMessage, $clientId); - } catch (JsonException $e) { - $this->logger->error("JSON Parse Error for client {$clientId}", ['error' => $e->getMessage()]); - $responseToSend = Response::error(McpServerException::parseError($e->getMessage())->toJsonRpcError(), null); + return Response::make($request->id, $result); } catch (McpServerException $e) { - $this->logger->warning("MCP Exception during processing for client {$clientId}", ['code' => $e->getCode(), 'error' => $e->getMessage()]); - $id = $this->getRequestId($parsedMessage, $messageData); - $responseToSend = Response::error($e->toJsonRpcError(), $id); + $this->logger->debug('MCP Processor caught McpServerException', ['method' => $request->method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); + + return $e->toJsonRpcError($request->id); } catch (Throwable $e) { - $this->logger->error("Unexpected processing error for client {$clientId}", ['exception' => $e]); - $id = $this->getRequestId($parsedMessage, $messageData); - $responseToSend = Response::error(McpServerException::internalError()->toJsonRpcError(), $id); + $this->logger->error('MCP Processor caught unexpected error', [ + 'method' => $request->method, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return new Error( + jsonrpc: '2.0', + id: $request->id, + code: Constants::INTERNAL_ERROR, + message: 'Internal error processing method ' . $request->method, + data: $e->getMessage() + ); } + } + + /** + * Process a notification message + */ + private function processNotification(Notification $notification, SessionInterface $session): void + { + $method = $notification->method; + $params = $notification->params; - if ($responseToSend instanceof Response) { - $this->sendResponse($clientId, $responseToSend); - } elseif ($parsedMessage instanceof Request && $responseToSend === null) { - $this->logger->error('Processor failed to return a Response for a Request', ['clientId' => $clientId, 'method' => $parsedMessage->method, 'id' => $parsedMessage->id]); - $responseToSend = Response::error(McpServerException::internalError('Processing failed to generate a response.')->toJsonRpcError(), $parsedMessage->id); - $this->sendResponse($clientId, $responseToSend); + try { + $this->dispatcher->handleNotification($notification, $session); + } catch (Throwable $e) { + $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]); + return; } } /** - * Safely gets the request ID from potentially parsed or raw message data + * Send a notification to a session */ - private function getRequestId(Request|Notification|null $parsed, ?array $rawData): string|int|null + public function sendNotification(Notification $notification, string $sessionId): PromiseInterface { - if ($parsed instanceof Request) { - return $parsed->id; - } - if (is_array($rawData) && isset($rawData['id']) && (is_string($rawData['id']) || is_int($rawData['id']))) { - return $rawData['id']; + if ($this->transport === null) { + $this->logger->error('Cannot send notification, transport not bound', [ + 'sessionId' => $sessionId, + 'method' => $notification->method + ]); + return reject(new McpServerException('Transport not bound')); } - return null; + return $this->transport->sendMessage($notification, $sessionId, []) + ->then(function () { + return resolve(null); + }) + ->catch(function (Throwable $e) { + return reject(new McpServerException('Failed to send notification: ' . $e->getMessage(), previous: $e)); + }); } /** - * Sends a Response object via the transport + * Notify subscribers about resource content change */ - private function sendResponse(string $clientId, Response $response): void + public function notifyResourceUpdated(string $uri): void { - if ($this->transport === null) { - $this->logger->error('Cannot send response, transport is not bound.', ['clientId' => $clientId]); + $subscribers = $this->subscriptionManager->getSubscribers($uri); + if (empty($subscribers)) { return; } - try { - $responseData = $response->toArray(); - $jsonResponse = json_encode($responseData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $framedMessage = $jsonResponse . "\n"; - - $this->transport->sendToClientAsync($clientId, $framedMessage) - ->catch( - function (Throwable $e) use ($clientId, $response) { - $this->logger->error('Transport failed to send response.', [ - 'clientId' => $clientId, - 'responseId' => $response->id, - 'error' => $e->getMessage(), - ]); - } - ); - - $this->logger->debug('Sent response', ['clientId' => $clientId, 'frame' => $framedMessage]); - } catch (JsonException $e) { - $this->logger->error('Failed to encode response to JSON.', ['clientId' => $clientId, 'responseId' => $response->id, 'error' => $e->getMessage()]); - } catch (Throwable $e) { - $this->logger->error('Unexpected error during response preparation/sending.', ['clientId' => $clientId, 'responseId' => $response->id, 'exception' => $e]); + $notification = ResourceUpdatedNotification::make($uri); + + foreach ($subscribers as $sessionId) { + $this->sendNotification($notification, $sessionId); } + + $this->logger->debug("Sent resource change notification", [ + 'uri' => $uri, + 'subscriber_count' => count($subscribers) + ]); } /** - * Sends a Notification object via the transport to a specific client. + * Validate that a session is initialized */ - public function sendNotification(string $clientId, Notification $notification): PromiseInterface + private function assertSessionInitialized(SessionInterface $session): void { - if ($this->transport === null) { - $this->logger->error('Cannot send notification, transport not bound.', ['clientId' => $clientId]); + if (!$session->get('initialized', false)) { + throw McpServerException::invalidRequest('Client session not initialized.'); + } + } - return reject(new McpServerException('Transport not bound')); + /** + * Assert that a request method is enabled + */ + private function assertRequestCapability(string $method): void + { + $capabilities = $this->configuration->capabilities; + + switch ($method) { + case "ping": + case "initialize": + // No specific capability required for these methods + break; + + case 'tools/list': + case 'tools/call': + if (!$capabilities->tools) { + throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.'); + } + break; + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + if (!$capabilities->resources) { + throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); + } + break; + + case 'resources/subscribe': + case 'resources/unsubscribe': + if (!$capabilities->resources) { + throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); + } + if (!$capabilities->resourcesSubscribe) { + throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.'); + } + break; + + case 'prompts/list': + case 'prompts/get': + if (!$capabilities->prompts) { + throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.'); + } + break; + + case 'logging/setLevel': + if (!$capabilities->logging) { + throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); + } + break; + + case 'completion/complete': + if (!$capabilities->completions) { + throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.'); + } + break; + + default: + break; } - try { - $jsonNotification = json_encode($notification->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $framedMessage = $jsonNotification . "\n"; - $this->logger->debug('Sending notification', ['clientId' => $clientId, 'method' => $notification->method]); + } + + private function canSendNotification(string $method): bool + { + $capabilities = $this->configuration->capabilities; - return $this->transport->sendToClientAsync($clientId, $framedMessage); - } catch (JsonException $e) { - $this->logger->error('Failed to encode notification to JSON.', ['clientId' => $clientId, 'method' => $notification->method, 'error' => $e->getMessage()]); + $valid = true; - return reject(new McpServerException('Failed to encode notification: ' . $e->getMessage(), 0, $e)); - } catch (Throwable $e) { - $this->logger->error('Unexpected error sending notification.', ['clientId' => $clientId, 'method' => $notification->method, 'exception' => $e]); + switch ($method) { + case 'notifications/message': + if (!$capabilities->logging) { + $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.'); + $valid = false; + } + break; + + case "notifications/resources/updated": + case "notifications/resources/list_changed": + if (!$capabilities->resources || !$capabilities->resourcesListChanged) { + $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.'); + $valid = false; + } + break; - return reject(new McpServerException('Failed to send notification: ' . $e->getMessage(), 0, $e)); + case "notifications/tools/list_changed": + if (!$capabilities->tools || !$capabilities->toolsListChanged) { + $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.'); + $valid = false; + } + break; + + case "notifications/prompts/list_changed": + if (!$capabilities->prompts || !$capabilities->promptsListChanged) { + $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.'); + $valid = false; + } + break; + + case "notifications/cancelled": + // Cancellation notifications are always allowed + break; + + case "notifications/progress": + // Progress notifications are always allowed + break; + + default: + break; } + + return $valid; } /** * Handles 'client_connected' event from the transport */ - public function handleClientConnected(string $clientId): void + public function handleClientConnected(string $sessionId): void { - $this->logger->info('Client connected', ['clientId' => $clientId]); + $this->logger->info('Client connected', ['sessionId' => $sessionId]); + + $this->sessionManager->createSession($sessionId); } /** * Handles 'client_disconnected' event from the transport */ - public function handleClientDisconnected(string $clientId, ?string $reason = null): void + public function handleClientDisconnected(string $sessionId, ?string $reason = null): void + { + $this->logger->info('Client disconnected', ['clientId' => $sessionId, 'reason' => $reason ?? 'N/A']); + + $this->sessionManager->deleteSession($sessionId); + } + + /** + * Handle list changed event from registry + */ + public function handleListChanged(string $listType): void { - $this->logger->info('Client disconnected', ['clientId' => $clientId, 'reason' => $reason ?? 'N/A']); - $this->clientStateManager->cleanupClient($clientId); + $listChangeUri = "mcp://changes/{$listType}"; + + $subscribers = $this->subscriptionManager->getSubscribers($listChangeUri); + if (empty($subscribers)) { + return; + } + + $notification = match ($listType) { + 'resources' => ResourceListChangedNotification::make(), + 'tools' => ToolListChangedNotification::make(), + 'prompts' => PromptListChangedNotification::make(), + 'roots' => RootsListChangedNotification::make(), + default => throw new \InvalidArgumentException("Invalid list type: {$listType}"), + }; + + if (!$this->canSendNotification($notification->method)) { + return; + } + + foreach ($subscribers as $sessionId) { + $this->sendNotification($notification, $sessionId); + } + + $this->logger->debug("Sent list change notification", [ + 'list_type' => $listType, + 'subscriber_count' => count($subscribers) + ]); } /** @@ -242,31 +461,8 @@ public function handleTransportError(Throwable $error, ?string $clientId = null) if ($clientId) { $context['clientId'] = $clientId; $this->logger->error('Transport error for client', $context); - $this->clientStateManager->cleanupClient($clientId); } else { $this->logger->error('General transport error', $context); } } - - /** - * Parses raw array into Request or Notification - */ - private function parseMessageData(array $data): Request|Notification|null - { - try { - if (isset($data['method'])) { - if (isset($data['id']) && $data['id'] !== null) { - return Request::fromArray($data); - } else { - return Notification::fromArray($data); - } - } - } catch (ProtocolException $e) { - throw McpServerException::invalidRequest('Invalid JSON-RPC structure: ' . $e->getMessage(), $e); - } catch (Throwable $e) { - throw new ProtocolException('Unexpected error parsing message structure: ' . $e->getMessage(), McpServerException::CODE_PARSE_ERROR, null, $e); - } - - throw McpServerException::invalidRequest("Message must contain a 'method' field."); - } } diff --git a/src/Registry.php b/src/Registry.php index 281bf00..d2826a5 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -4,394 +4,289 @@ namespace PhpMcp\Server; -use ArrayObject; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; +use Evenement\EventEmitterInterface; +use Evenement\EventEmitterTrait; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredResource; +use PhpMcp\Server\Elements\RegisteredResourceTemplate; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Throwable; -class Registry +class Registry implements EventEmitterInterface { - private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - - private ?CacheInterface $cache; - - private LoggerInterface $logger; - - private ?ClientStateManager $clientStateManager = null; - - /** @var ArrayObject */ - private ArrayObject $tools; + use EventEmitterTrait; - /** @var ArrayObject */ - private ArrayObject $resources; - - /** @var ArrayObject */ - private ArrayObject $prompts; - - /** @var ArrayObject */ - private ArrayObject $resourceTemplates; + private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - /** @var array */ - private array $manualToolNames = []; + /** @var array */ + private array $tools = []; - /** @var array */ - private array $manualResourceUris = []; + /** @var array */ + private array $resources = []; - /** @var array */ - private array $manualPromptNames = []; + /** @var array */ + private array $prompts = []; - /** @var array */ - private array $manualTemplateUris = []; + /** @var array */ + private array $resourceTemplates = []; - private bool $discoveredElementsLoaded = false; + private array $listHashes = [ + 'tools' => '', + 'resources' => '', + 'resource_templates' => '', + 'prompts' => '', + ]; private bool $notificationsEnabled = true; - public function __construct( - LoggerInterface $logger, - ?CacheInterface $cache = null, - ?ClientStateManager $clientStateManager = null + protected LoggerInterface $logger, + protected ?CacheInterface $cache = null, ) { - $this->logger = $logger; - $this->cache = $cache; - $this->clientStateManager = $clientStateManager; - - $this->initializeCollections(); - - if ($this->cache) { - $this->loadDiscoveredElementsFromCache(); - } else { - $this->discoveredElementsLoaded = true; - $this->logger->debug('No cache provided to registry, skipping initial cache load.'); - } + $this->load(); + $this->computeAllHashes(); } /** - * Checks if discovery has been run OR elements loaded from cache. - * - * Note: Manual elements can exist even if this is false initially. + * Compute hashes for all lists for change detection */ - public function discoveryRanOrCached(): bool - { - return $this->discoveredElementsLoaded; - } - - /** Checks if any elements (manual or discovered) are currently registered. */ - public function hasElements(): bool - { - return ! $this->tools->getArrayCopy() === false - || ! $this->resources->getArrayCopy() === false - || ! $this->prompts->getArrayCopy() === false - || ! $this->resourceTemplates->getArrayCopy() === false; - } - - private function initializeCollections(): void + private function computeAllHashes(): void { - $this->tools = new ArrayObject(); - $this->resources = new ArrayObject(); - $this->prompts = new ArrayObject(); - $this->resourceTemplates = new ArrayObject(); - - $this->manualToolNames = []; - $this->manualResourceUris = []; - $this->manualPromptNames = []; - $this->manualTemplateUris = []; + $this->listHashes['tools'] = $this->computeHash($this->tools); + $this->listHashes['resources'] = $this->computeHash($this->resources); + $this->listHashes['resource_templates'] = $this->computeHash($this->resourceTemplates); + $this->listHashes['prompts'] = $this->computeHash($this->prompts); } - public function enableNotifications(): void + /** + * Compute a stable hash for a collection + */ + private function computeHash(array $collection): string { - $this->notificationsEnabled = true; - } + if (empty($collection)) { + return ''; + } - public function disableNotifications(): void - { - $this->notificationsEnabled = false; + ksort($collection); + return md5(json_encode($collection)); } - public function notifyToolsListChanged(): void + public function load(): void { - if (!$this->notificationsEnabled || !$this->clientStateManager) { + if ($this->cache === null) { return; } - $notification = Notification::make('notifications/tools/list_changed'); - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); - } + $this->clear(); - public function notifyResourcesListChanged(): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } - $notification = Notification::make('notifications/resources/list_changed'); + try { + $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); - } + if (!is_array($cached)) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + return; + } - public function notifyPromptsListChanged(): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } - $notification = Notification::make('notifications/prompts/list_changed'); + $loadCount = 0; - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); - } + foreach ($cached['tools'] ?? [] as $toolData) { + $cachedTool = RegisteredTool::fromArray(json_decode($toolData, true)); + if ($cachedTool === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } - public function notifyResourceUpdated(string $uri): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } + $toolName = $cachedTool->schema->name; + $existingTool = $this->tools[$toolName] ?? null; - $subscribers = $this->clientStateManager->getResourceSubscribers($uri); - if (empty($subscribers)) { - return; - } - $notification = Notification::make('notifications/resources/updated', ['uri' => $uri]); + if ($existingTool && $existingTool->isManual) { + $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); + continue; + } - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode resource/updated notification.', ['uri' => $uri]); - return; - } + $this->tools[$toolName] = $cachedTool; + $loadCount++; + } - foreach ($subscribers as $clientId) { - $this->clientStateManager->queueMessage($clientId, $framedMessage); - } - } + foreach ($cached['resources'] ?? [] as $resourceData) { + $cachedResource = RegisteredResource::fromArray(json_decode($resourceData, true)); + if ($cachedResource === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } - /** @deprecated */ - public function setToolsChangedNotifier(?callable $notifier): void {} + $uri = $cachedResource->schema->uri; + $existingResource = $this->resources[$uri] ?? null; - /** @deprecated */ - public function setResourcesChangedNotifier(?callable $notifier): void {} + if ($existingResource && $existingResource->isManual) { + $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); + continue; + } - /** @deprecated */ - public function setPromptsChangedNotifier(?callable $notifier): void {} + $this->resources[$uri] = $cachedResource; + $loadCount++; + } - public function registerTool(ToolDefinition $tool, bool $isManual = false): void - { - $toolName = $tool->getName(); - $exists = $this->tools->offsetExists($toolName); - $wasManual = isset($this->manualToolNames[$toolName]); + foreach ($cached['prompts'] ?? [] as $promptData) { + $cachedPrompt = RegisteredPrompt::fromArray(json_decode($promptData, true)); + if ($cachedPrompt === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } - if ($exists && ! $isManual && $wasManual) { - $this->logger->debug("MCP Registry: Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + $promptName = $cachedPrompt->schema->name; + $existingPrompt = $this->prompts[$promptName] ?? null; - return; // Manual registration takes precedence - } + if ($existingPrompt && $existingPrompt->isManual) { + $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); + continue; + } - if ($exists) { - $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " tool '{$toolName}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); - } + $this->prompts[$promptName] = $cachedPrompt; + $loadCount++; + } - $this->tools[$toolName] = $tool; + foreach ($cached['resourceTemplates'] ?? [] as $templateData) { + $cachedTemplate = RegisteredResourceTemplate::fromArray(json_decode($templateData, true)); + if ($cachedTemplate === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } - if ($isManual) { - $this->manualToolNames[$toolName] = true; - } elseif ($wasManual) { - unset($this->manualToolNames[$toolName]); - } + $uriTemplate = $cachedTemplate->schema->uriTemplate; + $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null; - if (! $exists) { - $this->notifyToolsListChanged(); + if ($existingTemplate && $existingTemplate->isManual) { + $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); + continue; + } + + $this->resourceTemplates[$uriTemplate] = $cachedTemplate; + $loadCount++; + } + + $this->logger->debug("Loaded {$loadCount} elements from cache."); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + } catch (DefinitionException $e) { + $this->logger->error('Error hydrating definition from cache.', ['exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); } } - public function registerResource(ResourceDefinition $resource, bool $isManual = false): void + public function registerTool(Tool $tool, string $handlerClass, string $handlerMethod, bool $isManual = false): void { - $uri = $resource->getUri(); - $exists = $this->resources->offsetExists($uri); - $wasManual = isset($this->manualResourceUris[$uri]); + $toolName = $tool->name; + $existing = $this->tools[$toolName] ?? null; - if ($exists && ! $isManual && $wasManual) { - $this->logger->debug("MCP Registry: Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); + if ($existing && ! $isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); return; } - if ($exists) { - $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " resource '{$uri}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); - } - $this->resources[$uri] = $resource; - if ($isManual) { - $this->manualResourceUris[$uri] = true; - } elseif ($wasManual) { - unset($this->manualResourceUris[$uri]); - } + $this->tools[$toolName] = RegisteredTool::make($tool, $handlerClass, $handlerMethod, $isManual); - if (! $exists) { - $this->notifyResourcesListChanged(); - } + $this->checkAndEmitChange('tools', $this->tools); } - public function registerResourceTemplate(ResourceTemplateDefinition $template, bool $isManual = false): void + public function registerResource(Resource $resource, string $handlerClass, string $handlerMethod, bool $isManual = false): void { - $uriTemplate = $template->getUriTemplate(); - $exists = $this->resourceTemplates->offsetExists($uriTemplate); - $wasManual = isset($this->manualTemplateUris[$uriTemplate]); + $uri = $resource->uri; + $existing = $this->resources[$uri] ?? null; - if ($exists && ! $isManual && $wasManual) { - $this->logger->debug("MCP Registry: Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); + if ($existing && ! $isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); return; } - if ($exists) { - $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " template '{$uriTemplate}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); - } - $this->resourceTemplates[$uriTemplate] = $template; - if ($isManual) { - $this->manualTemplateUris[$uriTemplate] = true; - } elseif ($wasManual) { - unset($this->manualTemplateUris[$uriTemplate]); - } - // No listChanged for templates + $this->resources[$uri] = RegisteredResource::make($resource, $handlerClass, $handlerMethod, $isManual); + + $this->checkAndEmitChange('resources', $this->resources); } - public function registerPrompt(PromptDefinition $prompt, bool $isManual = false): void - { - $promptName = $prompt->getName(); - $exists = $this->prompts->offsetExists($promptName); - $wasManual = isset($this->manualPromptNames[$promptName]); + public function registerResourceTemplate( + ResourceTemplate $template, + string $handlerClass, + string $handlerMethod, + array $completionProviders = [], + bool $isManual = false, + ): void { + $uriTemplate = $template->uriTemplate; + $existing = $this->resourceTemplates[$uriTemplate] ?? null; - if ($exists && ! $isManual && $wasManual) { - $this->logger->debug("MCP Registry: Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); + if ($existing && ! $isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); return; } - if ($exists) { - $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " prompt '{$promptName}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); - } - $this->prompts[$promptName] = $prompt; - if ($isManual) { - $this->manualPromptNames[$promptName] = true; - } elseif ($wasManual) { - unset($this->manualPromptNames[$promptName]); - } + $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handlerClass, $handlerMethod, $isManual, $completionProviders); - if (! $exists) { - $this->notifyPromptsListChanged(); - } + $this->checkAndEmitChange('resource_templates', $this->resourceTemplates); } - public function loadDiscoveredElementsFromCache(bool $force = false): void - { - if ($this->cache === null) { - $this->logger->debug('MCP Registry: Cache load skipped, cache not available.'); - $this->discoveredElementsLoaded = true; + public function registerPrompt( + Prompt $prompt, + string $handlerClass, + string $handlerMethod, + array $completionProviders = [], + bool $isManual = false, + ): void { + $promptName = $prompt->name; + $existing = $this->prompts[$promptName] ?? null; - return; - } + if ($existing && ! $isManual && $existing->isManual) { + $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); - if ($this->discoveredElementsLoaded && ! $force) { - return; // Already loaded or ran discovery this session + return; } - $this->clearDiscoveredElements(false); // Don't delete cache, just clear internal collections - - try { - $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); + $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handlerClass, $handlerMethod, $isManual, $completionProviders); - if (is_array($cached)) { - $this->logger->debug('MCP Registry: Loading discovered elements from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); - $loadCount = 0; - - foreach ($cached['tools'] ?? [] as $toolData) { - $toolDefinition = $toolData instanceof ToolDefinition ? $toolData : ToolDefinition::fromArray($toolData); - $toolName = $toolDefinition->getName(); - if (! isset($this->manualToolNames[$toolName])) { - $this->tools[$toolName] = $toolDefinition; - $loadCount++; - } else { - $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); - } - } + $this->checkAndEmitChange('prompts', $this->prompts); + } - foreach ($cached['resources'] ?? [] as $resourceData) { - $resourceDefinition = $resourceData instanceof ResourceDefinition ? $resourceData : ResourceDefinition::fromArray($resourceData); - $uri = $resourceDefinition->getUri(); - if (! isset($this->manualResourceUris[$uri])) { - $this->resources[$uri] = $resourceDefinition; - $loadCount++; - } else { - $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); - } - } + public function enableNotifications(): void + { + $this->notificationsEnabled = true; + } - foreach ($cached['prompts'] ?? [] as $promptData) { - $promptDefinition = $promptData instanceof PromptDefinition ? $promptData : PromptDefinition::fromArray($promptData); - $promptName = $promptDefinition->getName(); - if (! isset($this->manualPromptNames[$promptName])) { - $this->prompts[$promptName] = $promptDefinition; - $loadCount++; - } else { - $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); - } - } + public function disableNotifications(): void + { + $this->notificationsEnabled = false; + } - foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - $templateDefinition = $templateData instanceof ResourceTemplateDefinition ? $templateData : ResourceTemplateDefinition::fromArray($templateData); - $uriTemplate = $templateDefinition->getUriTemplate(); - if (! isset($this->manualTemplateUris[$uriTemplate])) { - $this->resourceTemplates[$uriTemplate] = $templateDefinition; - $loadCount++; - } else { - $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); - } - } + /** + * Check if a list has changed and emit event if needed + */ + private function checkAndEmitChange(string $listType, array $collection): void + { + if (! $this->notificationsEnabled) { + return; + } - $this->logger->debug("MCP Registry: Loaded {$loadCount} elements from cache."); + $newHash = $this->computeHash($collection); - $this->discoveredElementsLoaded = true; - } elseif ($cached !== null) { - $this->logger->warning('MCP Registry: Invalid data type found in cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); - } else { - $this->logger->debug('MCP Registry: Cache miss or empty.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); - } - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP Registry: Invalid cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); - } catch (DefinitionException $e) { // Catch potential fromArray errors - $this->logger->error('MCP Registry: Error hydrating definition from cache.', ['exception' => $e]); - // Clear cache on hydration error? Or just log and continue? Let's log and skip cache load. - $this->initializeCollections(); // Reset collections if hydration failed - } catch (Throwable $e) { - $this->logger->error('MCP Registry: Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + if ($newHash !== $this->listHashes[$listType]) { + $this->listHashes[$listType] = $newHash; + $this->emit('list_changed', [$listType]); } } - public function saveDiscoveredElementsToCache(): bool + public function save(): bool { if ($this->cache === null) { - $this->logger->debug('MCP Registry: Cache save skipped, cache not available.'); - return false; } @@ -403,26 +298,26 @@ public function saveDiscoveredElementsToCache(): bool ]; foreach ($this->tools as $name => $tool) { - if (! isset($this->manualToolNames[$name])) { - $discoveredData['tools'][$name] = $tool; + if (! $tool->isManual) { + $discoveredData['tools'][$name] = json_encode($tool); } } foreach ($this->resources as $uri => $resource) { - if (! isset($this->manualResourceUris[$uri])) { - $discoveredData['resources'][$uri] = $resource; + if (! $resource->isManual) { + $discoveredData['resources'][$uri] = json_encode($resource); } } foreach ($this->prompts as $name => $prompt) { - if (! isset($this->manualPromptNames[$name])) { - $discoveredData['prompts'][$name] = $prompt; + if (! $prompt->isManual) { + $discoveredData['prompts'][$name] = json_encode($prompt); } } foreach ($this->resourceTemplates as $uriTemplate => $template) { - if (! isset($this->manualTemplateUris[$uriTemplate])) { - $discoveredData['resourceTemplates'][$uriTemplate] = $template; + if (! $template->isManual) { + $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template); } } @@ -430,129 +325,137 @@ public function saveDiscoveredElementsToCache(): bool $success = $this->cache->set(self::DISCOVERED_ELEMENTS_CACHE_KEY, $discoveredData); if ($success) { - $this->logger->debug('MCP Registry: Elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + $this->logger->debug('Registry elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } else { - $this->logger->warning('MCP Registry: Cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + $this->logger->warning('Registry cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } return $success; } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP Registry: Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + $this->logger->error('Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } catch (Throwable $e) { - $this->logger->error('MCP Registry: Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + $this->logger->error('Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } } - public function clearDiscoveredElements(bool $deleteFromCache = true): void + /** Checks if any elements (manual or discovered) are currently registered. */ + public function hasElements(): bool { - $this->logger->debug('Clearing discovered elements...', ['deleteCacheFile' => $deleteFromCache]); + return ! empty($this->tools) + || ! empty($this->resources) + || ! empty($this->prompts) + || ! empty($this->resourceTemplates); + } - if ($deleteFromCache && $this->cache !== null) { + public function clear(): void + { + if ($this->cache !== null) { try { $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY); - $this->logger->info('MCP Registry: Discovered elements cache cleared.'); + $this->logger->debug('Registry cache cleared.'); } catch (Throwable $e) { - $this->logger->error('MCP Registry: Error clearing discovered elements cache.', ['exception' => $e]); + $this->logger->error('Error clearing registry cache.', ['exception' => $e]); } } $clearCount = 0; foreach ($this->tools as $name => $tool) { - if (! isset($this->manualToolNames[$name])) { + if (! $tool->isManual) { unset($this->tools[$name]); $clearCount++; } } foreach ($this->resources as $uri => $resource) { - if (! isset($this->manualResourceUris[$uri])) { + if (! $resource->isManual) { unset($this->resources[$uri]); $clearCount++; } } foreach ($this->prompts as $name => $prompt) { - if (! isset($this->manualPromptNames[$name])) { + if (! $prompt->isManual) { unset($this->prompts[$name]); $clearCount++; } } foreach ($this->resourceTemplates as $uriTemplate => $template) { - if (! isset($this->manualTemplateUris[$uriTemplate])) { + if (! $template->isManual) { unset($this->resourceTemplates[$uriTemplate]); $clearCount++; } } - $this->discoveredElementsLoaded = false; - $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); + if ($clearCount > 0) { + $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); + } } - public function findTool(string $name): ?ToolDefinition + /** @return RegisteredTool|null */ + public function getTool(string $name): ?RegisteredTool { return $this->tools[$name] ?? null; } - public function findPrompt(string $name): ?PromptDefinition + /** @return RegisteredResource|RegisteredResourceTemplate|null */ + public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null { - return $this->prompts[$name] ?? null; - } - - public function findResourceByUri(string $uri): ?ResourceDefinition - { - return $this->resources[$uri] ?? null; - } - - public function findResourceTemplateByUri(string $uri): ?array - { - foreach ($this->resourceTemplates as $templateDefinition) { - try { - $matcher = new UriTemplateMatcher($templateDefinition->getUriTemplate()); - $variables = $matcher->match($uri); - - if ($variables !== null) { - $this->logger->debug('MCP Registry: Matched URI to template.', ['uri' => $uri, 'template' => $templateDefinition->getUriTemplate()]); + $registration = $this->resources[$uri] ?? null; + if ($registration) { + return $registration; + } - return ['definition' => $templateDefinition, 'variables' => $variables]; - } - } catch (\InvalidArgumentException $e) { - $this->logger->warning('Invalid resource template encountered during matching', [ - 'template' => $templateDefinition->getUriTemplate(), - 'error' => $e->getMessage(), - ]); + if (! $includeTemplates) { + return null; + } - continue; + foreach ($this->resourceTemplates as $template) { + if ($template->matches($uri)) { + return $template; } } - $this->logger->debug('MCP Registry: No template matched URI.', ['uri' => $uri]); + + $this->logger->debug('No resource matched URI.', ['uri' => $uri]); return null; } - /** @return ArrayObject */ - public function allTools(): ArrayObject + /** @return RegisteredResourceTemplate|null */ + public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate + { + return $this->resourceTemplates[$uriTemplate] ?? null; + } + + /** @return RegisteredPrompt|null */ + public function getPrompt(string $name): ?RegisteredPrompt + { + return $this->prompts[$name] ?? null; + } + + /** @return array */ + public function getTools(): array { - return $this->tools; + return array_map(fn ($tool) => $tool->schema, $this->tools); } - /** @return ArrayObject */ - public function allResources(): ArrayObject + /** @return array */ + public function getResources(): array { - return $this->resources; + return array_map(fn ($resource) => $resource->schema, $this->resources); } - /** @return ArrayObject */ - public function allPrompts(): ArrayObject + /** @return array */ + public function getPrompts(): array { - return $this->prompts; + return array_map(fn ($prompt) => $prompt->schema, $this->prompts); } - /** @return ArrayObject */ - public function allResourceTemplates(): ArrayObject + /** @return array */ + public function getResourceTemplates(): array { - return $this->resourceTemplates; + return array_map(fn ($template) => $template->schema, $this->resourceTemplates); } } diff --git a/src/Server.php b/src/Server.php index 540e7b9..9ef162d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -10,8 +10,8 @@ use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DiscoveryException; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\Discoverer; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Utils\Discoverer; use Throwable; /** @@ -40,8 +40,9 @@ public function __construct( protected readonly Configuration $configuration, protected readonly Registry $registry, protected readonly Protocol $protocol, - protected readonly ClientStateManager $clientStateManager, - ) {} + protected readonly SessionManager $sessionManager, + ) { + } public static function make(): ServerBuilder { @@ -65,14 +66,15 @@ public function discover( array $scanDirs = ['.', 'src'], array $excludeDirs = [], bool $force = false, - bool $saveToCache = true + bool $saveToCache = true, + ?Discoverer $discoverer = null ): void { $realBasePath = realpath($basePath); if ($realBasePath === false || ! is_dir($realBasePath)) { throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}"); } - $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules']); + $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']); if ($this->discoveryRan && ! $force) { $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.'); @@ -89,18 +91,17 @@ public function discover( 'saveToCache' => $shouldSaveCache, ]); - $this->registry->clearDiscoveredElements($shouldSaveCache); + $this->registry->clear(); try { - $discoverer = new Discoverer($this->registry, $this->configuration->logger); + $discoverer ??= new Discoverer($this->registry, $this->configuration->logger); $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); $this->discoveryRan = true; - $this->configuration->logger->info('Element discovery process finished.'); if ($shouldSaveCache) { - $this->registry->saveDiscoveredElementsToCache(); + $this->registry->save(); } } catch (Throwable $e) { $this->discoveryRan = false; @@ -155,12 +156,14 @@ public function listen(ServerTransportInterface $transport, bool $runLoop = true $this->isListening = true; if ($runLoop) { + $this->sessionManager->startGcTimer(); + $this->configuration->loop->run(); - $this->endListen($transport); // If the loop ends, we need to clean up + $this->endListen($transport); } } catch (Throwable $e) { - $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e]); + $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]); $this->endListen($transport); throw $e; } @@ -170,14 +173,15 @@ public function endListen(ServerTransportInterface $transport): void { $protocol = $this->getProtocol(); - if ($this->isListening) { - $protocol->unbindTransport(); - $transport->removeAllListeners('close'); - $transport->close(); - } + $protocol->unbindTransport(); + + $this->sessionManager->stopGcTimer(); + + $transport->removeAllListeners('close'); + $transport->close(); $this->isListening = false; - $this->configuration->logger->info("Server '{$this->configuration->serverName}' listener shut down."); + $this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down."); } /** @@ -221,8 +225,8 @@ public function getProtocol(): Protocol return $this->protocol; } - public function getClientStateManager(): ClientStateManager + public function getSessionManager(): SessionManager { - return $this->clientStateManager; + return $this->sessionManager; } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index fc15a4d..041a16c 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -4,12 +4,24 @@ namespace PhpMcp\Server; +use PhpMcp\Schema\Annotations; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\PromptArgument; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\ServerCapabilities; +use PhpMcp\Schema\Tool; +use PhpMcp\Schema\ToolAnnotations; +use PhpMcp\Server\Attributes\CompletionProvider; +use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Model\Capabilities; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\HandlerResolver; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\CacheSessionHandler; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Utils\HandlerResolver; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -20,11 +32,9 @@ final class ServerBuilder { - private ?string $name = null; + private ?Implementation $serverInfo = null; - private ?string $version = null; - - private ?Capabilities $capabilities = null; + private ?ServerCapabilities $capabilities = null; private ?LoggerInterface $logger = null; @@ -34,17 +44,48 @@ final class ServerBuilder private ?LoopInterface $loop = null; - private ?int $definitionCacheTtl = 3600; + private ?SessionHandlerInterface $sessionHandler = null; + + private ?string $sessionDriver = null; + + private ?int $sessionTtl = 3600; private ?int $paginationLimit = 50; - // Temporary storage for manual registrations + /** @var array< + * array{handler: array|string, + * name: string|null, + * description: string|null, + * annotations: ToolAnnotations|null} + * > */ private array $manualTools = []; + /** @var array< + * array{handler: array|string, + * uri: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * size: int|null, + * annotations: Annotations|null} + * > */ private array $manualResources = []; + /** @var array< + * array{handler: array|string, + * uriTemplate: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * annotations: Annotations|null} + * > */ private array $manualResourceTemplates = []; + /** @var array< + * array{handler: array|string, + * name: string|null, + * description: string|null} + * > */ private array $manualPrompts = []; public function __construct() {} @@ -54,8 +95,7 @@ public function __construct() {} */ public function withServerInfo(string $name, string $version): self { - $this->name = trim($name); - $this->version = trim($version); + $this->serverInfo = Implementation::make(name: trim($name), version: trim($version)); return $this; } @@ -63,7 +103,7 @@ public function withServerInfo(string $name, string $version): self /** * Configures the server's declared capabilities. */ - public function withCapabilities(Capabilities $capabilities): self + public function withCapabilities(ServerCapabilities $capabilities): self { $this->capabilities = $capabilities; @@ -91,13 +131,43 @@ public function withLogger(LoggerInterface $logger): self } /** - * Provides a PSR-16 cache instance and optionally sets the TTL for definition caching. - * If no cache is provided, definition caching is disabled (uses default FileCache if possible). + * Provides a PSR-16 cache instance used for all internal caching. */ - public function withCache(CacheInterface $cache, int $definitionCacheTtl = 3600): self + public function withCache(CacheInterface $cache): self { $this->cache = $cache; - $this->definitionCacheTtl = $definitionCacheTtl > 0 ? $definitionCacheTtl : 3600; + + return $this; + } + + /** + * Configures session handling with a specific driver. + * + * @param 'array' | 'cache' $driver The session driver: 'array' for in-memory sessions, 'cache' for cache-backed sessions + * @param int $ttl Session time-to-live in seconds. Defaults to 3600. + */ + public function withSession(string $driver, int $ttl = 3600): self + { + if (!in_array($driver, ['array', 'cache'], true)) { + throw new \InvalidArgumentException( + "Unsupported session driver '{$driver}'. Only 'array' and 'cache' drivers are supported. " . + "For custom session handling, use withSessionHandler() instead." + ); + } + + $this->sessionDriver = $driver; + $this->sessionTtl = $ttl; + + return $this; + } + + /** + * Provides a custom session handler. + */ + public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self + { + $this->sessionHandler = $sessionHandler; + $this->sessionTtl = $sessionTtl; return $this; } @@ -126,9 +196,9 @@ public function withLoop(LoopInterface $loop): self /** * Manually registers a tool handler. */ - public function withTool(array|string $handler, ?string $name = null, ?string $description = null): self + public function withTool(array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null): self { - $this->manualTools[] = compact('handler', 'name', 'description'); + $this->manualTools[] = compact('handler', 'name', 'description', 'annotations'); return $this; } @@ -136,7 +206,7 @@ public function withTool(array|string $handler, ?string $name = null, ?string $d /** * Manually registers a resource handler. */ - public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, array $annotations = []): self + public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self { $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); @@ -146,7 +216,7 @@ public function withResource(array|string $handler, string $uri, ?string $name = /** * Manually registers a resource template handler. */ - public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, array $annotations = []): self + public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self { $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); @@ -170,7 +240,7 @@ public function withPrompt(array|string $handler, ?string $name = null, ?string */ public function build(): Server { - if ($this->name === null || $this->version === null || $this->name === '' || $this->version === '') { + if ($this->serverInfo === null) { throw new ConfigurationException('Server name and version must be provided using withServerInfo().'); } @@ -178,31 +248,30 @@ public function build(): Server $cache = $this->cache; $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new BasicContainer(); - $capabilities = $this->capabilities ?? Capabilities::forServer(); + $capabilities = $this->capabilities ?? ServerCapabilities::make(); $configuration = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $capabilities, logger: $logger, loop: $loop, cache: $cache, container: $container, - definitionCacheTtl: $this->definitionCacheTtl ?? 3600, paginationLimit: $this->paginationLimit ?? 50 ); - $clientStateManager = new ClientStateManager($configuration->logger, $configuration->cache, 'mcp_state_', $configuration->definitionCacheTtl); - $registry = new Registry($configuration->logger, $configuration->cache, $clientStateManager); - $protocol = new Protocol($configuration, $registry, $clientStateManager); + $sessionHandler = $this->createSessionHandler(); + $sessionManager = new SessionManager($sessionHandler, $logger, $loop, $this->sessionTtl); + $registry = new Registry($logger, $cache, $sessionManager); + $protocol = new Protocol($configuration, $registry, $sessionManager); $registry->disableNotifications(); - - $this->performManualRegistrations($registry, $configuration->logger); - + + $this->registerManualElements($registry, $logger); + $registry->enableNotifications(); - $server = new Server($configuration, $registry, $protocol, $clientStateManager); + $server = new Server($configuration, $registry, $protocol, $sessionManager); return $server; } @@ -211,29 +280,33 @@ public function build(): Server * Helper to perform the actual registration based on stored data. * Moved into the builder. */ - private function performManualRegistrations(Registry $registry, LoggerInterface $logger): void + private function registerManualElements(Registry $registry, LoggerInterface $logger): void { if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { return; } $errorCount = 0; - $docBlockParser = new Support\DocBlockParser($logger); - $schemaGenerator = new Support\SchemaGenerator($docBlockParser); + $docBlockParser = new Utils\DocBlockParser($logger); + $schemaGenerator = new Utils\SchemaGenerator($docBlockParser); // Register Tools foreach ($this->manualTools as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ToolDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $docBlockParser, - $schemaGenerator - ); - $registry->registerTool($def, true); - $logger->debug("Registered manual tool '{$def->getName()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $inputSchema = $schemaGenerator->generate($reflectionMethod); + + $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); + $registry->registerTool($tool, $className, $methodName, true); + + $logger->debug("Registered manual tool {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); @@ -243,19 +316,23 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Resources foreach ($this->manualResources as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ResourceDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $data['uri'], - $data['mimeType'], - $data['size'], - $data['annotations'], - $docBlockParser - ); - $registry->registerResource($def, true); - $logger->debug("Registered manual resource '{$def->getUri()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $uri = $data['uri']; + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $data['mimeType']; + $size = $data['size']; + $annotations = $data['annotations']; + + $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); + $registry->registerResource($resource, $className, $methodName, true); + + $logger->debug("Registered manual resource {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); @@ -265,18 +342,23 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Templates foreach ($this->manualResourceTemplates as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ResourceTemplateDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $data['uriTemplate'], - $data['mimeType'], - $data['annotations'], - $docBlockParser - ); - $registry->registerResourceTemplate($def, true); - $logger->debug("Registered manual template '{$def->getUriTemplate()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $uriTemplate = $data['uriTemplate']; + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $data['mimeType']; + $annotations = $data['annotations']; + + $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); + $completionProviders = $this->getCompletionProviders($reflectionMethod); + $registry->registerResourceTemplate($template, $className, $methodName, $completionProviders, true); + + $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); @@ -286,15 +368,38 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Prompts foreach ($this->manualPrompts as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\PromptDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $docBlockParser - ); - $registry->registerPrompt($def, true); - $logger->debug("Registered manual prompt '{$def->getName()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + + $arguments = []; + $paramTags = $docBlockParser->getParamTags($docBlock); + foreach ($reflectionMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$' . $param->getName()] ?? null; + $arguments[] = PromptArgument::make( + name: $param->getName(), + description: $paramTag ? trim((string) $paramTag->getDescription()) : null, + required: ! $param->isOptional() && ! $param->isDefaultValueAvailable() + ); + } + + $prompt = Prompt::make($name, $description, $arguments); + $completionProviders = $this->getCompletionProviders($reflectionMethod); + $registry->registerPrompt($prompt, $className, $methodName, $completionProviders, true); + + $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); @@ -307,4 +412,64 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $logger->debug('Manual element registration complete.'); } + + /** + * Creates the appropriate session handler based on configuration. + * + * @throws ConfigurationException If cache driver is selected but no cache is provided + */ + private function createSessionHandler(): SessionHandlerInterface + { + // If a custom session handler was provided, use it + if ($this->sessionHandler !== null) { + return $this->sessionHandler; + } + + // If no session driver was specified, default to array + if ($this->sessionDriver === null) { + return new ArraySessionHandler($this->sessionTtl ?? 3600); + } + + // Create handler based on driver + return match ($this->sessionDriver) { + 'array' => new ArraySessionHandler($this->sessionTtl ?? 3600), + 'cache' => $this->createCacheSessionHandler(), + default => throw new ConfigurationException("Unsupported session driver: {$this->sessionDriver}") + }; + } + + /** + * Creates a cache-based session handler. + * + * @throws ConfigurationException If no cache is configured + */ + private function createCacheSessionHandler(): CacheSessionHandler + { + if ($this->cache === null) { + throw new ConfigurationException( + "Cache session driver requires a cache instance. Please configure a cache using withCache() before using withSession('cache')." + ); + } + + return new CacheSessionHandler($this->cache, $this->sessionTtl ?? 3600); + } + + private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array + { + $completionProviders = []; + foreach ($reflectionMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + $completionProviders[$param->getName()] = $attributeInstance->providerClass; + } + } + + return $completionProviders; + } } diff --git a/src/Session/ArraySessionHandler.php b/src/Session/ArraySessionHandler.php new file mode 100644 index 0000000..f8af810 --- /dev/null +++ b/src/Session/ArraySessionHandler.php @@ -0,0 +1,77 @@ + + */ + protected array $store = []; + + private ClockInterface $clock; + + public function __construct( + public readonly int $ttl = 3600, + ?ClockInterface $clock = null + ) { + $this->clock = $clock ?? new SystemClock(); + } + + public function read(string $sessionId): string|false + { + $session = $this->store[$sessionId] ?? ''; + if ($session === '') { + return false; + } + + $currentTimestamp = $this->clock->now()->getTimestamp(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId]); + return false; + } + + return $session['data']; + } + + public function write(string $sessionId, string $data): bool + { + $this->store[$sessionId] = [ + 'data' => $data, + 'timestamp' => $this->clock->now()->getTimestamp(), + ]; + + return true; + } + + public function destroy(string $sessionId): bool + { + if (isset($this->store[$sessionId])) { + unset($this->store[$sessionId]); + } + + return true; + } + + public function gc(int $maxLifetime): array + { + $currentTimestamp = $this->clock->now()->getTimestamp(); + $deletedSessions = []; + + foreach ($this->store as $sessionId => $session) { + if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { + unset($this->store[$sessionId]); + $deletedSessions[] = $sessionId; + } + } + + return $deletedSessions; + } +} diff --git a/src/Session/CacheSessionHandler.php b/src/Session/CacheSessionHandler.php new file mode 100644 index 0000000..88f54ab --- /dev/null +++ b/src/Session/CacheSessionHandler.php @@ -0,0 +1,73 @@ +sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); + $this->clock = $clock ?? new SystemClock(); + } + + public function read(string $sessionId): string|false + { + $session = $this->cache->get($sessionId, false); + if ($session === false) { + return false; + } + + if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) { + $this->cache->delete($sessionId); + return false; + } + + return $session; + } + + public function write(string $sessionId, string $data): bool + { + $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); + return $this->cache->set($sessionId, $data); + } + + public function destroy(string $sessionId): bool + { + unset($this->sessionIndex[$sessionId]); + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); + return $this->cache->delete($sessionId); + } + + public function gc(int $maxLifetime): array + { + $currentTime = $this->clock->now()->getTimestamp(); + $deletedSessions = []; + + foreach ($this->sessionIndex as $sessionId => $timestamp) { + if ($currentTime - $timestamp > $maxLifetime) { + $this->cache->delete($sessionId); + unset($this->sessionIndex[$sessionId]); + $deletedSessions[] = $sessionId; + } + } + + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); + + return $deletedSessions; + } +} diff --git a/src/Session/Session.php b/src/Session/Session.php new file mode 100644 index 0000000..d7f1285 --- /dev/null +++ b/src/Session/Session.php @@ -0,0 +1,179 @@ + Stores all session data. + * Keys are snake_case by convention for MCP-specific data. + * + * Official keys are: + * - initialized: bool + * - client_info: array|null + * - protocol_version: string|null + * - subscriptions: array + * - message_queue: array + * - log_level: string|null + */ + protected array $data = []; + + public function __construct( + protected SessionHandlerInterface $handler, + protected string $id = '' + ) { + if (empty($this->id)) { + $this->id = $this->generateId(); + } + + if ($data = $this->handler->read($this->id)) { + $this->data = json_decode($data, true) ?? []; + } + } + + public function getId(): string + { + return $this->id; + } + + public function generateId(): string + { + return bin2hex(random_bytes(16)); + } + + public function save(): void + { + $this->handler->write($this->id, json_encode($this->data)); + } + + public function get(string $key, mixed $default = null): mixed + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } else { + return $default; + } + } + + return $data; + } + + public function set(string $key, mixed $value, bool $overwrite = true): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if ($overwrite || !isset($data[$lastKey])) { + $data[$lastKey] = $value; + } + } + + public function has(string $key): bool + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } elseif (is_object($data) && isset($data->{$segment})) { + $data = $data->{$segment}; + } else { + return false; + } + } + + return true; + } + + public function forget(string $key): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if (isset($data[$lastKey])) { + unset($data[$lastKey]); + } + } + + public function clear(): void + { + $this->data = []; + } + + public function pull(string $key, mixed $default = null): mixed + { + $value = $this->get($key, $default); + $this->forget($key); + return $value; + } + + public function all(): array + { + return $this->data; + } + + public function hydrate(array $attributes): void + { + $this->data = array_merge( + [ + 'initialized' => false, + 'client_info' => null, + 'protocol_version' => null, + 'message_queue' => [], + 'log_level' => null, + ], + $attributes + ); + unset($this->data['id']); + } + + public function queueMessage(string $rawFramedMessage): void + { + $this->data['message_queue'][] = $rawFramedMessage; + } + + public function dequeueMessages(): array + { + $messages = $this->data['message_queue'] ?? []; + $this->data['message_queue'] = []; + return $messages; + } + + public function hasQueuedMessages(): bool + { + return !empty($this->data['message_queue']); + } + + public function jsonSerialize(): array + { + return $this->all(); + } +} diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php new file mode 100644 index 0000000..0edd63c --- /dev/null +++ b/src/Session/SessionManager.php @@ -0,0 +1,155 @@ +loop ??= Loop::get(); + } + + /** + * Start the garbage collection timer + */ + public function startGcTimer(): void + { + if ($this->gcTimer !== null) { + return; + } + + $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, function () { + $deletedSessions = $this->handler->gc($this->ttl); + + foreach ($deletedSessions as $sessionId) { + $this->emit('session_deleted', [$sessionId]); + } + + if (count($deletedSessions) > 0) { + $this->logger->debug('Session garbage collection complete', [ + 'purged_sessions' => count($deletedSessions), + ]); + } + }); + } + + /** + * Stop the garbage collection timer + */ + public function stopGcTimer(): void + { + if ($this->gcTimer !== null) { + $this->loop->cancelTimer($this->gcTimer); + $this->gcTimer = null; + } + } + + /** + * Create a new session + */ + public function createSession(string $sessionId): SessionInterface + { + $session = new Session($this->handler, $sessionId); + + $session->hydrate([ + 'initialized' => false, + 'client_info' => null, + 'protocol_version' => null, + 'subscriptions' => [], // [uri => true] + 'message_queue' => [], // string[] (raw JSON-RPC frames) + 'log_level' => null, + ]); + + $session->save(); + + $this->logger->info('Session created', ['sessionId' => $sessionId]); + $this->emit('session_created', [$sessionId, $session]); + + return $session; + } + + /** + * Get an existing session + */ + public function getSession(string $sessionId): ?SessionInterface + { + $session = new Session($this->handler, $sessionId); + + if (empty($session->all())) { + return null; + } + + return $session; + } + + /** + * Delete a session completely + */ + public function deleteSession(string $sessionId): bool + { + $success = $this->handler->destroy($sessionId); + + if ($success) { + $this->emit('session_deleted', [$sessionId]); + $this->logger->info('Session deleted', ['sessionId' => $sessionId]); + } else { + $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]); + } + + return $success; + } + + public function queueMessage(string $sessionId, string $message): void + { + $session = $this->getSession($sessionId); + if ($session === null) { + return; + } + + $session->queueMessage($message); + $session->save(); + } + + public function dequeueMessages(string $sessionId): array + { + $session = $this->getSession($sessionId); + if ($session === null) { + return []; + } + + $messages = $session->dequeueMessages(); + $session->save(); + + return $messages; + } + + public function hasQueuedMessages(string $sessionId): bool + { + $session = $this->getSession($sessionId, true); + if ($session === null) { + return false; + } + + return $session->hasQueuedMessages(); + } +} diff --git a/src/Session/SubscriptionManager.php b/src/Session/SubscriptionManager.php new file mode 100644 index 0000000..e7fc733 --- /dev/null +++ b/src/Session/SubscriptionManager.php @@ -0,0 +1,96 @@ +> Key: URI, Value: array of session IDs */ + private array $resourceSubscribers = []; + + /** @var array> Key: Session ID, Value: array of URIs */ + private array $sessionSubscriptions = []; + + public function __construct( + private readonly LoggerInterface $logger + ) { + } + + /** + * Subscribe a session to a resource + */ + public function subscribe(string $sessionId, string $uri): void + { + // Add to both mappings for efficient lookup + $this->resourceSubscribers[$uri][$sessionId] = true; + $this->sessionSubscriptions[$sessionId][$uri] = true; + + $this->logger->debug('Session subscribed to resource', [ + 'sessionId' => $sessionId, + 'uri' => $uri + ]); + } + + /** + * Unsubscribe a session from a resource + */ + public function unsubscribe(string $sessionId, string $uri): void + { + unset($this->resourceSubscribers[$uri][$sessionId]); + unset($this->sessionSubscriptions[$sessionId][$uri]); + + // Clean up empty arrays + if (empty($this->resourceSubscribers[$uri])) { + unset($this->resourceSubscribers[$uri]); + } + + $this->logger->debug('Session unsubscribed from resource', [ + 'sessionId' => $sessionId, + 'uri' => $uri + ]); + } + + /** + * Get all sessions subscribed to a resource + */ + public function getSubscribers(string $uri): array + { + return array_keys($this->resourceSubscribers[$uri] ?? []); + } + + /** + * Check if a session is subscribed to a resource + */ + public function isSubscribed(string $sessionId, string $uri): bool + { + return isset($this->sessionSubscriptions[$sessionId][$uri]); + } + + /** + * Clean up all subscriptions for a session + */ + public function cleanupSession(string $sessionId): void + { + if (!isset($this->sessionSubscriptions[$sessionId])) { + return; + } + + $uris = array_keys($this->sessionSubscriptions[$sessionId]); + foreach ($uris as $uri) { + unset($this->resourceSubscribers[$uri][$sessionId]); + + // Clean up empty arrays + if (empty($this->resourceSubscribers[$uri])) { + unset($this->resourceSubscribers[$uri]); + } + } + + unset($this->sessionSubscriptions[$sessionId]); + + $this->logger->debug('Cleaned up all subscriptions for session', [ + 'sessionId' => $sessionId, + 'count' => count($uris) + ]); + } +} diff --git a/src/State/ClientState.php b/src/State/ClientState.php deleted file mode 100644 index 6ddbc2c..0000000 --- a/src/State/ClientState.php +++ /dev/null @@ -1,63 +0,0 @@ - URIs this client is subscribed to. Key is URI, value is true. */ - public array $subscriptions = []; - - /** @var array Queued outgoing framed messages for this client. */ - public array $messageQueue = []; - - public int $lastActivityTimestamp; - - public ?string $requestedLogLevel = null; - - public function __construct(protected string $clientId) - { - $this->lastActivityTimestamp = time(); - } - - public function addSubscription(string $uri): void - { - $this->subscriptions[$uri] = true; - } - - public function removeSubscription(string $uri): void - { - unset($this->subscriptions[$uri]); - } - - public function clearSubscriptions(): void - { - $this->subscriptions = []; - } - - public function addMessageToQueue(string $message): void - { - $this->messageQueue[] = $message; - } - - /** @return array */ - public function consumeMessageQueue(): array - { - $messages = $this->messageQueue; - $this->messageQueue = []; - - return $messages; - } -} diff --git a/src/State/ClientStateManager.php b/src/State/ClientStateManager.php deleted file mode 100644 index 6f416bc..0000000 --- a/src/State/ClientStateManager.php +++ /dev/null @@ -1,501 +0,0 @@ -cachePrefix = $clientDataPrefix; - $this->cacheTtl = max(60, $cacheTtl); - - $this->cache ??= new ArrayCache(); - } - - private function getClientStateCacheKey(string $clientId): string - { - return $this->cachePrefix . $clientId; - } - - private function getResourceSubscribersCacheKey(string $uri): string - { - return self::GLOBAL_RESOURCE_SUBSCRIBERS_KEY_PREFIX . sha1($uri); - } - - private function getActiveClientsCacheKey(): string - { - return $this->cachePrefix . self::GLOBAL_ACTIVE_CLIENTS_KEY; - } - - /** - * Fetches or creates a ClientState object for a client. - */ - private function getClientState(string $clientId, bool $createIfNotFound = false): ?ClientState - { - $key = $this->getClientStateCacheKey($clientId); - - try { - $state = $this->cache->get($key); - if ($state instanceof ClientState) { - return $state; - } - - if ($state !== null) { - $this->logger->warning('Invalid data type found in cache for client state, deleting.', ['clientId' => $clientId, 'key' => $key]); - $this->cache->delete($key); - } - - if ($createIfNotFound) { - return new ClientState($clientId); - } - } catch (Throwable $e) { - $this->logger->error('Error fetching client state from cache.', ['clientId' => $clientId, 'key' => $key, 'exception' => $e]); - } - - return null; - } - - /** - * Saves a ClientState object to the cache. - */ - private function saveClientState(string $clientId, ClientState $state): bool - { - $key = $this->getClientStateCacheKey($clientId); - - try { - $state->lastActivityTimestamp = time(); - - return $this->cache->set($key, $state, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->error('Error saving client state to cache.', ['clientId' => $clientId, 'key' => $key, 'exception' => $e]); - - return false; - } - } - - /** - * Checks if a client has been initialized. - */ - public function isInitialized(string $clientId): bool - { - $state = $this->getClientState($clientId); - - return $state !== null && $state->isInitialized; - } - - /** - * Marks a client as initialized. - */ - public function markInitialized(string $clientId): void - { - $state = $this->getClientState($clientId, true); - - if ($state) { - $state->isInitialized = true; - - if ($this->saveClientState($clientId, $state)) { - $this->updateGlobalActiveClientTimestamp($clientId); - } - } else { - $this->logger->error('Failed to get/create state to mark client as initialized.', ['clientId' => $clientId]); - } - } - - /** - * Stores client information. - */ - public function storeClientInfo(array $clientInfo, string $protocolVersion, string $clientId): void - { - $state = $this->getClientState($clientId, true); - - if ($state) { - $state->clientInfo = $clientInfo; - $state->protocolVersion = $protocolVersion; - $this->saveClientState($clientId, $state); - } - } - - /** - * Gets client information. - */ - public function getClientInfo(string $clientId): ?array - { - return $this->getClientState($clientId)?->clientInfo; - } - - /** - * Gets the protocol version for a client. - */ - public function getProtocolVersion(string $clientId): ?string - { - return $this->getClientState($clientId)?->protocolVersion; - } - - /** - * Adds a resource subscription for a client. - */ - public function addResourceSubscription(string $clientId, string $uri): void - { - $clientState = $this->getClientState($clientId, true); - if (! $clientState) { - $this->logger->error('Failed to get/create client state for subscription.', ['clientId' => $clientId, 'uri' => $uri]); - - return; - } - - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $clientState->addSubscription($uri); - $this->saveClientState($clientId, $clientState); - - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $subscribers[$clientId] = true; - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - - $this->logger->debug('Client subscribed to resource.', ['clientId' => $clientId, 'uri' => $uri]); - } catch (Throwable $e) { - $this->logger->error('Failed to add resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - /** - * Removes a resource subscription for a client. - */ - public function removeResourceSubscription(string $clientId, string $uri): void - { - $clientState = $this->getClientState($clientId); - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - if ($clientState) { - $clientState->removeSubscription($uri); - $this->saveClientState($clientId, $clientState); - } - - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $changed = false; - - if (isset($subscribers[$clientId])) { - unset($subscribers[$clientId]); - $changed = true; - } - - if ($changed) { - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - $this->logger->debug('Client unsubscribed from resource.', ['clientId' => $clientId, 'uri' => $uri]); - } - } catch (Throwable $e) { - $this->logger->error('Failed to remove resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - /** - * Removes all resource subscriptions for a client. - */ - public function removeAllResourceSubscriptions(string $clientId): void - { - $clientState = $this->getClientState($clientId); - if (! $clientState || empty($clientState->subscriptions)) { - return; - } - - $urisClientWasSubscribedTo = array_keys($clientState->subscriptions); - - try { - $clientState->clearSubscriptions(); - $this->saveClientState($clientId, $clientState); - - foreach ($urisClientWasSubscribedTo as $uri) { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - if (isset($subscribers[$clientId])) { - unset($subscribers[$clientId]); - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - } - } - $this->logger->debug('Client removed all resource subscriptions.', ['clientId' => $clientId, 'count' => count($urisClientWasSubscribedTo)]); - } catch (Throwable $e) { - $this->logger->error('Failed to remove all resource subscriptions.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** - * Gets the client IDs subscribed to a resource. - * - * @return string[] Client IDs subscribed to the URI - */ - public function getResourceSubscribers(string $uri): array - { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - try { - $subscribers = $this->cache->get($resourceSubKey, []); - - return is_array($subscribers) ? array_keys($subscribers) : []; - } catch (Throwable $e) { - $this->logger->error('Failed to get resource subscribers.', ['uri' => $uri, 'exception' => $e]); - - return []; - } - } - - /** - * Checks if a client is subscribed to a resource. - */ - public function isSubscribedToResource(string $clientId, string $uri): bool - { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $subscribers = $this->cache->get($resourceSubKey, []); - - return is_array($subscribers) && isset($subscribers[$clientId]); - } catch (Throwable $e) { - $this->logger->error('Failed to check resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - - return false; - } - } - - /** - * Queues a message for a client. - */ - public function queueMessage(string $clientId, string $message): void - { - $state = $this->getClientState($clientId, true); - if (! $state) { - return; - } - - $state->addMessageToQueue($message); - $this->saveClientState($clientId, $state); - } - - /** - * Queues a message for all active clients. - */ - public function queueMessageForAll(string $message): void - { - $clients = $this->getActiveClients(); - - foreach ($clients as $clientId) { - $this->queueMessage($clientId, $message); - } - } - - /** - * Gets the queued messages for a client. - * - * @return array Queued messages - */ - public function getQueuedMessages(string $clientId): array - { - $state = $this->getClientState($clientId); - if (! $state) { - return []; - } - - $messages = $state->consumeMessageQueue(); - if (! empty($messages)) { - $this->saveClientState($clientId, $state); - } - - return $messages; - } - - /** - * Sets the requested log level for a specific client. - * This preference is stored in the client's state. - * - * @param string $clientId The ID of the client. - * @param string $level The PSR-3 log level string (e.g., 'debug', 'info'). - */ - public function setClientRequestedLogLevel(string $clientId, string $level): void - { - $state = $this->getClientState($clientId, true); - if (! $state) { - $this->logger->error('Failed to get/create state to set log level.', ['clientId' => $clientId, 'level' => $level]); - - return; - } - - $state->requestedLogLevel = strtolower($level); - $this->saveClientState($clientId, $state); - } - - /** - * Gets the client-requested log level. - * Returns null if the client hasn't set a specific level, implying server default should be used. - * - * @param string $clientId The ID of the client. - * @return string|null The PSR-3 log level string or null. - */ - public function getClientRequestedLogLevel(string $clientId): ?string - { - return $this->getClientState($clientId)?->requestedLogLevel; - } - - /** - * Cleans up a client's state. - */ - public function cleanupClient(string $clientId, bool $removeFromActiveList = true): void - { - $this->removeAllResourceSubscriptions($clientId); - - $clientStateKey = $this->getClientStateCacheKey($clientId); - try { - $this->cache->delete($clientStateKey); - } catch (Throwable $e) { - $this->logger->error('Failed to delete client state object.', ['clientId' => $clientId, 'key' => $clientStateKey, 'exception' => $e]); - } - - if ($removeFromActiveList) { - $activeClientsKey = $this->getActiveClientsCacheKey(); - try { - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - if (isset($activeClients[$clientId])) { - unset($activeClients[$clientId]); - $this->cache->set($activeClientsKey, $activeClients, $this->cacheTtl); - } - } catch (Throwable $e) { - $this->logger->error('Failed to update global active clients list during cleanup.', ['clientId' => $clientId, 'exception' => $e]); - } - } - $this->logger->info('Client state cleaned up.', ['client_id' => $clientId]); - } - - /** - * Updates the global active client list with current timestamp - */ - private function updateGlobalActiveClientTimestamp(string $clientId): void - { - try { - $key = $this->getActiveClientsCacheKey(); - $activeClients = $this->cache->get($key, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - $activeClients[$clientId] = time(); - $this->cache->set($key, $activeClients, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->error('Failed to update global active client timestamp.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** - * Updates client's own lastActivityTimestamp AND the global list - */ - public function updateClientActivity(string $clientId): void - { - $state = $this->getClientState($clientId, true); - if ($state) { - if (! $this->saveClientState($clientId, $state)) { - $this->logger->warning('Failed to save client state after updating activity.', ['clientId' => $clientId]); - } - } - $this->updateGlobalActiveClientTimestamp($clientId); - } - - /** - * Gets the active clients from the global active list. - * - * @return string[] Client IDs from the global active list - */ - public function getActiveClients(int $inactiveThreshold = 300): array - { - try { - $activeClientsKey = $this->getActiveClientsCacheKey(); - $activeClientsData = $this->cache->get($activeClientsKey, []); - $activeClientsData = is_array($activeClientsData) ? $activeClientsData : []; - - $currentTime = time(); - $validActiveClientIds = []; - $clientsToCleanUp = []; - $listNeedsUpdateInCache = false; - - foreach ($activeClientsData as $id => $lastSeen) { - if (! is_string($id) || ! is_int($lastSeen)) { // Sanity check entry - $clientsToCleanUp[] = $id; - $listNeedsUpdateInCache = true; - - continue; - } - if ($currentTime - $lastSeen < $inactiveThreshold) { - $validActiveClientIds[] = $id; - } else { - $clientsToCleanUp[] = $id; - $listNeedsUpdateInCache = true; - } - } - - if ($listNeedsUpdateInCache) { - $updatedList = $activeClientsData; - foreach ($clientsToCleanUp as $idToClean) { - unset($updatedList[$idToClean]); - } - $this->cache->set($activeClientsKey, $updatedList, $this->cacheTtl); - - foreach ($clientsToCleanUp as $idToClean) { - $this->cleanupClient($idToClean, false); // false: already handled active list - } - } - - return $validActiveClientIds; - } catch (Throwable $e) { - $this->logger->error('Failed to get active clients.', ['exception' => $e]); - - return []; - } - } - - /** - * Retrieves the last activity timestamp from the global list. - */ - public function getLastActivityTime(string $clientId): ?int - { - try { - $activeClientsKey = $this->getActiveClientsCacheKey(); - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - $lastSeen = $activeClients[$clientId] ?? null; - - return is_int($lastSeen) ? $lastSeen : null; - } catch (Throwable $e) { - $this->logger->error('Failed to get last activity time.', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } - } -} diff --git a/src/Support/ArgumentPreparer.php b/src/Support/ArgumentPreparer.php deleted file mode 100644 index 9cdcd26..0000000 --- a/src/Support/ArgumentPreparer.php +++ /dev/null @@ -1,201 +0,0 @@ -logger = $logger; - } - - /** - * Prepares the arguments array in the correct order for method invocation. - * - * @param object $instance The class instance where the method resides. - * @param string $methodName The name of the method to prepare arguments for. - * @param array $validatedInput Key-value array of validated input arguments. - * @param array $schema Optional JSON Schema (as array) for the input (currently unused here). - * @return list The ordered list of arguments for splat (...) operator or invokeArgs. - * - * @throws McpException If preparation fails (e.g., required arg missing, type casting fails). - * @throws ReflectionException If method/parameter reflection fails. - */ - public function prepareMethodArguments( - object $instance, - string $methodName, - array $validatedInput, - array $schema = [] - ): array { - if (! method_exists($instance, $methodName)) { - throw new ReflectionException('Method does not exist: '.get_class($instance)."::{$methodName}"); - } - - $reflectionMethod = new ReflectionMethod($instance, $methodName); - $finalArgs = []; - - foreach ($reflectionMethod->getParameters() as $reflectionParameter) { - $paramName = $reflectionParameter->getName(); - $paramPosition = $reflectionParameter->getPosition(); - - if (isset($validatedInput[$paramName])) { - $inputValue = $validatedInput[$paramName]; - try { - $finalArgs[$paramPosition] = $this->castArgumentType($inputValue, $reflectionParameter); - } catch (InvalidArgumentException $e) { - throw McpException::invalidParams($e->getMessage(), $e); - } catch (Throwable $e) { - // Catch other unexpected casting errors - throw McpException::internalError( - "Error processing parameter `{$paramName}`: {$e->getMessage()}", - $e - ); - } - } elseif ($reflectionParameter->isDefaultValueAvailable()) { - $finalArgs[$paramPosition] = $reflectionParameter->getDefaultValue(); - } elseif ($reflectionParameter->allowsNull()) { - $finalArgs[$paramPosition] = null; - } elseif ($reflectionParameter->isOptional()) { - continue; - } else { - // If this happens, it's likely a mismatch between schema validation and reflection - $this->logger->error("Invariant violation: Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$methodName} despite passing schema validation.", [ - 'method' => $methodName, - 'parameter' => $paramName, - 'validated_input_keys' => array_keys($validatedInput), - 'schema' => $schema, // Log schema for debugging - ]); - throw McpException::internalError( - "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$methodName}." - ); - } - } - return array_values($finalArgs); - } - - /** - * Attempts type casting based on ReflectionParameter type hints. - * - * @throws InvalidArgumentException If casting is impossible for the required type. - * @throws TypeError If internal PHP casting fails unexpectedly. - */ - private function castArgumentType(mixed $value, ReflectionParameter $rp): mixed - { - $type = $rp->getType(); - - if ($value === null) { - if ($type && $type->allowsNull()) { - return null; - } - } - - if (! $type instanceof ReflectionNamedType) { - return $value; - } - - $typeName = $type->getName(); - - // --- Handle Backed Enum --- - if (enum_exists($typeName) && is_subclass_of($typeName, \BackedEnum::class)) { - try { - return $typeName::from($value); - } catch (\ValueError $e) { - // Provide a more specific error message - $valueStr = is_scalar($value) ? strval($value) : gettype($value); - throw new InvalidArgumentException( - "Invalid value '{$valueStr}' for enum {$typeName}.", - 0, - $e - ); - } - } - // --- End Enum Handling --- - - // --- Handle Scalar Types --- - try { - return match (strtolower($typeName)) { - 'int', 'integer' => $this->castToInt($value), - 'string' => (string) $value, - 'bool', 'boolean' => $this->castToBoolean($value), - 'float', 'double' => $this->castToFloat($value), - 'array' => $this->castToArray($value), - default => $value, - }; - } catch (TypeError $e) { - throw new InvalidArgumentException( - "Value cannot be cast to required type `{$typeName}`.", - 0, - $e - ); - } - } - - /** Helper to cast strictly to boolean */ - private function castToBoolean(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - if ($value === 1 || $value === '1' || strtolower((string) $value) === 'true') { - return true; - } - if ($value === 0 || $value === '0' || strtolower((string) $value) === 'false') { - return false; - } - throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); - } - - /** Helper to cast strictly to integer */ - private function castToInt(mixed $value): int - { - if (is_int($value)) { - return $value; - } - if (is_numeric($value) && floor((float) $value) == $value && ! is_string($value)) { - return (int) $value; - } - if (is_string($value) && ctype_digit(ltrim($value, '-'))) { - return (int) $value; - } - throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); - } - - /** Helper to cast strictly to float */ - private function castToFloat(mixed $value): float - { - if (is_float($value)) { - return $value; - } - if (is_int($value)) { - return (float) $value; - } - if (is_numeric($value)) { - return (float) $value; - } - throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); - } - - /** Helper to cast strictly to array */ - private function castToArray(mixed $value): array - { - if (is_array($value)) { - return $value; - } - throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); - } -} diff --git a/src/Support/AttributeFinder.php b/src/Support/AttributeFinder.php deleted file mode 100644 index 13f6684..0000000 --- a/src/Support/AttributeFinder.php +++ /dev/null @@ -1,139 +0,0 @@ - $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getClassAttributes(ReflectionClass $reflectionClass, string $attributeClass): array - { - return $reflectionClass->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a class. - * - * @template T of object - * - * @param ReflectionClass $reflectionClass The reflection class. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstClassAttribute(ReflectionClass $reflectionClass, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getClassAttributes($reflectionClass, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a method. - * - * @template T of object - * - * @param ReflectionMethod $reflectionMethod The reflection method. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getMethodAttributes(ReflectionMethod $reflectionMethod, string $attributeClass): array - { - return $reflectionMethod->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a method. - * - * @template T of object - * - * @param ReflectionMethod $reflectionMethod The reflection method. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstMethodAttribute(ReflectionMethod $reflectionMethod, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getMethodAttributes($reflectionMethod, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a property. - * - * @template T of object - * - * @param ReflectionProperty $reflectionProperty The reflection property. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getPropertyAttributes(ReflectionProperty $reflectionProperty, string $attributeClass): array - { - return $reflectionProperty->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a property. - * - * @template T of object - * - * @param ReflectionProperty $reflectionProperty The reflection property. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstPropertyAttribute(ReflectionProperty $reflectionProperty, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getPropertyAttributes($reflectionProperty, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a parameter. - * - * @template T of object - * - * @param ReflectionParameter $reflectionParameter The reflection parameter. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getParameterAttributes(ReflectionParameter $reflectionParameter, string $attributeClass): array - { - return $reflectionParameter->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a parameter. - * - * @template T of object - * - * @param ReflectionParameter $reflectionParameter The reflection parameter. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstParameterAttribute(ReflectionParameter $reflectionParameter, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getParameterAttributes($reflectionParameter, $attributeClass); - - return $attributes[0] ?? null; - } -} diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php deleted file mode 100644 index bf17ee2..0000000 --- a/src/Support/RequestProcessor.php +++ /dev/null @@ -1,534 +0,0 @@ -configuration = $configuration; - $this->registry = $registry; - $this->clientStateManager = $clientStateManager; - $this->container = $configuration->container; - $this->logger = $configuration->logger; - - $this->schemaValidator = $schemaValidator ?? new SchemaValidator($this->configuration->logger); - $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->configuration->logger); - } - - public function process(Request|Notification $message, string $clientId): ?Response - { - $method = $message->method; - $params = $message->params; - $id = $message instanceof Notification ? null : $message->id; - - try { - /** @var Result|null $result */ - $result = null; - - if ($method === 'initialize') { - $result = $this->handleInitialize($params, $clientId); - } elseif ($method === 'ping') { - $result = $this->handlePing($clientId); - } elseif ($method === 'notifications/initialized') { - $this->handleNotificationInitialized($params, $clientId); - - return null; - } else { - $this->validateClientInitialized($clientId); - [$type, $action] = $this->parseMethod($method); - $this->validateCapabilityEnabled($type); - - $result = match ($type) { - 'tools' => match ($action) { - 'list' => $this->handleToolList($params), - 'call' => $this->handleToolCall($params), - default => throw McpServerException::methodNotFound($method), - }, - 'resources' => match ($action) { - 'list' => $this->handleResourcesList($params), - 'read' => $this->handleResourceRead($params), - 'subscribe' => $this->handleResourceSubscribe($params, $clientId), - 'unsubscribe' => $this->handleResourceUnsubscribe($params, $clientId), - 'templates/list' => $this->handleResourceTemplateList($params), - default => throw McpServerException::methodNotFound($method), - }, - 'prompts' => match ($action) { - 'list' => $this->handlePromptsList($params), - 'get' => $this->handlePromptGet($params), - default => throw McpServerException::methodNotFound($method), - }, - 'logging' => match ($action) { - 'setLevel' => $this->handleLoggingSetLevel($params, $clientId), - default => throw McpServerException::methodNotFound($method), - }, - default => throw McpServerException::methodNotFound($method), - }; - } - - if (isset($id) && $result === null && $method !== 'notifications/initialized') { - $this->logger->error('MCP Processor resulted in null for a request requiring a response', ['method' => $method]); - throw McpServerException::internalError("Processing method '{$method}' failed to return a result."); - } - - return isset($id) ? Response::success($result, id: $id) : null; - } catch (McpServerException $e) { - $this->logger->debug('MCP Processor caught McpServerException', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); - - return isset($id) ? Response::error($e->toJsonRpcError(), id: $id) : null; - } catch (Throwable $e) { - $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e]); - $mcpError = McpServerException::internalError("Internal error processing method '{$method}'", $e); // Use internalError factory - - return isset($id) ? Response::error($mcpError->toJsonRpcError(), id: $id) : null; - } - } - - private function parseMethod(string $method): array - { - if (str_contains($method, '/')) { - $parts = explode('/', $method, 2); - if (count($parts) === 2) { - return [$parts[0], $parts[1]]; - } - } - - return [$method, '']; - } - - private function validateClientInitialized(string $clientId): void - { - if (! $this->clientStateManager->isInitialized($clientId)) { - throw McpServerException::invalidRequest('Client not initialized.'); - } - } - - private function validateCapabilityEnabled(string $type): void - { - $caps = $this->configuration->capabilities; - - $enabled = match ($type) { - 'tools' => $caps->toolsEnabled, - 'resources', 'resources/templates' => $caps->resourcesEnabled, - 'resources/subscribe', 'resources/unsubscribe' => $caps->resourcesEnabled && $caps->resourcesSubscribe, - 'prompts' => $caps->promptsEnabled, - 'logging' => $caps->loggingEnabled, - default => false, - }; - - if (! $enabled) { - $methodSegment = explode('/', $type)[0]; - throw McpServerException::methodNotFound("MCP capability '{$methodSegment}' is not enabled on this server."); - } - } - - private function handleInitialize(array $params, string $clientId): InitializeResult - { - $clientProtocolVersion = $params['protocolVersion'] ?? null; - if (! $clientProtocolVersion) { - throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); - } - - if (! in_array($clientProtocolVersion, self::SUPPORTED_PROTOCOL_VERSIONS)) { - $this->logger->warning("Client requested unsupported protocol version: {$clientProtocolVersion}", [ - 'supportedVersions' => self::SUPPORTED_PROTOCOL_VERSIONS, - ]); - } - - $serverProtocolVersion = self::SUPPORTED_PROTOCOL_VERSIONS[count(self::SUPPORTED_PROTOCOL_VERSIONS) - 1]; - - $clientInfo = $params['clientInfo'] ?? null; - if (! is_array($clientInfo)) { - throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); - } - - $this->clientStateManager->storeClientInfo($clientInfo, $serverProtocolVersion, $clientId); - - $serverInfo = [ - 'name' => $this->configuration->serverName, - 'version' => $this->configuration->serverVersion, - ]; - - $serverCapabilities = $this->configuration->capabilities; - $responseCapabilities = $serverCapabilities->toInitializeResponseArray(); - - $instructions = $serverCapabilities->instructions; - - return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); - } - - private function handlePing(string $clientId): EmptyResult - { - return new EmptyResult(); - } - - private function handleNotificationInitialized(array $params, string $clientId): EmptyResult - { - $this->clientStateManager->markInitialized($clientId); - - return new EmptyResult(); - } - - - private function handleToolList(array $params): ListToolsResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allTools()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListToolsResult(array_values($pagedItems), $nextCursor); - } - - private function handleResourcesList(array $params): ListResourcesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResources()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourcesResult(array_values($pagedItems), $nextCursor); - } - - private function handleResourceTemplateList(array $params): ListResourceTemplatesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResourceTemplates()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); - } - - private function handlePromptsList(array $params): ListPromptsResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allPrompts()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListPromptsResult(array_values($pagedItems), $nextCursor); - } - - private function handleToolCall(array $params): CallToolResult - { - $toolName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? null; - - if (! is_string($toolName) || empty($toolName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); - } - - if ($arguments === null || $arguments === []) { - $arguments = new stdClass(); - } elseif (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); - } - - $definition = $this->registry->findTool($toolName); - if (! $definition) { - throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); - } - - $inputSchema = $definition->getInputSchema(); - - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); - - if (! empty($validationErrors)) { - $errorMessages = []; - - foreach ($validationErrors as $errorDetail) { - $pointer = $errorDetail['pointer'] ?? ''; - $message = $errorDetail['message'] ?? 'Unknown validation error'; - $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; - } - - $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); - - if (count($errorMessages) > 3) { - $summaryMessage .= '; ...and more errors.'; - } - - throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); - } - - $argumentsForPhpCall = (array) $arguments; - - try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $argumentsForPhpCall, - $inputSchema - ); - - $toolExecutionResult = $instance->{$methodName}(...$args); - $formattedResult = $this->formatToolResult($toolExecutionResult); - - return new CallToolResult($formattedResult, false); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); - $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; - - return new CallToolResult([new TextContent($errorMessage)], true); - } catch (Throwable $toolError) { - $this->logger->error('MCP SDK: Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); - $errorContent = $this->formatToolErrorResult($toolError); - - return new CallToolResult($errorContent, true); - } - } - - private function handleResourceRead(array $params): ReadResourceResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); - } - - $definition = null; - $uriVariables = []; - - $definition = $this->registry->findResourceByUri($uri); - - if (! $definition) { - $templateResult = $this->registry->findResourceTemplateByUri($uri); - if ($templateResult) { - $definition = $templateResult['definition']; - $uriVariables = $templateResult['variables']; - } else { - throw McpServerException::invalidParams("Resource URI '{$uri}' not found or no handler available."); - } - } - - try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); - - $methodParams = array_merge($uriVariables, ['uri' => $uri]); - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $methodParams, - [] - ); - - $readResult = $instance->{$methodName}(...$args); - $contents = $this->formatResourceContents($readResult, $uri, $definition->getMimeType()); - - return new ReadResourceResult($contents); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); - throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); - } catch (McpServerException $e) { - throw $e; - } catch (Throwable $e) { - $this->logger->error('MCP SDK: Resource read failed.', ['uri' => $uri, 'exception' => $e]); - throw McpServerException::resourceReadFailed($uri, $e); - } - } - - private function handleResourceSubscribe(array $params, string $clientId): EmptyResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); - } - - $this->validateCapabilityEnabled('resources/subscribe'); - - $this->clientStateManager->addResourceSubscription($clientId, $uri); - - return new EmptyResult(); - } - - private function handleResourceUnsubscribe(array $params, string $clientId): EmptyResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); - } - - $this->validateCapabilityEnabled('resources/unsubscribe'); - - $this->clientStateManager->removeResourceSubscription($clientId, $uri); - - return new EmptyResult(); - } - - private function handlePromptGet(array $params): GetPromptResult - { - $promptName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? []; - - if (! is_string($promptName) || empty($promptName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); - } - if (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for prompts/get."); - } - - $definition = $this->registry->findPrompt($promptName); - if (! $definition) { - throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); - } - - $arguments = (array) $arguments; - - foreach ($definition->getArguments() as $argDef) { - if ($argDef->isRequired() && ! array_key_exists($argDef->getName(), $arguments)) { - throw McpServerException::invalidParams("Missing required argument '{$argDef->getName()}' for prompt '{$promptName}'."); - } - } - - try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); - - // Prepare arguments for the prompt generator method - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $arguments, - [] // No input schema for prompts - ); - - $promptGenerationResult = $instance->{$methodName}(...$args); - $messages = $this->formatPromptMessages($promptGenerationResult); - - return new GetPromptResult($messages, $definition->getDescription()); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); - throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); - } catch (McpServerException $e) { - throw $e; // Re-throw known MCP errors - } catch (Throwable $e) { - $this->logger->error('MCP SDK: Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); - throw McpServerException::promptGenerationFailed($promptName, $e); // Use specific factory - } - } - - private function handleLoggingSetLevel(array $params, string $clientId): EmptyResult - { - $level = $params['level'] ?? null; - $validLevels = [ - LogLevel::EMERGENCY, - LogLevel::ALERT, - LogLevel::CRITICAL, - LogLevel::ERROR, - LogLevel::WARNING, - LogLevel::NOTICE, - LogLevel::INFO, - LogLevel::DEBUG, - ]; - - if (! is_string($level) || ! in_array(strtolower($level), $validLevels)) { - throw McpServerException::invalidParams("Invalid or missing 'level'. Must be one of: " . implode(', ', $validLevels)); - } - - $this->validateCapabilityEnabled('logging'); - - $this->clientStateManager->setClientRequestedLogLevel($clientId, strtolower($level)); - - $this->logger->info("Processor: Client '{$clientId}' requested log level set to '{$level}'."); - - return new EmptyResult(); - } - - private function decodeCursor(?string $cursor): int - { - if ($cursor === null) { - return 0; - } - $decoded = base64_decode($cursor, true); - if ($decoded === false) { - $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); - - return 0; - } - if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { - return (int) $matches[1]; - } - $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); - - return 0; - } - - private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string - { - $nextOffset = $currentOffset + $returnedCount; - if ($returnedCount > 0 && $nextOffset < $totalCount) { - return base64_encode("offset={$nextOffset}"); - } - - return null; - } -} diff --git a/src/Support/UriTemplateMatcher.php b/src/Support/UriTemplateMatcher.php deleted file mode 100644 index 8ebb004..0000000 --- a/src/Support/UriTemplateMatcher.php +++ /dev/null @@ -1,57 +0,0 @@ -template = $template; - $this->compileTemplate(); - } - - private function compileTemplate(): void - { - $this->variableNames = []; - $regexParts = []; - - // Split the template by placeholders, keeping the delimiters - $segments = preg_split('/(\{\w+\})/', $this->template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - - foreach ($segments as $segment) { - if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { - // This segment is a placeholder like {var} - $varName = $matches[1]; - $this->variableNames[] = $varName; - // Append named capture group (match non-slash characters) - $regexParts[] = '(?P<' . $varName . '>[^/]+)'; - } else { - // This is a literal part, escape it - $regexParts[] = preg_quote($segment, '#'); - } - } - - $this->regex = '#^' . implode('', $regexParts) . '$#'; - } - - public function match(string $uri): ?array - { - if (preg_match($this->regex, $uri, $matches)) { - $variables = []; - // Extract only the named capture groups - foreach ($this->variableNames as $varName) { - if (isset($matches[$varName])) { - $variables[$varName] = $matches[$varName]; - } - } - return $variables; - } - - return null; - } -} diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php deleted file mode 100644 index 735e253..0000000 --- a/src/Traits/ResponseFormatter.php +++ /dev/null @@ -1,396 +0,0 @@ - Content objects describing the error. - */ - protected function formatToolErrorResult(Throwable $toolError): array - { - // Provide a user/LLM-friendly error message. Avoid stack traces. - $errorMessage = 'Tool execution failed: '.$toolError->getMessage(); - // Include exception type name for context, might help debugging/LLM understanding. - $errorMessage .= ' (Type: '.get_class($toolError).')'; - - return [ - new TextContent($errorMessage), - ]; - } - - /** - * Formats the raw result of a resource read operation into MCP ResourceContents items. - * - * @param mixed $readResult The raw result from the resource handler method. - * @param string $uri The URI of the resource that was read. - * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. - * @return array Array of EmbeddedResource objects. - * - * @throws \RuntimeException If the result cannot be formatted. - * - * Supported result types: - * - EmbeddedResource: Used as-is - * - ResourceContent: Embedded resource is extracted - * - string: Converted to text content with guessed or provided MIME type - * - stream resource: Read and converted to blob with provided MIME type - * - array with 'blob' key: Used as blob content - * - array with 'text' key: Used as text content - * - SplFileInfo: Read and converted to blob - * - array: Converted to JSON if MIME type is application/json or contains 'json' - * For other MIME types, will try to convert to JSON with a warning - */ - protected function formatResourceContents(mixed $readResult, string $uri, ?string $defaultMimeType): array - { - // If already an EmbeddedResource, just wrap it - if ($readResult instanceof EmbeddedResource) { - return [$readResult]; - } - - // If it's a ResourceContent, extract the embedded resource - if ($readResult instanceof ResourceContent) { - return [$readResult->getResource()]; - } - - // Handle array of EmbeddedResource objects - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return $readResult; - } - - // Handle array of ResourceContent objects - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContent) { - return array_map(fn ($item) => $item->getResource(), $readResult); - } - - // Handle string (text content) - if (is_string($readResult)) { - $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); - - return [new EmbeddedResource($uri, $mimeType, $readResult)]; - } - - // Handle stream resources - if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { - // Let exceptions bubble up - $result = EmbeddedResource::fromStream( - $uri, - $readResult, - $defaultMimeType ?? 'application/octet-stream' - ); - - // Ensure stream is closed if we opened/read it - if (is_resource($readResult)) { - @fclose($readResult); - } - - return [$result]; - } - - // Handle pre-formatted array structure - if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; - - return [new EmbeddedResource($uri, $mimeType, null, $readResult['blob'])]; - } - - if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; - - return [new EmbeddedResource($uri, $mimeType, $readResult['text'])]; - } - - // Handle SplFileInfo - if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - // Let exceptions bubble up - return [EmbeddedResource::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; - } - - // Handle arrays for JSON MIME types - convert to JSON string - if (is_array($readResult)) { - // If default MIME type is JSON or contains 'json', encode the array to JSON - if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || - $defaultMimeType === 'application/json')) { - try { - $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - - return [new EmbeddedResource($uri, $defaultMimeType, $jsonString)]; - } catch (\JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode array resource result', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - ]); - throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); - } - } - - // For non-JSON mime types, we could still try to encode the array, but with a warning - try { - $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - $mimeType = 'application/json'; // Override to JSON mime type - $this->logger->warning('MCP SDK: Automatically converted array to JSON for resource', [ - 'uri' => $uri, - 'requestedMimeType' => $defaultMimeType, - 'usedMimeType' => $mimeType, - ]); - - return [new EmbeddedResource($uri, $mimeType, $jsonString)]; - } catch (\JsonException $e) { - // If JSON encoding fails, log error and continue to the error handling below - $this->logger->error('MCP SDK: Failed to encode array resource result as JSON', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - ]); - } - } - - $this->logger->error('MCP SDK: Unformattable resource read result type.', ['type' => gettype($readResult), 'uri' => $uri]); - throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".gettype($readResult)); - } - - /** Guesses MIME type from string content (very basic) */ - private function guessMimeTypeFromString(string $content): string - { - $trimmed = ltrim($content); - if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - // Looks like HTML or XML? Prefer text/plain unless sure. - if (stripos($trimmed, ' Array of PromptMessage objects. - * - * @throws \RuntimeException If the result cannot be formatted. - * @throws \JsonException If JSON encoding fails. - */ - protected function formatPromptMessages(mixed $promptGenerationResult): array - { - // If already an array of PromptMessage objects, use as is - if (is_array($promptGenerationResult) && ! empty($promptGenerationResult) - && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage) { - return $promptGenerationResult; - } - - // Handle simple role => text pairs array - if (is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) - && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant']))) { - - $result = []; - if (isset($promptGenerationResult['user'])) { - $result[] = PromptMessage::user($promptGenerationResult['user']); - } - if (isset($promptGenerationResult['assistant'])) { - $result[] = PromptMessage::assistant($promptGenerationResult['assistant']); - } - - if (! empty($result)) { - return $result; - } - } - - if (! is_array($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return an array of messages.'); - } - - // Ensure it's a list of messages - if (! array_is_list($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return a list (sequential array) of messages, not an associative array.'); - } - - $formattedMessages = []; - foreach ($promptGenerationResult as $index => $message) { - // If it's already a PromptMessage, use it directly - if ($message instanceof PromptMessage) { - $formattedMessages[] = $message; - - continue; - } - - // Handle simple role => content object - if (is_array($message) && isset($message['role']) && isset($message['content']) && count($message) === 2) { - $role = $message['role']; - $content = $message['content']; - - if (! in_array($role, ['user', 'assistant'])) { - throw new \RuntimeException("Invalid role '{$role}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); - } - - // If content is already a Content object - if ($content instanceof Content) { - $formattedMessages[] = new PromptMessage($role, $content); - - continue; - } - - // If content is a string, convert to TextContent - if (is_string($content)) { - $formattedMessages[] = new PromptMessage($role, new TextContent($content)); - - continue; - } - - // Handle content array with type field - if (is_array($content) && isset($content['type'])) { - $type = $content['type']; - if (! in_array($type, ['text', 'image', 'audio', 'resource'])) { - throw new \RuntimeException("Invalid content type '{$type}' in prompt message at index {$index}."); - } - - // Convert to appropriate Content object - $contentObj = null; - switch ($type) { - case 'text': - if (! isset($content['text']) || ! is_string($content['text'])) { - throw new \RuntimeException("Invalid 'text' content: Missing or invalid 'text' string."); - } - $contentObj = new TextContent($content['text']); - break; - - case 'image': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'mimeType' string."); - } - $contentObj = new ImageContent($content['data'], $content['mimeType']); - break; - - case 'audio': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'mimeType' string."); - } - $contentObj = new AudioContent($content['data'], $content['mimeType']); - break; - - case 'resource': - if (! isset($content['resource']) || ! is_array($content['resource'])) { - throw new \RuntimeException("Invalid 'resource' content: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (! isset($resource['uri']) || ! is_string($resource['uri'])) { - throw new \RuntimeException("Invalid resource: Missing or invalid 'uri'."); - } - - $embeddedResource = null; - if (isset($resource['text']) && is_string($resource['text'])) { - $embeddedResource = new EmbeddedResource( - $resource['uri'], - $resource['mimeType'] ?? 'text/plain', - $resource['text'] - ); - } elseif (isset($resource['blob']) && is_string($resource['blob'])) { - $embeddedResource = new EmbeddedResource( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - null, - $resource['blob'] - ); - } else { - throw new \RuntimeException("Invalid resource: Must contain 'text' or 'blob'."); - } - - $contentObj = new ResourceContent($embeddedResource); - break; - } - - if ($contentObj) { - $formattedMessages[] = new PromptMessage($role, $contentObj); - - continue; - } - } - - throw new \RuntimeException("Invalid content format at index {$index}. Must be a string, Content object, or valid content array."); - } - - throw new \RuntimeException("Invalid message format at index {$index}. Expected a PromptMessage or an array with 'role' and 'content' keys."); - } - - return $formattedMessages; - } -} diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index 773b009..1965704 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -9,6 +9,9 @@ use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; +use PhpMcp\Schema\JsonRpc\Message; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Parser; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -31,7 +34,7 @@ * * Listens for HTTP connections, manages SSE streams, and emits events. */ -class HttpServerTransport implements LoggerAwareInterface, LoopAwareInterface, ServerTransportInterface +class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; @@ -43,8 +46,8 @@ class HttpServerTransport implements LoggerAwareInterface, LoopAwareInterface, S protected ?HttpServer $http = null; - /** @var array clientId => SSE Stream */ - protected array $activeSseStreams = []; + /** @var array sessionId => SSE Stream */ + private array $activeSseStreams = []; protected bool $listening = false; @@ -61,10 +64,10 @@ class HttpServerTransport implements LoggerAwareInterface, LoopAwareInterface, S * @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS). */ public function __construct( - protected readonly string $host = '127.0.0.1', - protected readonly int $port = 8080, - protected readonly string $mcpPathPrefix = 'mcp', // e.g., /mcp/sse, /mcp/message - protected readonly ?array $sslContext = null // For enabling HTTPS + private readonly string $host = '127.0.0.1', + private readonly int $port = 8080, + private readonly string $mcpPathPrefix = 'mcp', + private readonly ?array $sslContext = null, ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); @@ -82,6 +85,11 @@ public function setLoop(LoopInterface $loop): void $this->loop = $loop; } + protected function generateId(): string + { + return bin2hex(random_bytes(16)); // 32 hex characters + } + /** * Starts the HTTP server listener. * @@ -136,17 +144,14 @@ protected function createRequestHandler(): callable $method = $request->getMethod(); $this->logger->debug('Received request', ['method' => $method, 'path' => $path]); - // --- SSE Connection Handling --- if ($method === 'GET' && $path === $this->ssePath) { return $this->handleSseRequest($request); } - // --- Message POST Handling --- if ($method === 'POST' && $path === $this->messagePath) { return $this->handleMessagePostRequest($request); } - // --- Not Found --- $this->logger->debug('404 Not Found', ['method' => $method, 'path' => $path]); return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); @@ -156,40 +161,41 @@ protected function createRequestHandler(): callable /** Handles a new SSE connection request */ protected function handleSseRequest(ServerRequestInterface $request): Response { - $clientId = 'sse_' . bin2hex(random_bytes(16)); - $this->logger->info('New SSE connection', ['clientId' => $clientId]); + $sessionId = $this->generateId(); + $this->logger->info('New SSE connection', ['sessionId' => $sessionId]); $sseStream = new ThroughStream(); - $sseStream->on('close', function () use ($clientId) { - $this->logger->info('SSE stream closed', ['clientId' => $clientId]); - unset($this->activeSseStreams[$clientId]); - $this->emit('client_disconnected', [$clientId, 'SSE stream closed']); + $sseStream->on('close', function () use ($sessionId) { + $this->logger->info('SSE stream closed', ['sessionId' => $sessionId]); + unset($this->activeSseStreams[$sessionId]); + $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); }); - $sseStream->on('error', function (Throwable $error) use ($clientId) { - $this->logger->warning('SSE stream error', ['clientId' => $clientId, 'error' => $error->getMessage()]); - unset($this->activeSseStreams[$clientId]); - $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $clientId]); - $this->emit('client_disconnected', [$clientId, 'SSE stream error']); + $sseStream->on('error', function (Throwable $error) use ($sessionId) { + $this->logger->warning('SSE stream error', ['sessionId' => $sessionId, 'error' => $error->getMessage()]); + unset($this->activeSseStreams[$sessionId]); + $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $sessionId]); + $this->emit('client_disconnected', [$sessionId, 'SSE stream error']); }); - $this->activeSseStreams[$clientId] = $sseStream; + $this->activeSseStreams[$sessionId] = $sseStream; - $this->loop->futureTick(function () use ($clientId, $request, $sseStream) { - if (! isset($this->activeSseStreams[$clientId]) || ! $sseStream->isWritable()) { - $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['clientId' => $clientId]); + $this->loop->futureTick(function () use ($sessionId, $request, $sseStream) { + if (! isset($this->activeSseStreams[$sessionId]) || ! $sseStream->isWritable()) { + $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['sessionId' => $sessionId]); return; } try { - $postEndpoint = $this->messagePath . "?clientId={$clientId}"; - $this->sendSseEvent($sseStream, 'endpoint', $postEndpoint, "init-{$clientId}"); + $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment(''); + $postEndpointWithId = (string) $baseUri->withQuery("clientId={$sessionId}"); + $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$sessionId}"); - $this->emit('client_connected', [$clientId]); + $this->emit('client_connected', [$sessionId]); } catch (Throwable $e) { - $this->logger->error('Error sending initial endpoint event', ['clientId' => $clientId, 'exception' => $e]); + $this->logger->error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]); $sseStream->close(); } }); @@ -211,66 +217,85 @@ protected function handleSseRequest(ServerRequestInterface $request): Response protected function handleMessagePostRequest(ServerRequestInterface $request): Response { $queryParams = $request->getQueryParams(); - $clientId = $queryParams['clientId'] ?? null; + $sessionId = $queryParams['clientId'] ?? null; + $jsonEncodeFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; - if (! $clientId || ! is_string($clientId)) { + if (! $sessionId || ! is_string($sessionId)) { $this->logger->warning('Received POST without valid clientId query parameter.'); + $error = Error::forInvalidRequest('Missing or invalid clientId query parameter'); - return new Response(400, ['Content-Type' => 'text/plain'], 'Missing or invalid clientId query parameter'); + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } - if (! isset($this->activeSseStreams[$clientId])) { - $this->logger->warning('Received POST for unknown or disconnected clientId.', ['clientId' => $clientId]); + if (! isset($this->activeSseStreams[$sessionId])) { + $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]); + + $error = Error::forInvalidRequest('Session ID not found or disconnected'); - return new Response(404, ['Content-Type' => 'text/plain'], 'Client ID not found or disconnected'); + return new Response(404, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { - return new Response(415, ['Content-Type' => 'text/plain'], 'Content-Type must be application/json'); + $error = Error::forInvalidRequest('Content-Type must be application/json'); + + return new Response(415, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } $body = $request->getBody()->getContents(); if (empty($body)) { - $this->logger->warning('Received empty POST body', ['clientId' => $clientId]); + $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]); + + $error = Error::forInvalidRequest('Empty request body'); - return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } - $this->emit('message', [$body, $clientId]); + try { + $message = Parser::parse($body); + } catch (Throwable $e) { + $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); + + $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage()); + + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); + } + + $this->emit('message', [$message, $sessionId]); return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); } + /** * Sends a raw JSON-RPC message frame to a specific client via SSE. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { - if (! isset($this->activeSseStreams[$clientId])) { - return reject(new TransportException("Cannot send message: Client '{$clientId}' not connected via SSE.")); + if (! isset($this->activeSseStreams[$sessionId])) { + return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); } - $stream = $this->activeSseStreams[$clientId]; + $stream = $this->activeSseStreams[$sessionId]; if (! $stream->isWritable()) { - return reject(new TransportException("Cannot send message: SSE stream for client '{$clientId}' is not writable.")); + return reject(new TransportException("Cannot send message: SSE stream for client '{$sessionId}' is not writable.")); } - $jsonData = trim($rawFramedMessage); + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if ($jsonData === '') { + if ($json === '') { return resolve(null); } $deferred = new Deferred(); - $written = $this->sendSseEvent($stream, 'message', $jsonData); + $written = $this->sendSseEvent($stream, 'message', $json); if ($written) { $deferred->resolve(null); } else { - $this->logger->debug('SSE stream buffer full, waiting for drain.', ['clientId' => $clientId]); - $stream->once('drain', function () use ($deferred, $clientId) { - $this->logger->debug('SSE stream drained.', ['clientId' => $clientId]); + $this->logger->debug('SSE stream buffer full, waiting for drain.', ['sessionId' => $sessionId]); + $stream->once('drain', function () use ($deferred, $sessionId) { + $this->logger->debug('SSE stream drained.', ['sessionId' => $sessionId]); $deferred->resolve(null); }); } @@ -320,9 +345,9 @@ public function close(): void $activeStreams = $this->activeSseStreams; $this->activeSseStreams = []; - foreach ($activeStreams as $clientId => $stream) { - $this->logger->debug('Closing active SSE stream', ['clientId' => $clientId]); - unset($this->activeSseStreams[$clientId]); + foreach ($activeStreams as $sessionId => $stream) { + $this->logger->debug('Closing active SSE stream', ['sessionId' => $sessionId]); + unset($this->activeSseStreams[$sessionId]); $stream->close(); } diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 68bb65d..011efe6 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -5,10 +5,13 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; +use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Message; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; @@ -28,7 +31,7 @@ * Implementation of the STDIO server transport using ReactPHP Process and Streams. * Listens on STDIN, writes to STDOUT, and emits events for the Protocol. */ -class StdioServerTransport implements LoggerAwareInterface, LoopAwareInterface, ServerTransportInterface +class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; @@ -118,7 +121,7 @@ public function listen(): void $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop); $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop); } catch (Throwable $e) { - $this->logger->error('StdioTransport: Failed to open STDIN/STDOUT streams.', ['exception' => $e]); + $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]); throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e); } @@ -128,7 +131,7 @@ public function listen(): void }); $this->stdin->on('error', function (Throwable $error) { - $this->logger->error('StdioTransport: STDIN stream error.', ['error' => $error->getMessage()]); + $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); @@ -140,14 +143,13 @@ public function listen(): void }); $this->stdout->on('error', function (Throwable $error) { - $this->logger->error('StdioTransport: STDOUT stream error.', ['error' => $error->getMessage()]); + $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); $signalHandler = function (int $signal) { - $this->logger->info("StdioTransport: Received signal {$signal}, shutting down."); - // $this->emit('client_disconnected', [self::CLIENT_ID, 'SIGTERM/SIGINT']); + $this->logger->info("Received signal {$signal}, shutting down."); $this->close(); }; $this->loop->addSignal(SIGTERM, $signalHandler); @@ -170,34 +172,39 @@ private function processBuffer(): void $this->buffer = substr($this->buffer, $pos + 1); $trimmedLine = trim($line); - if ($trimmedLine !== '') { - $this->emit('message', [$trimmedLine, self::CLIENT_ID]); + if (empty($trimmedLine)) { + continue; } + + try { + $message = Parser::parse($trimmedLine); + } catch (Throwable $e) { + $this->logger->error('Error parsing message', ['exception' => $e]); + $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); + $this->sendMessage($error, self::CLIENT_ID); + continue; + } + + $this->emit('message', [$message, self::CLIENT_ID]); } } /** * Sends a raw, framed message to STDOUT. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { - if ($clientId !== self::CLIENT_ID) { - $this->logger->error("Attempted to send message to invalid clientId '{$clientId}'."); - - return reject(new TransportException("Invalid clientId '{$clientId}' for Stdio transport.")); - } - if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) { return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); } $deferred = new Deferred(); - $written = $this->stdout->write($rawFramedMessage); + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $written = $this->stdout->write($json . "\n"); if ($written) { $deferred->resolve(null); } else { - // Handle backpressure: resolve the promise once the stream drains $this->logger->debug('STDOUT buffer full, waiting for drain.'); $this->stdout->once('drain', function () use ($deferred) { $this->logger->debug('STDOUT drained.'); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php new file mode 100644 index 0000000..75eee9f --- /dev/null +++ b/src/Transports/StreamableHttpServerTransport.php @@ -0,0 +1,594 @@ + + */ + private array $pendingRequests = []; + + /** + * Stores active SSE streams. + * Key: streamId + * Value: ['stream' => ThroughStream, 'sessionId' => string, 'context' => array] + * @var array + */ + private array $activeSseStreams = []; + + private ?ThroughStream $getStream = null; + + /** + * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream. + * This can be useful for simple request/response scenarios without streaming. + */ + public function __construct( + private readonly string $host = '127.0.0.1', + private readonly int $port = 8080, + private string $mcpPath = '/mcp', + private ?array $sslContext = null, + private readonly bool $enableJsonResponse = true, + ?EventStoreInterface $eventStore = null + ) { + $this->logger = new NullLogger(); + $this->loop = Loop::get(); + $this->mcpPath = '/' . trim($mcpPath, '/'); + $this->eventStore = $eventStore; + } + + protected function generateId(): string + { + return bin2hex(random_bytes(16)); // 32 hex characters + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function setLoop(LoopInterface $loop): void + { + $this->loop = $loop; + } + + public function listen(): void + { + if ($this->listening) { + throw new TransportException('StreamableHttp transport is already listening.'); + } + + if ($this->closing) { + throw new TransportException('Cannot listen, transport is closing/closed.'); + } + + $listenAddress = "{$this->host}:{$this->port}"; + $protocol = $this->sslContext ? 'https' : 'http'; + + try { + $this->socket = new SocketServer( + $listenAddress, + $this->sslContext ?? [], + $this->loop + ); + + $this->http = new HttpServer($this->loop, $this->createRequestHandler()); + $this->http->listen($this->socket); + + $this->socket->on('error', function (Throwable $error) { + $this->logger->error('Socket server error (StreamableHttp).', ['error' => $error->getMessage()]); + $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]); + $this->close(); + }); + + $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} ๐Ÿš€"); + $this->logger->info("MCP Endpoint: {$protocol}://{$listenAddress}{$this->mcpPath}"); + + $this->listening = true; + $this->closing = false; + $this->emit('ready'); + } catch (Throwable $e) { + $this->logger->error("Failed to start StreamableHttp listener on {$listenAddress}", ['exception' => $e]); + throw new TransportException("Failed to start StreamableHttp listener on {$listenAddress}: {$e->getMessage()}", 0, $e); + } + } + + private function createRequestHandler(): callable + { + return function (ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + $method = $request->getMethod(); + + $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]); + + if ($path !== $this->mcpPath) { + $error = Error::forInvalidRequest("Not found: {$path}"); + return new HttpResponse(404, ['Content-Type' => 'application/json'], json_encode($error)); + } + + $corsHeaders = [ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization', + ]; + + if ($method === 'OPTIONS') { + return new HttpResponse(204, $corsHeaders); + } + + $addCors = function (HttpResponse $r) use ($corsHeaders) { + foreach ($corsHeaders as $key => $value) { + $r = $r->withAddedHeader($key, $value); + } + return $r; + }; + + try { + return match ($method) { + 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + default => $addCors($this->handleUnsupportedRequest($request)), + }; + } catch (Throwable $e) { + return $addCors($this->handleRequestError($e, $request)); + } + }; + } + + private function handleGetRequest(ServerRequestInterface $request): PromiseInterface + { + $acceptHeader = $request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests."); + return resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); + } + + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + if (empty($sessionId)) { + $this->logger->warning("GET request without Mcp-Session-Id."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for GET requests."); + return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + } + + $this->getStream = new ThroughStream(); + + $this->getStream->on('close', function () use ($sessionId) { + $this->logger->debug("GET SSE stream closed.", ['sessionId' => $sessionId]); + $this->getStream = null; + }); + + $this->getStream->on('error', function (Throwable $e) use ($sessionId) { + $this->logger->error("GET SSE stream error.", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + $this->getStream = null; + }); + + $headers = [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]; + + $response = new HttpResponse(200, $headers, $this->getStream); + + if ($this->eventStore) { + $lastEventId = $request->getHeaderLine('Last-Event-ID'); + $this->replayEvents($lastEventId, $this->getStream, $sessionId); + } + + return resolve($response); + } + + private function handlePostRequest(ServerRequestInterface $request): PromiseInterface + { + $deferred = new Deferred(); + + $acceptHeader = $request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { + $error = Error::forInvalidRequest("Not Acceptable: Client must accept both application/json or text/event-stream"); + $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { + $error = Error::forInvalidRequest("Unsupported Media Type: Content-Type must be application/json"); + $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + $body = $request->getBody()->getContents(); + + if (empty($body)) { + $this->logger->warning("Received empty POST body"); + $error = Error::forInvalidRequest("Empty request body."); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + try { + $message = Parser::parse($body); + } catch (Throwable $e) { + $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]); + $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + $isInitializeRequest = ($message instanceof Request && $message->method === 'initialize'); + $sessionId = null; + + if ($isInitializeRequest) { + if ($request->hasHeader('Mcp-Session-Id')) { + $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]); + $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId()); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + $sessionId = $this->generateId(); + $this->emit('client_connected', [$sessionId]); + } else { + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + + if (empty($sessionId)) { + $this->logger->warning("POST request without Mcp-Session-Id."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId()); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + } + + $context = [ + 'is_initialize_request' => $isInitializeRequest, + ]; + + $nRequests = match (true) { + $message instanceof Request => 1, + $message instanceof BatchRequest => $message->nRequests(), + default => 0, + }; + + if ($nRequests === 0) { + $deferred->resolve(new HttpResponse(202)); + $context['type'] = 'post_202_sent'; + } else { + if ($this->enableJsonResponse) { + $pendingRequestId = $this->generateId(); + $this->pendingRequests[$pendingRequestId] = $deferred; + + $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { + if (isset($this->pendingRequests[$pendingRequestId])) { + $deferred = $this->pendingRequests[$pendingRequestId]; + unset($this->pendingRequests[$pendingRequestId]); + $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); + $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId); + $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray()))); + } + }); + + $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { + $this->loop->cancelTimer($timeoutTimer); + }); + + $context['type'] = 'post_json'; + $context['pending_request_id'] = $pendingRequestId; + } else { + $streamId = $this->generateId(); + $sseStream = new ThroughStream(); + $this->activeSseStreams[$streamId] = [ + 'stream' => $sseStream, + 'sessionId' => $sessionId, + 'context' => ['nRequests' => $nRequests, 'nResponses' => 0] + ]; + + $sseStream->on('close', function () use ($streamId) { + $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId']]); + unset($this->activeSseStreams[$streamId]); + }); + $sseStream->on('error', function (Throwable $e) use ($streamId) { + $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId'], 'error' => $e->getMessage()]); + unset($this->activeSseStreams[$streamId]); + }); + + $headers = [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]; + + if (!empty($sessionId)) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + $deferred->resolve(new HttpResponse(200, $headers, $sseStream)); + $context['type'] = 'post_sse'; + $context['streamId'] = $streamId; + $context['nRequests'] = $nRequests; + } + } + + $this->loop->futureTick(function () use ($message, $sessionId, $context) { + $this->emit('message', [$message, $sessionId, $context]); + }); + + return $deferred->promise(); + } + + private function handleDeleteRequest(ServerRequestInterface $request): PromiseInterface + { + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + if (empty($sessionId)) { + $this->logger->warning("DELETE request without Mcp-Session-Id."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for DELETE."); + return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + } + + $streamsToClose = []; + foreach ($this->activeSseStreams as $streamId => $streamInfo) { + if ($streamInfo['sessionId'] === $sessionId) { + $streamsToClose[] = $streamId; + } + } + + foreach ($streamsToClose as $streamId) { + $this->activeSseStreams[$streamId]['stream']->end(); + unset($this->activeSseStreams[$streamId]); + } + + if ($this->getStream !== null) { + $this->getStream->end(); + $this->getStream = null; + } + + $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']); + + return resolve(new HttpResponse(204)); + } + + private function handleUnsupportedRequest(ServerRequestInterface $request): HttpResponse + { + $error = Error::forInvalidRequest("Method not allowed: {$request->getMethod()}"); + $headers = [ + 'Content-Type' => 'application/json', + 'Allow' => 'GET, POST, DELETE, OPTIONS', + ]; + return new HttpResponse(405, $headers, json_encode($error)); + } + + private function handleRequestError(Throwable $e, ServerRequestInterface $request): HttpResponse + { + $this->logger->error("Error processing HTTP request", [ + 'method' => $request->getMethod(), + 'path' => $request->getUri()->getPath(), + 'exception' => $e->getMessage() + ]); + + if ($e instanceof TransportException) { + $error = Error::forInternalError("Transport Error: " . $e->getMessage()); + return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); + } + + $error = Error::forInternalError("Internal Server Error during HTTP request processing."); + return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); + } + + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface + { + if ($this->closing) { + return reject(new TransportException('Transport is closing.')); + } + + $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof Response); + + switch ($context['type'] ?? null) { + case 'post_202_sent': + return resolve(null); + + case 'post_sse': + $streamId = $context['streamId']; + if (!isset($this->activeSseStreams[$streamId])) { + $this->logger->error("SSE stream for POST not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("SSE stream {$streamId} not found for POST response.")); + } + + $stream = $this->activeSseStreams[$streamId]['stream']; + if (!$stream->isWritable()) { + $this->logger->warning("SSE stream for POST is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("SSE stream {$streamId} for POST is not writable.")); + } + + $sentCountThisCall = 0; + + if ($message instanceof Response || $message instanceof Error) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + $sentCountThisCall = 1; + } elseif ($message instanceof BatchResponse) { + foreach ($message->getAll() as $singleResponse) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + $sentCountThisCall++; + } + } + + if (isset($this->activeSseStreams[$streamId]['context'])) { + $this->activeSseStreams[$streamId]['context']['nResponses'] += $sentCountThisCall; + if ($this->activeSseStreams[$streamId]['context']['nResponses'] >= $this->activeSseStreams[$streamId]['context']['nRequests']) { + $this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + $stream->end(); // Will trigger 'close' event. + } + } + + return resolve(null); + + case 'post_json': + $pendingRequestId = $context['pending_request_id']; + if (!isset($this->pendingRequests[$pendingRequestId])) { + $this->logger->error("Pending direct JSON request not found.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); + return reject(new TransportException("Pending request {$pendingRequestId} not found.")); + } + + $deferred = $this->pendingRequests[$pendingRequestId]; + unset($this->pendingRequests[$pendingRequestId]); + + $responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $headers = ['Content-Type' => 'application/json']; + if ($isInitializeResponse) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + $statusCode = $context['status_code'] ?? 200; + $deferred->resolve(new HttpResponse($statusCode, $headers, $responseBody . "\n")); + return resolve(null); + + default: + if ($this->getStream === null) { + $this->logger->error("GET SSE stream not found.", ['sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream not found.")); + } + + if (!$this->getStream->isWritable()) { + $this->logger->warning("GET SSE stream is not writable.", ['sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream not writable.")); + } + if ($message instanceof Response || $message instanceof Error) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; + $this->sendSseEventToStream($this->getStream, $json, $eventId); + } elseif ($message instanceof BatchResponse) { + foreach ($message->getAll() as $singleResponse) { + $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; + $this->sendSseEventToStream($this->getStream, $json, $eventId); + } + } + return resolve(null); + } + } + + private function replayEvents(string $lastEventId, ThroughStream $sseStream, string $sessionId): void + { + if (empty($lastEventId)) { + return; + } + + try { + $this->eventStore->replayEventsAfter( + $lastEventId, + function (string $replayedEventId, string $json) use ($sseStream) { + $this->logger->debug("Replaying event", ['replayedEventId' => $replayedEventId]); + $this->sendSseEventToStream($sseStream, $json, $replayedEventId); + } + ); + } catch (Throwable $e) { + $this->logger->error("Error during event replay.", ['sessionId' => $sessionId, 'exception' => $e]); + } + } + + private function sendSseEventToStream(ThroughStream $stream, string $data, ?string $eventId = null): bool + { + if (! $stream->isWritable()) { + return false; + } + + $frame = "event: message\n"; + if ($eventId !== null) { + $frame .= "id: {$eventId}\n"; + } + + $lines = explode("\n", $data); + foreach ($lines as $line) { + $frame .= "data: {$line}\n"; + } + $frame .= "\n"; + + return $stream->write($frame); + } + + public function close(): void + { + if ($this->closing) { + return; + } + + $this->closing = true; + $this->listening = false; + $this->logger->info('Closing transport...'); + + if ($this->socket) { + $this->socket->close(); + $this->socket = null; + } + + foreach ($this->activeSseStreams as $streamId => $streamInfo) { + if ($streamInfo['stream']->isWritable()) { + $streamInfo['stream']->end(); + } + } + + if ($this->getStream !== null) { + $this->getStream->end(); + $this->getStream = null; + } + + foreach ($this->pendingRequests as $pendingRequestId => $deferred) { + $deferred->reject(new TransportException('Transport is closing.')); + } + + $this->activeSseStreams = []; + $this->pendingRequests = []; + + $this->emit('close', ['Transport closed.']); + $this->removeAllListeners(); + } +} diff --git a/src/Support/Discoverer.php b/src/Utils/Discoverer.php similarity index 70% rename from src/Support/Discoverer.php rename to src/Utils/Discoverer.php index 0f96a62..3913bad 100644 --- a/src/Support/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -2,16 +2,18 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; - +namespace PhpMcp\Server\Utils; + +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\PromptArgument; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; +use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\McpTool; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Registry; use Psr\Log\LoggerInterface; @@ -25,8 +27,6 @@ class Discoverer { - private AttributeFinder $attributeFinder; - private DocBlockParser $docBlockParser; private SchemaGenerator $schemaGenerator; @@ -36,9 +36,7 @@ public function __construct( private LoggerInterface $logger, ?DocBlockParser $docBlockParser = null, ?SchemaGenerator $schemaGenerator = null, - ?AttributeFinder $attributeFinder = null ) { - $this->attributeFinder = $attributeFinder ?? new AttributeFinder(); $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->logger); $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); } @@ -92,7 +90,7 @@ public function discover(string $basePath, array $directories, array $excludeDir } $duration = microtime(true) - $startTime; - $this->logger->info('MCP: Attribute discovery finished.', [ + $this->logger->info('Attribute discovery finished.', [ 'duration_sec' => round($duration, 3), 'tools' => $discoveredCount['tools'], 'resources' => $discoveredCount['resources'], @@ -133,10 +131,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void if ($invokeMethod->isPublic() && ! $invokeMethod->isStatic()) { $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; foreach ($attributeTypes as $attributeType) { - $classAttribute = $this->attributeFinder->getFirstClassAttribute( - $reflectionClass, - $attributeType - ); + $classAttribute = $reflectionClass->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($classAttribute) { $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); $processedViaClassAttribute = true; @@ -156,10 +151,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void } $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; foreach ($attributeTypes as $attributeType) { - $methodAttribute = $this->attributeFinder->getFirstMethodAttribute( - $method, - $attributeType - ); + $methodAttribute = $method->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($methodAttribute) { $this->processMethod($method, $discoveredCount, $methodAttribute); break; @@ -185,11 +177,12 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void * * @param ReflectionMethod $method The target method (e.g., regular method or __invoke). * @param array $discoveredCount Pass by reference to update counts. - * @param ReflectionAttribute $attribute The ReflectionAttribute instance found (on method or class). + * @param ReflectionAttribute $attribute The ReflectionAttribute instance found (on method or class). */ private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void { $className = $method->getDeclaringClass()->getName(); + $classShortName = $method->getDeclaringClass()->getShortName(); $methodName = $method->getName(); $attributeClassName = $attribute->getName(); @@ -198,60 +191,56 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount switch ($attributeClassName) { case McpTool::class: - $definition = ToolDefinition::fromReflection( - $method, - $instance->name ?? null, - $instance->description ?? null, - $this->docBlockParser, - $this->schemaGenerator - ); - $this->registry->registerTool($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $inputSchema = $this->schemaGenerator->generate($method); + $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); + $this->registry->registerTool($tool, $className, $methodName); $discoveredCount['tools']++; break; case McpResource::class: - if (! isset($instance->uri)) { - throw new McpServerException("McpResource attribute on {$className}::{$methodName} requires a 'uri'."); - } - $definition = ResourceDefinition::fromReflection( - $method, - $instance->name ?? null, - $instance->description ?? null, - $instance->uri, - $instance->mimeType ?? null, - $instance->size ?? null, - $instance->annotations ?? [], - $this->docBlockParser - ); - $this->registry->registerResource($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $instance->mimeType; + $size = $instance->size; + $annotations = $instance->annotations; + $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); + $this->registry->registerResource($resource, $className, $methodName); $discoveredCount['resources']++; break; case McpPrompt::class: - $definition = PromptDefinition::fromReflection( - $method, - $instance->name ?? null, - $instance->description ?? null, - $this->docBlockParser - ); - $this->registry->registerPrompt($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $arguments = []; + $paramTags = $this->docBlockParser->getParamTags($docBlock); + foreach ($method->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { + continue; + } + $paramTag = $paramTags['$' . $param->getName()] ?? null; + $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable()); + } + $prompt = Prompt::make($name, $description, $arguments); + $completionProviders = $this->getCompletionProviders($method); + $this->registry->registerPrompt($prompt, $className, $methodName, $completionProviders); $discoveredCount['prompts']++; break; case McpResourceTemplate::class: - if (! isset($instance->uriTemplate)) { - throw new McpServerException("McpResourceTemplate attribute on {$className}::{$methodName} requires a 'uriTemplate'."); - } - $definition = ResourceTemplateDefinition::fromReflection( - $method, - $instance->name ?? null, - $instance->description ?? null, - $instance->uriTemplate, - $instance->mimeType ?? null, - $instance->annotations ?? [], - $this->docBlockParser - ); - $this->registry->registerResourceTemplate($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $instance->mimeType; + $annotations = $instance->annotations; + $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); + $completionProviders = $this->getCompletionProviders($method); + $this->registry->registerResourceTemplate($resourceTemplate, $className, $methodName, $completionProviders); $discoveredCount['resourceTemplates']++; break; } @@ -262,6 +251,25 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } } + private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array + { + $completionProviders = []; + foreach ($reflectionMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + $completionProviders[$param->getName()] = $attributeInstance->providerClass; + } + } + + return $completionProviders; + } + /** * Attempt to determine the FQCN from a PHP file path. * Uses tokenization to extract namespace and class name. @@ -363,7 +371,7 @@ private function getClassFromFile(string $filePath): ?string if (! empty($potentialClasses)) { if (! class_exists($potentialClasses[0], false)) { - $this->logger->debug('getClassFromFile returning potential non-class type', ['file' => $filePath, 'type' => $potentialClasses[0]]); + $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]); } return $potentialClasses[0]; diff --git a/src/Support/DocBlockParser.php b/src/Utils/DocBlockParser.php similarity index 89% rename from src/Support/DocBlockParser.php rename to src/Utils/DocBlockParser.php index 2fa7a1d..85bcb75 100644 --- a/src/Support/DocBlockParser.php +++ b/src/Utils/DocBlockParser.php @@ -1,12 +1,13 @@ docBlockFactory = DocBlockFactory::createInstance(); + $this->logger = $logger ?? new NullLogger(); } /** * Safely parses a DocComment string into a DocBlock object. */ - public function parseDocBlock(?string $docComment): ?DocBlock + public function parseDocBlock(string|null|false $docComment): ?DocBlock { - if (empty($docComment)) { + if ($docComment === false || $docComment === null || empty($docComment)) { return null; } try { @@ -67,7 +70,7 @@ public function getDescription(?DocBlock $docBlock): ?string $descriptionBody = trim((string) $docBlock->getDescription()); if ($summary && $descriptionBody) { - return $summary."\n\n".$descriptionBody; + return $summary . "\n\n" . $descriptionBody; } if ($summary) { return $summary; @@ -94,7 +97,7 @@ public function getParamTags(?DocBlock $docBlock): array $paramTags = []; foreach ($docBlock->getTagsByName('param') as $tag) { if ($tag instanceof Param && $tag->getVariableName()) { - $paramTags['$'.$tag->getVariableName()] = $tag; + $paramTags['$' . $tag->getVariableName()] = $tag; } } diff --git a/src/Support/HandlerResolver.php b/src/Utils/HandlerResolver.php similarity index 87% rename from src/Support/HandlerResolver.php rename to src/Utils/HandlerResolver.php index 225b458..090cbeb 100644 --- a/src/Support/HandlerResolver.php +++ b/src/Utils/HandlerResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server\Utils; use InvalidArgumentException; use ReflectionMethod; @@ -21,13 +21,12 @@ class HandlerResolver * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * * @param array|string $handler The handler to resolve. - * @return array{className: class-string, methodName: string, reflectionMethod: ReflectionMethod} - * An associative array containing 'className', 'methodName', and 'reflectionMethod'. + * @return ReflectionMethod * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, * or the method is unsuitable (e.g., static, private, abstract). */ - public static function resolve(array|string $handler): array + public static function resolve(array|string $handler): ReflectionMethod { $className = null; $methodName = null; @@ -69,11 +68,7 @@ public static function resolve(array|string $handler): array throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); } - return [ - 'className' => $className, - 'methodName' => $methodName, - 'reflectionMethod' => $reflectionMethod, - ]; + return $reflectionMethod; } catch (ReflectionException $e) { // This typically occurs if class_exists passed but ReflectionMethod still fails (rare) throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); diff --git a/src/Support/SchemaGenerator.php b/src/Utils/SchemaGenerator.php similarity index 51% rename from src/Support/SchemaGenerator.php rename to src/Utils/SchemaGenerator.php index 2fa4bb4..2153cc5 100644 --- a/src/Support/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -1,8 +1,7 @@ extractMethodLevelSchema($method); + + if ($methodSchema && isset($methodSchema['definition'])) { + return $methodSchema['definition']; + } + + $parametersInfo = $this->parseParametersInfo($method); + + return $this->buildSchemaFromParameters($parametersInfo, $methodSchema); + } + + /** + * Extracts method-level Schema attribute. + */ + private function extractMethodLevelSchema(ReflectionMethod $method): ?array + { + $schemaAttrs = $method->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); + if (empty($schemaAttrs)) { + return null; + } + + $schemaAttr = $schemaAttrs[0]->newInstance(); + return $schemaAttr->toArray(); + } + + /** + * Extracts parameter-level Schema attribute. + */ + private function extractParameterLevelSchema(ReflectionParameter $parameter): array + { + $schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); + if (empty($schemaAttrs)) { + return []; + } + + $schemaAttr = $schemaAttrs[0]->newInstance(); + return $schemaAttr->toArray(); + } + + /** + * Builds the final schema from parameter information and method-level schema. + * + * @param array + * }> $parametersInfo + * + * @param array|null $methodSchema * * @return array */ - public function fromMethodParameters(ReflectionMethod $method): array + private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array { $schema = [ 'type' => 'object', @@ -39,185 +104,287 @@ public function fromMethodParameters(ReflectionMethod $method): array 'required' => [], ]; - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->docBlockParser->parseDocBlock($docComment); - $parametersInfo = $this->parseParametersInfo($method, $docBlock); + // Apply method-level schema as base + if ($methodSchema) { + $schema = array_merge($schema, $methodSchema); + if (!isset($schema['type'])) { + $schema['type'] = 'object'; + } + if (!isset($schema['properties'])) { + $schema['properties'] = []; + } + if (!isset($schema['required'])) { + $schema['required'] = []; + } + } foreach ($parametersInfo as $paramInfo) { - $name = $paramInfo['name']; - $typeString = $paramInfo['type_string']; - $description = $paramInfo['description']; - $required = $paramInfo['required']; - $allowsNull = $paramInfo['allows_null']; - $defaultValue = $paramInfo['default_value']; - $hasDefault = $paramInfo['has_default']; - $reflectionType = $paramInfo['reflection_type_object']; - $isVariadic = $paramInfo['is_variadic']; - $schemaConstraints = $paramInfo['schema_constraints'] ?? []; - - $paramSchema = []; - - if ($isVariadic) { - $paramSchema['type'] = 'array'; - if ($description) { - $paramSchema['description'] = $description; - } - $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($typeString); - $nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => $t !== 'null'); - if (count($nonNullItemTypes) === 1) { - $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; - } - } else { - $jsonTypes = $this->mapPhpTypeToJsonSchemaType($typeString); + $paramName = $paramInfo['name']; - if ($allowsNull && strtolower($typeString) !== 'mixed' && ! in_array('null', $jsonTypes)) { - $jsonTypes[] = 'null'; - } + $methodLevelParamSchema = $schema['properties'][$paramName] ?? null; - if (count($jsonTypes) > 1) { - sort($jsonTypes); - } + $paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema); - $nonNullTypes = array_filter($jsonTypes, fn ($t) => $t !== 'null'); - if (count($jsonTypes) === 1) { - $paramSchema['type'] = $jsonTypes[0]; - } elseif (count($jsonTypes) > 1) { - $paramSchema['type'] = $jsonTypes; - } else { - // If $jsonTypes is still empty (meaning original type was 'mixed'), - // DO NOTHING - omitting 'type' implies any type in JSON Schema. - } + $schema['properties'][$paramName] = $paramSchema; - if ($description) { - $paramSchema['description'] = $description; - } + if ($paramInfo['required'] && !in_array($paramName, $schema['required'])) { + $schema['required'][] = $paramName; + } elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) { + unset($schema['required'][$key]); + $schema['required'] = array_values($schema['required']); // Re-index + } + } - if ($hasDefault && ! $required) { - $paramSchema['default'] = $defaultValue; - } + // Clean up empty properties + if (empty($schema['properties'])) { + $schema['properties'] = new stdClass(); + } + if (empty($schema['required'])) { + unset($schema['required']); + } - // Handle enums (PHP 8.1+) - if ($reflectionType instanceof ReflectionNamedType && ! $reflectionType->isBuiltin() && function_exists('enum_exists') && enum_exists($reflectionType->getName())) { - $enumClass = $reflectionType->getName(); - if (method_exists($enumClass, 'cases')) { // Ensure it's actually an enum - $isBacked = ! empty($enumClass::cases()) && isset($enumClass::cases()[0]->value); - $enumReflection = new ReflectionEnum($enumClass); - $backingTypeReflection = $enumReflection->getBackingType(); - - if ($isBacked && $backingTypeReflection instanceof ReflectionNamedType) { - $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); - $jsonBackingType = match ($backingTypeReflection->getName()) { - 'int' => 'integer', - 'string' => 'string', - default => null, // Should not happen for valid backed enums - }; - - if ($jsonBackingType) { - // Ensure schema type matches backing type, considering nullability - if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = [$jsonBackingType, 'null']; - } else { - $paramSchema['type'] = $jsonBackingType; - } - } - } else { - // Non-backed enum - use names as enum values - $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); - // Ensure schema type is string, considering nullability - if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = ['string', 'null']; - } else { - $paramSchema['type'] = 'string'; - } - } - } - } + return $schema; + } - // TODO: Revisit format inference or add explicit @schema docblock tag for formats in a future version. - // For now, parameters typed as 'string' will not have a 'format' keyword automatically added. - // Users needing specific string format validation (date-time, email, uri, regex pattern) - // would need to perform that validation within their tool/resource handler method. - - // Handle array items type if possible - if (isset($paramSchema['type'])) { - $schemaType = is_array($paramSchema['type']) ? (in_array('array', $paramSchema['type']) ? 'array' : null) : $paramSchema['type']; - - // Special handling for object-like arrays using array{} syntax - if (preg_match('/^array\s*{/i', $typeString)) { - $objectSchema = $this->inferArrayItemsType($typeString); - if (is_array($objectSchema) && isset($objectSchema['properties'])) { - // Override the type and merge in the properties - $paramSchema = array_merge($paramSchema, $objectSchema); - // Ensure type is object - $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object'; - } - } - // Handle regular arrays - elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { - $itemsType = $this->inferArrayItemsType($typeString); - if ($itemsType !== 'any') { - if (is_string($itemsType)) { - $paramSchema['items'] = ['type' => $itemsType]; - } else { - // Handle complex array item types (for nested arrays and object types) - if (!isset($itemsType['type']) && isset($itemsType['properties'])) { - // This is an object schema from array{} syntax - $itemsType = array_merge(['type' => 'object'], $itemsType); - } - $paramSchema['items'] = $itemsType; - } - } - // Ensure the main type is array, potentially adding null - if ($allowsNull) { - $paramSchema['type'] = ['array', 'null']; - sort($paramSchema['type']); - } else { - $paramSchema['type'] = 'array'; // Just array if null not allowed - } - } - } + /** + * Builds the final schema for a single parameter by merging all three levels. + * + * @param array{ + * name: string, + * doc_block_tag: Param|null, + * reflection_param: ReflectionParameter, + * reflection_type_object: ReflectionType|null, + * type_string: string, + * description: string|null, + * required: bool, + * allows_null: bool, + * default_value: mixed|null, + * has_default: bool, + * is_variadic: bool, + * parameter_schema: array + * } $paramInfo + * @param array|null $methodLevelParamSchema + */ + private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema = null): array + { + if ($paramInfo['is_variadic']) { + return $this->buildVariadicParameterSchema($paramInfo); + } + + $inferredSchema = $this->buildInferredParameterSchema($paramInfo); + + // Method-level takes precedence over inferred schema + $mergedSchema = $inferredSchema; + if ($methodLevelParamSchema) { + $mergedSchema = $this->mergeSchemas($inferredSchema, $methodLevelParamSchema); + } + + // Parameter-level takes highest precedence + $parameterLevelSchema = $paramInfo['parameter_schema']; + if (!empty($parameterLevelSchema)) { + $mergedSchema = $this->mergeSchemas($mergedSchema, $parameterLevelSchema); + } + + return $mergedSchema; + } + + /** + * Merge two schemas where the dominant schema takes precedence over the recessive one. + * + * @param array $recessiveSchema The schema with lower precedence + * @param array $dominantSchema The schema with higher precedence + */ + private function mergeSchemas(array $recessiveSchema, array $dominantSchema): array + { + $mergedSchema = array_merge($recessiveSchema, $dominantSchema); + + return $mergedSchema; + } + + /** + * Builds parameter schema from inferred type and docblock information only. + * Returns empty array for variadic parameters (handled separately). + */ + private function buildInferredParameterSchema(array $paramInfo): array + { + $paramSchema = []; + + // Variadic parameters are handled separately + if ($paramInfo['is_variadic']) { + return []; + } + + // Infer JSON Schema types + $jsonTypes = $this->inferParameterTypes($paramInfo); + + if (count($jsonTypes) === 1) { + $paramSchema['type'] = $jsonTypes[0]; + } elseif (count($jsonTypes) > 1) { + $paramSchema['type'] = $jsonTypes; + } + + // Add description from docblock + if ($paramInfo['description']) { + $paramSchema['description'] = $paramInfo['description']; + } + + // Add default value only if parameter actually has a default + if ($paramInfo['has_default']) { + $paramSchema['default'] = $paramInfo['default_value']; + } + + // Handle enums + $paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo); + + // Handle array items + $paramSchema = $this->applyArrayConstraints($paramSchema, $paramInfo); + + return $paramSchema; + } + + /** + * Builds schema for variadic parameters. + */ + private function buildVariadicParameterSchema(array $paramInfo): array + { + $paramSchema = ['type' => 'array']; + + // Apply parameter-level Schema attributes first + if (!empty($paramInfo['parameter_schema'])) { + $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']); + // Ensure type is always array for variadic + $paramSchema['type'] = 'array'; + } + + if ($paramInfo['description']) { + $paramSchema['description'] = $paramInfo['description']; + } + + // If no items specified by Schema attribute, infer from type + if (!isset($paramSchema['items'])) { + $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); + $nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => $t !== 'null'); + + if (count($nonNullItemTypes) === 1) { + $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; } + } - // Merge constraints from Schema attribute - if (!empty($schemaConstraints)) { - // Special handling for 'type' to avoid overriding detected type - if (isset($schemaConstraints['type']) && isset($paramSchema['type'])) { - if (is_array($paramSchema['type']) && !is_array($schemaConstraints['type'])) { - if (!in_array($schemaConstraints['type'], $paramSchema['type'])) { - $paramSchema['type'][] = $schemaConstraints['type']; - sort($paramSchema['type']); - } - } elseif (is_array($schemaConstraints['type']) && !is_array($paramSchema['type'])) { - if (!in_array($paramSchema['type'], $schemaConstraints['type'])) { - $schemaConstraints['type'][] = $paramSchema['type']; - sort($schemaConstraints['type']); - $paramSchema['type'] = $schemaConstraints['type']; - } - } - // Remove 'type' to avoid overwriting in the array_merge - unset($schemaConstraints['type']); - } + return $paramSchema; + } - // Now merge the rest of the schema constraints - $paramSchema = array_merge($paramSchema, $schemaConstraints); + /** + * Infers JSON Schema types for a parameter. + */ + private function inferParameterTypes(array $paramInfo): array + { + $jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); + + if ($paramInfo['allows_null'] && strtolower($paramInfo['type_string']) !== 'mixed' && !in_array('null', $jsonTypes)) { + $jsonTypes[] = 'null'; + } + + if (count($jsonTypes) > 1) { + // Sort but ensure null comes first for consistency + $nullIndex = array_search('null', $jsonTypes); + if ($nullIndex !== false) { + unset($jsonTypes[$nullIndex]); + sort($jsonTypes); + array_unshift($jsonTypes, 'null'); + } else { + sort($jsonTypes); } + } - $schema['properties'][$name] = $paramSchema; + return $jsonTypes; + } + + /** + * Applies enum constraints to parameter schema. + */ + private function applyEnumConstraints(array $paramSchema, array $paramInfo): array + { + $reflectionType = $paramInfo['reflection_type_object']; - if ($required) { - $schema['required'][] = $name; + if (!($reflectionType instanceof ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) { + return $paramSchema; + } + + $enumClass = $reflectionType->getName(); + $enumReflection = new ReflectionEnum($enumClass); + $backingTypeReflection = $enumReflection->getBackingType(); + + if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) { + $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); + $jsonBackingType = match ($backingTypeReflection->getName()) { + 'int' => 'integer', + 'string' => 'string', + default => null, + }; + + if ($jsonBackingType) { + if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { + $paramSchema['type'] = ['null', $jsonBackingType]; + } else { + $paramSchema['type'] = $jsonBackingType; + } + } + } else { + // Non-backed enum - use names as enum values + $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); + if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { + $paramSchema['type'] = ['null', 'string']; + } else { + $paramSchema['type'] = 'string'; } } - if (empty($schema['properties'])) { - // Keep properties object even if empty, per spec - $schema['properties'] = new stdClass(); + return $paramSchema; + } + + /** + * Applies array-specific constraints to parameter schema. + */ + private function applyArrayConstraints(array $paramSchema, array $paramInfo): array + { + if (!isset($paramSchema['type'])) { + return $paramSchema; } - if (empty($schema['required'])) { - unset($schema['required']); + + $typeString = $paramInfo['type_string']; + $allowsNull = $paramInfo['allows_null']; + + // Handle object-like arrays using array{} syntax + if (preg_match('/^array\s*{/i', $typeString)) { + $objectSchema = $this->inferArrayItemsType($typeString); + if (is_array($objectSchema) && isset($objectSchema['properties'])) { + $paramSchema = array_merge($paramSchema, $objectSchema); + $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object'; + } + } + // Handle regular arrays + elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { + $itemsType = $this->inferArrayItemsType($typeString); + if ($itemsType !== 'any') { + if (is_string($itemsType)) { + $paramSchema['items'] = ['type' => $itemsType]; + } else { + if (!isset($itemsType['type']) && isset($itemsType['properties'])) { + $itemsType = array_merge(['type' => 'object'], $itemsType); + } + $paramSchema['items'] = $itemsType; + } + } + + if ($allowsNull) { + $paramSchema['type'] = ['array', 'null']; + sort($paramSchema['type']); + } else { + $paramSchema['type'] = 'array'; + } } - return $schema; + return $paramSchema; } /** @@ -235,20 +402,19 @@ public function fromMethodParameters(ReflectionMethod $method): array * default_value: mixed|null, * has_default: bool, * is_variadic: bool, - * schema_constraints: array + * parameter_schema: array * }> */ - private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlock): array + private function parseParametersInfo(ReflectionMethod $method): array { + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->docBlockParser->parseDocBlock($docComment); $paramTags = $this->docBlockParser->getParamTags($docBlock); $parametersInfo = []; - // Extract method-level schema constraints (for all parameters) - $methodSchemaConstraints = $this->extractSchemaConstraintsFromAttributes($method); - foreach ($method->getParameters() as $rp) { $paramName = $rp->getName(); - $paramTag = $paramTags['$'.$paramName] ?? null; + $paramTag = $paramTags['$' . $paramName] ?? null; $reflectionType = $rp->getType(); $typeString = $this->getParameterTypeString($rp, $paramTag); @@ -257,24 +423,22 @@ private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlo $defaultValue = $hasDefault ? $rp->getDefaultValue() : null; $isVariadic = $rp->isVariadic(); - // Extract schema constraints from parameter attributes - // Parameter attributes override method attributes - $paramSchemaConstraints = $this->extractSchemaConstraintsFromAttributes($rp); - $schemaConstraints = !empty($paramSchemaConstraints) - ? $paramSchemaConstraints - : $methodSchemaConstraints; + $parameterSchema = $this->extractParameterLevelSchema($rp); - // If the default value is a BackedEnum, use its scalar value for JSON schema - if ($hasDefault && $defaultValue instanceof \BackedEnum) { + if ($defaultValue instanceof \BackedEnum) { $defaultValue = $defaultValue->value; } + if ($defaultValue instanceof \UnitEnum) { + $defaultValue = $defaultValue->name; + } + $allowsNull = false; if ($reflectionType && $reflectionType->allowsNull()) { $allowsNull = true; } elseif ($hasDefault && $defaultValue === null) { $allowsNull = true; - } elseif (stripos($typeString, 'null') !== false || strtolower($typeString) === 'mixed') { + } elseif (str_contains($typeString, 'null') || strtolower($typeString) === 'mixed') { $allowsNull = true; } @@ -285,39 +449,18 @@ private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlo 'reflection_type_object' => $reflectionType, 'type_string' => $typeString, 'description' => $description, - 'required' => ! $rp->isOptional(), + 'required' => !$rp->isOptional(), 'allows_null' => $allowsNull, 'default_value' => $defaultValue, 'has_default' => $hasDefault, 'is_variadic' => $isVariadic, - 'schema_constraints' => $schemaConstraints, + 'parameter_schema' => $parameterSchema, ]; } return $parametersInfo; } - /** - * Extract schema constraints from attributes. - * - * @param ReflectionParameter|ReflectionMethod $reflection The reflection object to extract schema constraints from - * @return array The extracted schema constraints - */ - private function extractSchemaConstraintsFromAttributes(ReflectionParameter|ReflectionMethod $reflection): array - { - $constraints = []; - - if (method_exists($reflection, 'getAttributes')) { // PHP 8+ check - $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); - if (!empty($schemaAttrs)) { - $schemaAttr = $schemaAttrs[0]->newInstance(); - $constraints = $schemaAttr->toArray(); - } - } - - return $constraints; - } - /** * Determines the type string for a parameter, prioritizing DocBlock. */ @@ -346,12 +489,12 @@ private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTa } // Otherwise, use the DocBlock type if it was valid and non-generic - if ($docBlockType !== null && ! $isDocBlockTypeGeneric) { + if ($docBlockType !== null && !$isDocBlockTypeGeneric) { // Consider if DocBlock adds nullability missing from reflection - if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && ! str_ends_with($reflectionTypeString, '|null')) { + if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && !str_ends_with($reflectionTypeString, '|null')) { // If reflection didn't capture null, but docblock did, append |null (if not already mixed) if ($reflectionTypeString !== 'mixed') { - return $reflectionTypeString.'|null'; + return $reflectionTypeString . '|null'; } } @@ -373,7 +516,7 @@ private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTa private function getTypeStringFromReflection(?ReflectionType $type, bool $nativeAllowsNull): string { if ($type === null) { - return 'mixed'; // Or should it be null? MCP often uses 'mixed' for untyped. Let's stick to mixed for consistency. + return 'mixed'; } $types = []; @@ -385,7 +528,6 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native $types = array_filter($types, fn ($t) => strtolower($t) !== 'null'); } $typeString = implode('|', array_unique(array_filter($types))); - } elseif ($type instanceof ReflectionIntersectionType) { foreach ($type->getTypes() as $innerType) { $types[] = $this->getTypeStringFromReflection($innerType, false); @@ -394,7 +536,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native } elseif ($type instanceof ReflectionNamedType) { $typeString = $type->getName(); } else { - return 'mixed'; // Fallback for unknown ReflectionType implementations + return 'mixed'; } $typeString = match (strtolower($typeString)) { @@ -410,7 +552,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native $isNullable = true; } - if ($type instanceof ReflectionUnionType && ! $nativeAllowsNull) { + if ($type instanceof ReflectionUnionType && !$nativeAllowsNull) { foreach ($type->getTypes() as $innerType) { if ($innerType instanceof ReflectionNamedType && strtolower($innerType->getName()) === 'null') { $isNullable = true; @@ -420,7 +562,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native } if ($isNullable && $typeString !== 'mixed' && stripos($typeString, 'null') === false) { - if (! str_ends_with($typeString, '|null') && ! str_ends_with($typeString, '&null')) { + if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) { $typeString .= '|null'; } } @@ -437,8 +579,6 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native /** * Maps a PHP type string (potentially a union) to an array of JSON Schema type names. - * - * @return list JSON schema types: "string", "integer", "number", "boolean", "array", "object", "null", "any" (custom placeholder) */ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array { @@ -450,8 +590,10 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array } // PRIORITY 2: Check for array syntax first (T[] or generics) - if (str_contains($normalizedType, '[]') || - preg_match('/^(array|list|iterable|collection) ['null', 'number'], 'bool', 'boolean' => ['boolean'], '?bool', '?boolean' => ['null', 'boolean'], - 'array' => ['array'], // Catch native 'array' hint if not caught by generics/[] + 'array' => ['array'], '?array' => ['null', 'array'], - 'object', 'stdclass' => ['object'], // Catch native 'object' hint + 'object', 'stdclass' => ['object'], '?object', '?stdclass' => ['null', 'object'], 'null' => ['null'], - 'resource', 'callable' => ['object'], // Represent these complex types as object - 'mixed' => [], // Omit type for mixed - 'void', 'never' => [], // Not applicable for parameters - default => ['object'], // Fallback: Treat unknown non-namespaced words as object + 'resource', 'callable' => ['object'], + 'mixed' => [], + 'void', 'never' => [], + default => ['object'], }; } /** * Infers the 'items' schema type for an array based on DocBlock type hints. - * Returns 'any' if type cannot be determined. */ private function inferArrayItemsType(string $phpTypeString): string|array { @@ -510,8 +651,10 @@ private function inferArrayItemsType(string $phpTypeString): string|array } // Case 3: Nested array> syntax or T[][] syntax - if (preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) || - preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)) { + if ( + preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) || + preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches) + ) { $innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); // Return a schema for array with items being arrays return [ @@ -527,7 +670,6 @@ private function inferArrayItemsType(string $phpTypeString): string|array return $this->parseObjectLikeArray($matches[2]); } - // No match or unsupported syntax return 'any'; } @@ -541,7 +683,6 @@ private function parseObjectLikeArray(string $propertiesStr): array // Parse properties from the string, handling nested structures $depth = 0; - $currentProp = ''; $buffer = ''; for ($i = 0; $i < strlen($propertiesStr); $i++) { @@ -600,8 +741,10 @@ private function parsePropertyDefinition(string $propDefinition, array &$propert $properties[$propName] = $nestedSchema; } // Check for array or T[] syntax - elseif (preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) || - preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)) { + elseif ( + preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) || + preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches) + ) { $itemType = $arrayMatches[1] ?? 'any'; $properties[$propName] = [ 'type' => 'array', diff --git a/src/Support/SchemaValidator.php b/src/Utils/SchemaValidator.php similarity index 97% rename from src/Support/SchemaValidator.php rename to src/Utils/SchemaValidator.php index 6941ddd..bb7cad8 100644 --- a/src/Support/SchemaValidator.php +++ b/src/Utils/SchemaValidator.php @@ -1,6 +1,6 @@ convertDataForValidator($data); - } catch (JsonException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); @@ -75,7 +74,7 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar 'schema' => json_encode($schemaObject), ]); - return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]]; + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: ' . $e->getMessage()]]; } if ($result->isValid()) { @@ -178,7 +177,7 @@ private function formatJsonPointerPath(?array $pathComponents): string return str_replace(['~', '/'], ['~0', '~1'], $componentStr); }, $pathComponents); - return '/'.implode('/', $escapedComponents); + return '/' . implode('/', $escapedComponents); } /** @@ -216,7 +215,7 @@ private function formatValidationError(ValidationError $error): string } else { $formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */ if (is_string($v)) { - return '"'.$v.'"'; + return '"' . $v . '"'; } if (is_bool($v)) { return $v ? 'true' : 'false'; @@ -227,7 +226,7 @@ private function formatValidationError(ValidationError $error): string return (string) $v; }, $allowedValues); - $message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.'; + $message = 'Value must be one of the allowed values: ' . implode(', ', $formattedAllowed) . '.'; } break; case 'const': @@ -300,7 +299,7 @@ private function formatValidationError(ValidationError $error): string $placeholders = $args ?? []; $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) { $key = $match[1]; - $value = $placeholders[$key] ?? '{'.$key.'}'; + $value = $placeholders[$key] ?? '{' . $key . '}'; return is_array($value) ? json_encode($value) : (string) $value; }, $builtInMessage); diff --git a/tests/Fixtures/Discovery/DiscoverablePromptHandler.php b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php new file mode 100644 index 0000000..54d6a00 --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php @@ -0,0 +1,38 @@ + "user", "content" => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."] + ]; + } + + #[McpPrompt] + public function simpleQuestionPrompt(string $question): array + { + return [ + ["role" => "user", "content" => $question], + ["role" => "assistant", "content" => "I will try to answer that."] + ]; + } +} diff --git a/tests/Fixtures/Discovery/DiscoverableResourceHandler.php b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php new file mode 100644 index 0000000..8c8affa --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php @@ -0,0 +1,42 @@ + "dark", "fontSize" => 14]; + } + + public function someOtherMethod(): void + { + } +} diff --git a/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php new file mode 100644 index 0000000..ee7171f --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php @@ -0,0 +1,42 @@ + $productId, + "name" => "Product " . $productId, + "region" => $region, + "price" => ($region === "EU" ? "โ‚ฌ" : "$") . (hexdec(substr(md5($productId), 0, 4)) / 100) + ]; + } + + #[McpResourceTemplate(uriTemplate: "file://{path}/{filename}.{extension}")] + public function getFileContent(string $path, string $filename, string $extension): string + { + return "Content of {$path}/{$filename}.{$extension}"; + } +} diff --git a/tests/Fixtures/Discovery/DiscoverableToolHandler.php b/tests/Fixtures/Discovery/DiscoverableToolHandler.php new file mode 100644 index 0000000..c449c05 --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableToolHandler.php @@ -0,0 +1,57 @@ + $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => "Action repeated."]; + } + + // This method should NOT be discovered as a tool + public function internalHelperMethod(int $value): int + { + return $value * 2; + } + + #[McpTool(name: "private_tool_should_be_ignored")] // On private method + private function aPrivateTool(): void + { + } + + #[McpTool(name: "protected_tool_should_be_ignored")] // On protected method + protected function aProtectedTool(): void + { + } + + #[McpTool(name: "static_tool_should_be_ignored")] // On static method + public static function aStaticTool(): void + { + } +} diff --git a/tests/Fixtures/Discovery/InvocablePromptFixture.php b/tests/Fixtures/Discovery/InvocablePromptFixture.php new file mode 100644 index 0000000..443e292 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocablePromptFixture.php @@ -0,0 +1,20 @@ + 'user', 'content' => "Generate a short greeting for {$personName}."]]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableResourceFixture.php b/tests/Fixtures/Discovery/InvocableResourceFixture.php new file mode 100644 index 0000000..6c14779 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableResourceFixture.php @@ -0,0 +1,16 @@ + "OK", "load" => rand(1, 100) / 100.0]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php b/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php new file mode 100644 index 0000000..2c70f84 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php @@ -0,0 +1,20 @@ + $userId, "email" => "user{$userId}@example-invokable.com"]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableToolFixture.php b/tests/Fixtures/Discovery/InvocableToolFixture.php new file mode 100644 index 0000000..c31871d --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableToolFixture.php @@ -0,0 +1,25 @@ + str_starts_with($item, $currentValue)); + } +} diff --git a/tests/Mocks/SupportStubs/DocBlockTestStub.php b/tests/Fixtures/General/DocBlockTestFixture.php similarity index 91% rename from tests/Mocks/SupportStubs/DocBlockTestStub.php rename to tests/Fixtures/General/DocBlockTestFixture.php index 0d7e17d..5245bbc 100644 --- a/tests/Mocks/SupportStubs/DocBlockTestStub.php +++ b/tests/Fixtures/General/DocBlockTestFixture.php @@ -1,11 +1,11 @@ type = $type; + } + + public function __invoke(string $arg1, int $arg2 = 0): array + { + $this->argsReceived = func_get_args(); + return ['invoked' => $this->type, 'arg1' => $arg1, 'arg2' => $arg2]; + } +} diff --git a/tests/Fixtures/General/PromptHandlerFixture.php b/tests/Fixtures/General/PromptHandlerFixture.php new file mode 100644 index 0000000..92c0bca --- /dev/null +++ b/tests/Fixtures/General/PromptHandlerFixture.php @@ -0,0 +1,154 @@ + 'user', 'content' => "Craft a {$style} greeting for {$name}."] + ]; + } + + public function returnSinglePromptMessageObject(): PromptMessage + { + return PromptMessage::make(Role::User, TextContent::make("Single PromptMessage object.")); + } + + public function returnArrayOfPromptMessageObjects(): array + { + return [ + PromptMessage::make(Role::User, TextContent::make("First message object.")), + PromptMessage::make(Role::Assistant, ImageContent::make("img_data", "image/png")), + ]; + } + + public function returnEmptyArrayForPrompt(): array + { + return []; + } + + public function returnSimpleUserAssistantMap(): array + { + return [ + 'user' => "This is the user's turn.", + 'assistant' => "And this is the assistant's reply." + ]; + } + + public function returnUserAssistantMapWithContentObjects(): array + { + return [ + 'user' => TextContent::make("User text content object."), + 'assistant' => ImageContent::make("asst_img_data", "image/gif"), + ]; + } + + public function returnUserAssistantMapWithMixedContent(): array + { + return [ + 'user' => "Plain user string.", + 'assistant' => AudioContent::make("aud_data", "audio/mp3"), + ]; + } + + public function returnUserAssistantMapWithArrayContent(): array + { + return [ + 'user' => ['type' => 'text', 'text' => 'User array content'], + 'assistant' => ['type' => 'image', 'data' => 'asst_arr_img_data', 'mimeType' => 'image/jpeg'], + ]; + } + + public function returnListOfRawMessageArrays(): array + { + return [ + ['role' => 'user', 'content' => "First raw message string."], + ['role' => 'assistant', 'content' => TextContent::make("Second raw message with Content obj.")], + ['role' => 'user', 'content' => ['type' => 'image', 'data' => 'raw_img_data', 'mimeType' => 'image/webp']], + ['role' => 'assistant', 'content' => ['type' => 'audio', 'data' => 'raw_aud_data', 'mimeType' => 'audio/ogg']], + ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'file://doc.pdf', 'blob' => base64_encode('pdf-data'), 'mimeType' => 'application/pdf']]], + ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'config://settings.json', 'text' => '{"theme":"dark"}']]], + ]; + } + + public function returnListOfRawMessageArraysWithScalars(): array + { + return [ + ['role' => 'user', 'content' => 123], // int + ['role' => 'assistant', 'content' => true], // bool + ['role' => 'user', 'content' => null], // null + ['role' => 'assistant', 'content' => 3.14], // float + ['role' => 'user', 'content' => ['key' => 'value']], // array that becomes JSON + ]; + } + + public function returnMixedArrayOfPromptMessagesAndRaw(): array + { + return [ + PromptMessage::make(Role::User, TextContent::make("This is a PromptMessage object.")), + ['role' => 'assistant', 'content' => "This is a raw message array."], + PromptMessage::make(Role::User, ImageContent::make("pm_img", "image/bmp")), + ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Raw message with typed content.']], + ]; + } + + public function promptWithArgumentCompletion( + #[CompletionProvider(providerClass: CompletionProviderFixture::class)] + string $entityName, + string $action = "describe" + ): array { + return [ + ['role' => 'user', 'content' => "Please {$action} the entity: {$entityName}."] + ]; + } + + public function promptReturnsNonArray(): string + { + return "This is not a valid prompt return type."; + } + + public function promptReturnsArrayWithInvalidRole(): array + { + return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; + } + + public function promptReturnsInvalidRole(): array + { + return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; + } + + public function promptReturnsArrayWithInvalidContentStructure(): array + { + return [['role' => 'user', 'content' => ['text_only_no_type' => 'invalid']]]; + } + + public function promptReturnsArrayWithInvalidTypedContent(): array + { + return [['role' => 'user', 'content' => ['type' => 'image', 'source' => 'url.jpg']]]; // 'image' needs 'data' and 'mimeType' + } + + public function promptReturnsArrayWithInvalidResourceContent(): array + { + return [ + [ + 'role' => 'user', + 'content' => ['type' => 'resource', 'resource' => ['uri' => 'uri://uri']] + ] + ]; + } + + public function promptHandlerThrows(): void + { + throw new \LogicException("Prompt generation failed inside handler."); + } +} diff --git a/tests/Fixtures/General/ResourceHandlerFixture.php b/tests/Fixtures/General/ResourceHandlerFixture.php new file mode 100644 index 0000000..94486ae --- /dev/null +++ b/tests/Fixtures/General/ResourceHandlerFixture.php @@ -0,0 +1,152 @@ +dynamicContentStore['dynamic://data/item1'] = "Content for item 1"; + } + + public function returnStringText(string $uri): string + { + return "Plain string content for {$uri}"; + } + + public function returnStringJson(string $uri): string + { + return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']); + } + + public function returnStringHtml(string $uri): string + { + return "{$uri}Content"; + } + + public function returnArrayJson(string $uri): array + { + return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()]; + } + + public function returnEmptyArray(string $uri): array + { + return []; + } + + public function returnStream(string $uri) // Returns a stream resource + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, "Streamed content for {$uri}"); + rewind($stream); + return $stream; + } + + public function returnSplFileInfo(string $uri): SplFileInfo + { + self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_'); + file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}"); + return new SplFileInfo(self::$unlinkableSplFile); + } + + public function returnEmbeddedResource(string $uri): EmbeddedResource + { + return EmbeddedResource::make( + TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content') + ); + } + + public function returnTextResourceContents(string $uri): TextResourceContents + { + return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents'); + } + + public function returnBlobResourceContents(string $uri): BlobResourceContents + { + return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents')); + } + + public function returnArrayForBlobSchema(string $uri): array + { + return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array']; + } + + public function returnArrayForTextSchema(string $uri): array + { + return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text']; + } + + public function returnArrayOfResourceContents(string $uri): array + { + return [ + TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'), + BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')), + ]; + } + + public function returnArrayOfEmbeddedResources(string $uri): array + { + return [ + EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '')), + EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))), + ]; + } + + public function returnMixedArrayWithResourceTypes(string $uri): array + { + return [ + "A raw string piece", // Will be formatted + TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is + ['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON) + EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted + ]; + } + + public function handlerThrowsException(string $uri): void + { + throw new \DomainException("Cannot read resource {$uri} - handler error."); + } + + public function returnUnformattableType(string $uri) + { + return new \DateTimeImmutable(); + } + + public function resourceHandlerNeedsUri(string $uri): string + { + return "Handler received URI: " . $uri; + } + + public function resourceHandlerDoesNotNeedUri(): string + { + return "Handler did not need or receive URI parameter."; + } + + public function getTemplatedContent( + string $category, + string $itemId, + string $format, + ): array { + return [ + 'message' => "Content for item {$itemId} in category {$category}, format {$format}.", + 'category_received' => $category, + 'itemId_received' => $itemId, + 'format_received' => $format, + ]; + } + + public function getStaticText(): string + { + return self::$staticTextContent; + } +} diff --git a/tests/Fixtures/General/ToolHandlerFixture.php b/tests/Fixtures/General/ToolHandlerFixture.php new file mode 100644 index 0000000..af3a9cc --- /dev/null +++ b/tests/Fixtures/General/ToolHandlerFixture.php @@ -0,0 +1,135 @@ + 'ok', 'timestamp' => time()]; + } + + public function processBackedEnum(BackedStringEnum $status): string + { + return "Status processed: " . $status->value; + } + + public function returnString(): string + { + return "This is a string result."; + } + + public function returnInteger(): int + { + return 12345; + } + + public function returnFloat(): float + { + return 67.89; + } + + public function returnBooleanTrue(): bool + { + return true; + } + + public function returnBooleanFalse(): bool + { + return false; + } + + public function returnNull(): ?string + { + return null; + } + + public function returnArray(): array + { + return ['message' => 'Array result', 'data' => [1, 2, 3]]; + } + + public function returnStdClass(): \stdClass + { + $obj = new \stdClass(); + $obj->property = "value"; + return $obj; + } + + public function returnTextContent(): TextContent + { + return TextContent::make("Pre-formatted TextContent."); + } + + public function returnImageContent(): ImageContent + { + return ImageContent::make("base64data==", "image/png"); + } + + public function returnAudioContent(): AudioContent + { + return AudioContent::make("base64audio==", "audio/mp3"); + } + + public function returnArrayOfContent(): array + { + return [ + TextContent::make("Part 1"), + ImageContent::make("imgdata", "image/jpeg") + ]; + } + + public function returnMixedArray(): array + { + return [ + "A raw string", + TextContent::make("A TextContent object"), + 123, + true, + null, + ['nested_key' => 'nested_value', 'sub_array' => [4, 5]], + ImageContent::make("img_data_mixed", "image/gif"), + (object)['obj_prop' => 'obj_val'] + ]; + } + + public function returnEmptyArray(): array + { + return []; + } + + public function toolThatThrows(): void + { + throw new \InvalidArgumentException("Something went wrong in the tool."); + } + + public function toolUnencodableResult() + { + return fopen('php://memory', 'r'); + } +} diff --git a/tests/Fixtures/General/VariousTypesHandler.php b/tests/Fixtures/General/VariousTypesHandler.php new file mode 100644 index 0000000..999dda9 --- /dev/null +++ b/tests/Fixtures/General/VariousTypesHandler.php @@ -0,0 +1,145 @@ + $pIntArrayGeneric Array of integers (generic style) + * @param array $pAssocArray Associative array + * @param BackedIntEnum[] $pEnumArray Array of enums + * @param array{name: string, age: int} $pShapeArray Typed array shape + * @param array $pArrayOfShapes Array of shapes + */ + public function arrayTypes( + array $pStringArray, + array $pIntArrayGeneric, + array $pAssocArray, + array $pEnumArray, + array $pShapeArray, + array $pArrayOfShapes + ): void { + } + + /** + * Enum types. + * @param BackedStringEnum $pBackedStringEnum Backed string enum + * @param BackedIntEnum $pBackedIntEnum Backed int enum + * @param UnitEnum $pUnitEnum Unit enum + */ + public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void + { + } + + /** + * Variadic parameters. + * @param string ...$pVariadicStrings Variadic strings + */ + public function variadicParams(string ...$pVariadicStrings): void + { + } + + /** + * Mixed type. + * @param mixed $pMixed Mixed type + */ + public function mixedType(mixed $pMixed): void + { + } + + /** + * With #[Schema] attributes for enhanced validation. + * @param string $email With email format. + * @param int $quantity With numeric constraints. + * @param string[] $tags With array constraints. + * @param array $userProfile With object property constraints. + */ + public function withSchemaAttributes( + #[Schema(format: Format::EMAIL)] + string $email, + #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] + int $quantity, + #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] + array $tags, + #[Schema( + properties: [ + new Property(name: 'id', minimum: 1), + new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'), + ], + required: ['id', 'username'], + additionalProperties: false + )] + array $userProfile + ): void { + } +} diff --git a/tests/Fixtures/ServerScripts/HttpTestServer.php b/tests/Fixtures/ServerScripts/HttpTestServer.php new file mode 100755 index 0000000..07d66ae --- /dev/null +++ b/tests/Fixtures/ServerScripts/HttpTestServer.php @@ -0,0 +1,46 @@ +#!/usr/bin/env php +withServerInfo('HttpIntegrationTestServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_http_tool') + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://http/static", 'static_http_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_http_prompt') + ->build(); + + $transport = new HttpServerTransport($host, $port, $mcpPathPrefix); + $server->listen($transport); + + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPathPrefix}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Fixtures/ServerScripts/StdioTestServer.php b/tests/Fixtures/ServerScripts/StdioTestServer.php new file mode 100755 index 0000000..7651a92 --- /dev/null +++ b/tests/Fixtures/ServerScripts/StdioTestServer.php @@ -0,0 +1,44 @@ +#!/usr/bin/env php +info('StdioTestServer listener starting.'); + + $server = Server::make() + ->withServerInfo('StdioIntegrationTestServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_stdio_tool') + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], 'test://stdio/static', 'static_stdio_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_stdio_prompt') + ->build(); + + $transport = new StdioServerTransport(); + $server->listen($transport); + + $logger->info('StdioTestServer listener stopped.'); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[STDIO_SERVER_CRITICAL_ERROR]\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php new file mode 100755 index 0000000..b6c91a1 --- /dev/null +++ b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php @@ -0,0 +1,61 @@ +#!/usr/bin/env php +info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF')); + + $eventStore = $useEventStore ? new InMemoryEventStore() : null; + + $server = Server::make() + ->withServerInfo('StreamableHttpIntegrationServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') + ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') + ->build(); + + $transport = new StreamableHttpServerTransport( + host: $host, + port: $port, + mcpPath: $mcpPath, + enableJsonResponse: $enableJsonResponse, + eventStore: $eventStore + ); + + $server->listen($transport); + + $logger->info("StreamableHttpTestServer listener stopped on {$host}:{$port}."); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[STREAMABLE_HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPath}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Mocks/SupportStubs/AttributeTestStub.php b/tests/Fixtures/Utils/AttributeFixtures.php similarity index 90% rename from tests/Mocks/SupportStubs/AttributeTestStub.php rename to tests/Fixtures/Utils/AttributeFixtures.php index 1b15004..c22994f 100644 --- a/tests/Mocks/SupportStubs/AttributeTestStub.php +++ b/tests/Fixtures/Utils/AttributeFixtures.php @@ -1,8 +1,7 @@ generic syntax - * + * * @param array $strings Array of strings using generic syntax * @param array $integers Array of integers using generic syntax * @param array $booleans Array of booleans using generic syntax @@ -39,7 +39,7 @@ public function simpleArraySyntax( */ public function genericArraySyntax( array $strings, - array $integers, + array $integers, array $booleans, array $floats, array $objects, @@ -49,7 +49,7 @@ public function genericArraySyntax( /** * Method with nested array syntax - * + * * @param array> $nestedStringArrays Array of arrays of strings * @param array> $nestedIntArrays Array of arrays of integers * @param string[][] $doubleStringArrays Array of arrays of strings using double [] @@ -65,7 +65,7 @@ public function nestedArraySyntax( /** * Method with object-like array syntax - * + * * @param array{name: string, age: int} $person Simple object array with name and age * @param array{id: int, title: string, tags: string[]} $article Article with array of tags * @param array{user: array{id: int, name: string}, items: array} $order Order with nested user object and array of item IDs @@ -76,4 +76,4 @@ public function objectArraySyntax( array $order ): void { } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Utils/SchemaGeneratorFixture.php b/tests/Fixtures/Utils/SchemaGeneratorFixture.php new file mode 100644 index 0000000..98b43b9 --- /dev/null +++ b/tests/Fixtures/Utils/SchemaGeneratorFixture.php @@ -0,0 +1,395 @@ + 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'] + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']] + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] + ] + ])] + public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array + { + return compact('field', 'operator', 'value'); + } + + /** + * Method-level Schema defining properties. + */ + #[Schema( + description: "Creates a new user with detailed information.", + properties: [ + 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], + 'isActive' => ['type' => 'boolean', 'default' => true] + ], + required: ['username', 'email'] + )] + public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array + { + return compact('username', 'email', 'age', 'isActive'); + } + + /** + * Method-level Schema for complex array argument. + */ + #[Schema( + properties: [ + 'profiles' => [ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true] + ], + 'required' => ['id', 'data'] + ] + ] + ], + required: ['profiles'] + )] + public function methodLevelArrayArgument(array $profiles): array + { + return ['updated_count' => count($profiles)]; + } + + // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== + + /** + * Parameter-level Schema attributes only. + */ + public function parameterLevelOnly( + #[Schema(description: "Recipient ID", pattern: "^user_")] + string $recipientId, + #[Schema(maxLength: 1024)] + string $messageBody, + #[Schema(type: 'integer', enum: [1, 2, 5])] + int $priority = 1, + #[Schema( + type: 'object', + properties: [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] + ], + required: ['type'] + )] + ?array $notificationConfig = null + ): array { + return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); + } + + /** + * Parameter-level Schema with string constraints. + */ + public function parameterStringConstraints( + #[Schema(format: 'email')] + string $email, + #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] + string $password, + string $regularString + ): void { + } + + /** + * Parameter-level Schema with numeric constraints. + */ + public function parameterNumericConstraints( + #[Schema(minimum: 18, maximum: 120)] + int $age, + #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] + float $rating, + #[Schema(multipleOf: 10)] + int $count + ): void { + } + + /** + * Parameter-level Schema with array constraints. + */ + public function parameterArrayConstraints( + #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] + array $tags, + #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] + array $scores + ): void { + } + + // ===== COMBINED SCENARIOS ===== + + /** + * Method-level + Parameter-level Schema combination. + * @param string $settingKey The key of the setting + * @param mixed $newValue The new value for the setting + */ + #[Schema( + properties: [ + 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], + 'newValue' => ['description' => 'The new value for the setting (any type).'] + ], + required: ['settingKey', 'newValue'] + )] + public function methodAndParameterLevel( + string $settingKey, + #[Schema(description: "The specific new boolean value.", type: 'boolean')] + mixed $newValue + ): array { + return compact('settingKey', 'newValue'); + } + + /** + * Type hints + DocBlock + Parameter-level Schema. + * @param string $username The user's name + * @param int $priority Task priority level + */ + public function typeHintDocBlockAndParameterSchema( + #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] + string $username, + #[Schema(minimum: 1, maximum: 10)] + int $priority + ): void { + } + + // ===== ENUM SCENARIOS ===== + + /** + * Various enum parameter types. + * @param BackedStringEnum $stringEnum Backed string enum + * @param BackedIntEnum $intEnum Backed int enum + * @param UnitEnum $unitEnum Unit enum + */ + public function enumParameters( + BackedStringEnum $stringEnum, + BackedIntEnum $intEnum, + UnitEnum $unitEnum, + ?BackedStringEnum $nullableEnum = null, + BackedIntEnum $enumWithDefault = BackedIntEnum::First + ): void { + } + + // ===== ARRAY TYPE SCENARIOS ===== + + /** + * Various array type scenarios. + * @param array $genericArray Generic array + * @param string[] $stringArray Array of strings + * @param int[] $intArray Array of integers + * @param array $mixedMap Mixed array map + * @param array{name: string, age: int} $objectLikeArray Object-like array + * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array + */ + public function arrayTypeScenarios( + array $genericArray, + array $stringArray, + array $intArray, + array $mixedMap, + array $objectLikeArray, + array $nestedObjectArray + ): void { + } + + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== + + /** + * Nullable and optional parameter scenarios. + * @param string|null $nullableString Nullable string + * @param int|null $nullableInt Nullable integer + */ + public function nullableAndOptional( + ?string $nullableString, + ?int $nullableInt = null, + string $optionalString = 'default', + bool $optionalBool = true, + array $optionalArray = [] + ): void { + } + + // ===== UNION TYPE SCENARIOS ===== + + /** + * Union type parameters. + * @param string|int $stringOrInt String or integer + * @param bool|string|null $multiUnion Bool, string or null + */ + public function unionTypes( + string|int $stringOrInt, + bool|string|null $multiUnion + ): void { + } + + // ===== VARIADIC SCENARIOS ===== + + /** + * Variadic parameter scenarios. + * @param string ...$items Variadic strings + */ + public function variadicStrings(string ...$items): void + { + } + + /** + * Variadic with Schema constraints. + * @param int ...$numbers Variadic integers + */ + public function variadicWithConstraints( + #[Schema(items: ['type' => 'integer', 'minimum' => 0])] + int ...$numbers + ): void { + } + + // ===== MIXED TYPE SCENARIOS ===== + + /** + * Mixed type scenarios. + * @param mixed $anyValue Any value + * @param mixed $optionalAny Optional any value + */ + public function mixedTypes( + mixed $anyValue, + mixed $optionalAny = 'default' + ): void { + } + + // ===== COMPLEX NESTED SCENARIOS ===== + + /** + * Complex nested Schema constraints. + */ + public function complexNestedSchema( + #[Schema( + type: 'object', + properties: [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['id', 'name'] + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0] + ], + 'required' => ['product_id', 'quantity', 'price'] + ] + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true + ] + ], + required: ['customer', 'items'] + )] + array $order + ): array { + return ['order_id' => uniqid()]; + } + + // ===== TYPE PRECEDENCE SCENARIOS ===== + + /** + * Testing type precedence between PHP, DocBlock, and Schema. + * @param integer $numericString DocBlock says integer despite string type hint + * @param string $stringWithConstraints String with Schema constraints + * @param array $arrayWithItems Array with Schema item overrides + */ + public function typePrecedenceTest( + string $numericString, + #[Schema(format: 'email', minLength: 5)] + string $stringWithConstraints, + #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] + array $arrayWithItems + ): void { + } + + // ===== ERROR EDGE CASES ===== + + /** + * Method with no parameters but Schema description. + */ + #[Schema(description: "Gets server status. Takes no arguments.", properties: [])] + public function noParamsWithSchema(): array + { + return ['status' => 'OK']; + } + + /** + * Parameter with Schema but inferred type. + */ + public function parameterSchemaInferredType( + #[Schema(description: "Some parameter", minLength: 3)] + $inferredParam + ): void { + } +} diff --git a/tests/Integration/DiscoveryTest.php b/tests/Integration/DiscoveryTest.php new file mode 100644 index 0000000..ec7b54a --- /dev/null +++ b/tests/Integration/DiscoveryTest.php @@ -0,0 +1,145 @@ +registry = new Registry($logger); + + $docBlockParser = new DocBlockParser($logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + $this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator); + + $this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures'); +}); + +it('discovers all element types correctly from fixture files', function () { + $scanDir = 'Discovery'; + + $this->discoverer->discover($this->fixtureBasePath, [$scanDir]); + + // --- Assert Tools --- + $tools = $this->registry->getTools(); + expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool + + $greetUserTool = $this->registry->getTool('greet_user'); + expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class) + ->and($greetUserTool->isManual)->toBeFalse() + ->and($greetUserTool->schema->name)->toBe('greet_user') + ->and($greetUserTool->schema->description)->toBe('Greets a user by name.') + ->and($greetUserTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler::class) + ->and($greetUserTool->handlerMethod)->toBe('greet'); + expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name'); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class) + ->and($repeatActionTool->isManual)->toBeFalse() + ->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.') + ->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue(); + expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']); + + $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); + expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class) + ->and($invokableCalcTool->isManual)->toBeFalse() + ->and($invokableCalcTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture::class) + ->and($invokableCalcTool->handlerMethod)->toBe('__invoke'); + + expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull(); + expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull(); + expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull(); + + + // --- Assert Resources --- + $resources = $this->registry->getResources(); + expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture + + $appVersionRes = $this->registry->getResource('app://info/version'); + expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class) + ->and($appVersionRes->isManual)->toBeFalse() + ->and($appVersionRes->schema->name)->toBe('app_version') + ->and($appVersionRes->schema->mimeType)->toBe('text/plain'); + + $invokableStatusRes = $this->registry->getResource('invokable://config/status'); + expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class) + ->and($invokableStatusRes->isManual)->toBeFalse() + ->and($invokableStatusRes->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture::class) + ->and($invokableStatusRes->handlerMethod)->toBe('__invoke'); + + + // --- Assert Prompts --- + $prompts = $this->registry->getPrompts(); + expect($prompts)->toHaveCount(3); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture + + $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); + expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($storyPrompt->isManual)->toBeFalse() + ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords + ->and($storyPrompt->getCompletionProvider('genre'))->toBe(CompletionProviderFixture::class); + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name + expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($simplePrompt->isManual)->toBeFalse(); + + $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); + expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class) + ->and($invokableGreeter->isManual)->toBeFalse() + ->and($invokableGreeter->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture::class); + + + // --- Assert Resource Templates --- + $templates = $this->registry->getResourceTemplates(); + expect($templates)->toHaveCount(3); // product_details_template, getFileContent, InvocableResourceTemplateFixture + + $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); + expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($productTemplate->isManual)->toBeFalse() + ->and($productTemplate->schema->name)->toBe('product_details_template') + ->and($productTemplate->getCompletionProvider('region'))->toBe(CompletionProviderFixture::class); + expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']); + + $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); + expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($invokableUserTemplate->isManual)->toBeFalse() + ->and($invokableUserTemplate->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture::class); +}); + +it('does not discover elements from excluded directories', function () { + $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); + + expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull(); + + $this->registry->clear(); + + $this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']); + expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull(); +}); + +it('handles empty directories or directories with no PHP files', function () { + $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']); + expect($this->registry->getTools())->toBeEmpty(); +}); + +it('correctly infers names and descriptions from methods/classes if not set in attribute', function () { + $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name + expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); + expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt'); + expect($simplePrompt->schema->description)->toBeNull(); + + $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr + expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); + expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); +}); diff --git a/tests/Integration/HttpServerTransportTest.php b/tests/Integration/HttpServerTransportTest.php new file mode 100644 index 0000000..dab32bd --- /dev/null +++ b/tests/Integration/HttpServerTransportTest.php @@ -0,0 +1,419 @@ +loop = Loop::get(); + $this->port = findFreePort(); + + if (!is_file(HTTP_SERVER_SCRIPT_PATH)) { + $this->markTestSkipped("Server script not found: " . HTTP_SERVER_SCRIPT_PATH); + } + if (!is_executable(HTTP_SERVER_SCRIPT_PATH)) { + chmod(HTTP_SERVER_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; + $commandArgs = [ + escapeshellarg(HTTP_SERVER_HOST), + escapeshellarg((string)$this->port), + escapeshellarg(HTTP_MCP_PATH_PREFIX) + ]; + $commandScriptPath = escapeshellarg(HTTP_SERVER_SCRIPT_PATH); + $command = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $commandArgs); + + $this->process = new Process($command, getcwd() ?: null, null, []); + $this->process->start($this->loop); + + $this->processErrorOutput = ''; + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->on('data', function ($chunk) { + $this->processErrorOutput .= $chunk; + }); + } + + return await(delay(0.2, $this->loop)); +}); + +afterEach(function () { + if ($this->sseClient ?? null) { + $this->sseClient->close(); + } + + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdout instanceof ReadableStreamInterface) { + $this->process->stdout->close(); + } + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->close(); + } + + $this->process->terminate(SIGTERM); + try { + await(delay(0.02, $this->loop)); + } catch (\Throwable $e) { + } + + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + +afterAll(function () { + // Loop::stop(); +}); + +it('starts the http server, initializes, calls a tool, and closes', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + expect($this->sseClient->clientId)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-http-1', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpPestClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + $initResponse = await($this->sseClient->getNextMessageResponse('init-http-1')); + + expect($initResponse['id'])->toBe('init-http-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('HttpIntegrationTestServer'); + + // 3. Initialized Notification + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 4. Call a tool + await($this->sseClient->sendHttpRequest('tool-http-1', 'tools/call', [ + 'name' => 'greet_http_tool', + 'arguments' => ['name' => 'HTTP Integration User'] + ])); + $toolResponse = await($this->sseClient->getNextMessageResponse('tool-http-1')); + + expect($toolResponse['id'])->toBe('tool-http-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, HTTP Integration User!'); + expect($toolResponse['result']['isError'])->toBeFalse(); + + // 5. Close + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can handle invalid JSON from client', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + expect($this->sseClient->endpointUrl)->toBeString(); + + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-1", "method": "tools/list", "params": {"broken"}'; + + // 2. Send invalid JSON + $postPromise = $this->sseClient->browser->post( + $this->sseClient->endpointUrl, + ['Content-Type' => 'application/json'], + $malformedJson + ); + + // 3. Expect error response + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + + $errorResponse = json_decode($e->getResponse()->getBody(), true); + expect($errorResponse['jsonrpc'])->toBe('2.0'); + expect($errorResponse['id'])->toBe(''); + expect($errorResponse['error']['code'])->toBe(-32700); + expect($errorResponse['error']['message'])->toContain('Invalid JSON-RPC message'); + } +})->group('integration', 'http_transport'); + +it('can handle request for non-existent method after initialization', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-http-nonexist', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'Test'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-http-nonexist')); + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 3. Send request for non-existent method + await($this->sseClient->sendHttpRequest('err-meth-http-1', 'non/existentHttpTool', [])); + $errorResponse = await($this->sseClient->getNextMessageResponse('err-meth-http-1')); + + // 4. Expect error response + expect($errorResponse['id'])->toBe('err-meth-http-1'); + expect($errorResponse['error']['code'])->toBe(-32601); + expect($errorResponse['error']['message'])->toContain("Method 'non/existentHttpTool' not found"); +})->group('integration', 'http_transport'); + +it('can handle batch requests correctly over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-batch-http', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpBatchClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-batch-http')); + + // 3. Initialized notification + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 4. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 2']]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + await($this->sseClient->sendHttpBatchRequest($batchRequests)); + + // 5. Read Batch Response + $batchResponseArray = await($this->sseClient->getNextBatchMessageResponse(3)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); + $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); + $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can handle tool list request over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-tools', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-tools')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('tool-list-http-1', 'tools/list', [])); + $toolListResponse = await($this->sseClient->getNextMessageResponse('tool-list-http-1')); + + expect($toolListResponse['id'])->toBe('tool-list-http-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(1); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_http_tool'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can read a registered resource over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-res')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('res-read-http-1', 'resources/read', ['uri' => 'test://http/static'])); + $resourceResponse = await($this->sseClient->getNextMessageResponse('res-read-http-1')); + + expect($resourceResponse['id'])->toBe('res-read-http-1'); + expect($resourceResponse)->not->toHaveKey('error'); + expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); + expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://http/static'); + expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); + expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can get a registered prompt over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-prompt')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('prompt-get-http-1', 'prompts/get', [ + 'name' => 'simple_http_prompt', + 'arguments' => ['name' => 'HttpPromptUser', 'style' => 'polite'] + ])); + $promptResponse = await($this->sseClient->getNextMessageResponse('prompt-get-http-1')); + + expect($promptResponse['id'])->toBe('prompt-get-http-1'); + expect($promptResponse)->not->toHaveKey('error'); + expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); + expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); + expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a polite greeting for HttpPromptUser.'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('rejects subsequent requests if client does not send initialized notification', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + // 1. Send Initialize + await($this->sseClient->sendHttpRequest('init-http-no-ack', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpForgetfulClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-http-no-ack')); + // Client "forgets" to send notifications/initialized back + + await(delay(0.1, $this->loop)); + + // 2. Attempt to Call a tool + await($this->sseClient->sendHttpRequest('tool-call-http-no-ack', 'tools/call', [ + 'name' => 'greet_http_tool', + 'arguments' => ['name' => 'NoAckHttpUser'] + ])); + $toolResponse = await($this->sseClient->getNextMessageResponse('tool-call-http-no-ack')); + + expect($toolResponse['id'])->toBe('tool-call-http-no-ack'); + expect($toolResponse['error']['code'])->toBe(-32600); // Invalid Request + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('returns 404 for POST to /message without valid clientId in query', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + $validEndpointUrl = $this->sseClient->endpointUrl; + $this->sseClient->close(); + + $malformedEndpoint = (string) (new Uri($validEndpointUrl))->withQuery(''); + + $payload = ['jsonrpc' => '2.0', 'id' => 'post-no-clientid', 'method' => 'ping', 'params' => []]; + $postPromise = $this->sseClient->browser->post( + $malformedEndpoint, + ['Content-Type' => 'application/json'], + json_encode($payload) + ); + + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $errorData = json_decode($bodyContent, true); + expect($errorData['error']['message'])->toContain('Missing or invalid clientId'); + } +})->group('integration', 'http_transport'); + +it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + $originalEndpointUrl = $this->sseClient->endpointUrl; + $this->sseClient->close(); + + await(delay(0.1, $this->loop)); + + $payload = ['jsonrpc' => '2.0', 'id' => 'post-stale-clientid', 'method' => 'ping', 'params' => []]; + $postPromise = $this->sseClient->browser->post( + $originalEndpointUrl, + ['Content-Type' => 'application/json'], + json_encode($payload) + ); + + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + $bodyContent = (string) $e->getResponse()->getBody(); + $errorData = json_decode($bodyContent, true); + expect($errorData['error']['message'])->toContain('Session ID not found or disconnected'); + } +})->group('integration', 'http_transport'); + +it('returns 404 for unknown paths', function () { + $browser = new Browser($this->loop); + $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/unknown/path"; + + $promise = $browser->get($unknownUrl); + + try { + await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + $this->fail("Request to unknown path should have failed with 404."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $body = (string) $e->getResponse()->getBody(); + expect($body)->toContain("Not Found"); + } catch (\Throwable $e) { + $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage()); + } +})->group('integration', 'http_transport'); diff --git a/tests/Integration/SchemaGenerationTest.php b/tests/Integration/SchemaGenerationTest.php new file mode 100644 index 0000000..1069c01 --- /dev/null +++ b/tests/Integration/SchemaGenerationTest.php @@ -0,0 +1,346 @@ +schemaGenerator = new SchemaGenerator($docBlockParser); +}); + +it('generates an empty properties object for a method with no parameters', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema)->toEqual([ + 'type' => 'object', + 'properties' => new stdClass() + ]); + expect($schema)->not->toHaveKey('required'); +}); + +it('infers basic types from PHP type hints when no DocBlocks or Schema attributes are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['name'])->toEqual(['type' => 'string']); + expect($schema['properties']['age'])->toEqual(['type' => 'integer']); + expect($schema['properties']['active'])->toEqual(['type' => 'boolean']); + expect($schema['properties']['tags'])->toEqual(['type' => 'array']); + expect($schema['properties']['config'])->toEqual(['type' => ['null', 'object'], 'default' => null]); + + expect($schema['required'])->toEqualCanonicalizing(['name', 'age', 'active', 'tags']); +}); + +it('infers types and descriptions from DocBlock @param tags when no PHP type hints are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['username'])->toEqual(['type' => 'string', 'description' => 'The username']); + expect($schema['properties']['count'])->toEqual(['type' => 'integer', 'description' => 'Number of items']); + expect($schema['properties']['enabled'])->toEqual(['type' => 'boolean', 'description' => 'Whether enabled']); + expect($schema['properties']['data'])->toEqual(['type' => 'array', 'description' => 'Some data']); + + expect($schema['required'])->toEqualCanonicalizing(['username', 'count', 'enabled', 'data']); +}); + +it('uses PHP type hints for type and DocBlock @param tags for descriptions when both are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['email'])->toEqual(['type' => 'string', 'description' => 'User email address']); + expect($schema['properties']['score'])->toEqual(['type' => 'integer', 'description' => 'User score']); + expect($schema['properties']['verified'])->toEqual(['type' => 'boolean', 'description' => 'Whether user is verified']); + + expect($schema['required'])->toEqualCanonicalizing(['email', 'score', 'verified']); +}); + +it('uses the complete schema definition provided by a method-level #[Schema(definition: ...)] attribute', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + // Should return the complete definition as-is + expect($schema)->toEqual([ + 'type' => 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'] + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']] + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] + ] + ]); +}); + +it('generates schema from a method-level #[Schema] attribute defining properties for each parameter', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['description'])->toBe("Creates a new user with detailed information."); + expect($schema['properties']['username'])->toEqual(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$']); + expect($schema['properties']['email'])->toEqual(['type' => 'string', 'format' => 'email']); + expect($schema['properties']['age'])->toEqual(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.']); + expect($schema['properties']['isActive'])->toEqual(['type' => 'boolean', 'default' => true]); + + expect($schema['required'])->toEqualCanonicalizing(['age', 'username', 'email']); +}); + +it('generates schema for a single array argument defined by a method-level #[Schema] attribute', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['profiles'])->toEqual([ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true] + ], + 'required' => ['id', 'data'] + ] + ]); + + expect($schema['required'])->toEqual(['profiles']); +}); + +it('generates schema from individual parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['recipientId'])->toEqual(['description' => "Recipient ID", 'pattern' => "^user_", 'type' => 'string']); + expect($schema['properties']['messageBody'])->toEqual(['maxLength' => 1024, 'type' => 'string']); + expect($schema['properties']['priority'])->toEqual(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1]); + expect($schema['properties']['notificationConfig'])->toEqual([ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] + ], + 'required' => ['type'], + 'default' => null + ]); + + expect($schema['required'])->toEqualCanonicalizing(['recipientId', 'messageBody']); +}); + +it('applies string constraints from parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['email'])->toEqual(['format' => 'email', 'type' => 'string']); + expect($schema['properties']['password'])->toEqual(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string']); + expect($schema['properties']['regularString'])->toEqual(['type' => 'string']); + + expect($schema['required'])->toEqualCanonicalizing(['email', 'password', 'regularString']); +}); + +it('applies numeric constraints from parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['age'])->toEqual(['minimum' => 18, 'maximum' => 120, 'type' => 'integer']); + expect($schema['properties']['rating'])->toEqual(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number']); + expect($schema['properties']['count'])->toEqual(['multipleOf' => 10, 'type' => 'integer']); + + expect($schema['required'])->toEqualCanonicalizing(['age', 'rating', 'count']); +}); + +it('applies array constraints (minItems, uniqueItems, items schema) from parameter-level #[Schema]', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['tags'])->toEqual(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true]); + expect($schema['properties']['scores'])->toEqual(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5]); + + expect($schema['required'])->toEqualCanonicalizing(['tags', 'scores']); +}); + +it('merges method-level and parameter-level #[Schema] attributes, with parameter-level taking precedence', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); + $schema = $this->schemaGenerator->generate($method); + + // Method level defines base properties + expect($schema['properties']['settingKey'])->toEqual(['type' => 'string', 'description' => 'The key of the setting.']); + + // Parameter level Schema overrides method level for newValue + expect($schema['properties']['newValue'])->toEqual(['description' => "The specific new boolean value.", 'type' => 'boolean']); + + expect($schema['required'])->toEqualCanonicalizing(['settingKey', 'newValue']); +}); + +it('combines PHP type hints, DocBlock descriptions, and parameter-level #[Schema] constraints', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['username'])->toEqual(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"]); + expect($schema['properties']['priority'])->toEqual(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level']); + + expect($schema['required'])->toEqualCanonicalizing(['username', 'priority']); +}); + +it('generates correct schema for backed and unit enum parameters, inferring from type hints', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['stringEnum'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]); + expect($schema['properties']['intEnum'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]); + expect($schema['properties']['unitEnum'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]); + expect($schema['properties']['nullableEnum'])->toEqual(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null]); + expect($schema['properties']['enumWithDefault'])->toEqual(['type' => 'integer', 'enum' => [1, 2], 'default' => 1]); + + expect($schema['required'])->toEqualCanonicalizing(['stringEnum', 'intEnum', 'unitEnum']); +}); + +it('correctly generates schemas for various array type declarations (generic, typed, shape)', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['genericArray'])->toEqual(['type' => 'array', 'description' => 'Generic array']); + expect($schema['properties']['stringArray'])->toEqual(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']]); + expect($schema['properties']['intArray'])->toEqual(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']]); + expect($schema['properties']['mixedMap'])->toEqual(['type' => 'array', 'description' => 'Mixed array map']); + + // Object-like arrays should be converted to object type + expect($schema['properties']['objectLikeArray'])->toHaveKey('type'); + expect($schema['properties']['objectLikeArray']['type'])->toBe('object'); + expect($schema['properties']['objectLikeArray'])->toHaveKey('properties'); + expect($schema['properties']['objectLikeArray']['properties'])->toHaveKeys(['name', 'age']); + + expect($schema['required'])->toEqualCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray']); +}); + +it('handles nullable type hints and optional parameters with default values correctly', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['nullableString'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']); + expect($schema['properties']['nullableInt'])->toEqual(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null]); + expect($schema['properties']['optionalString'])->toEqual(['type' => 'string', 'default' => 'default']); + expect($schema['properties']['optionalBool'])->toEqual(['type' => 'boolean', 'default' => true]); + expect($schema['properties']['optionalArray'])->toEqual(['type' => 'array', 'default' => []]); + + expect($schema['required'])->toEqualCanonicalizing(['nullableString']); +}); + +it('generates schema for PHP union types, sorting types alphabetically', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['stringOrInt'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or integer']); + expect($schema['properties']['multiUnion'])->toEqual(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null']); + + expect($schema['required'])->toEqualCanonicalizing(['stringOrInt', 'multiUnion']); +}); + +it('represents variadic string parameters as an array of strings', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]); + expect($schema)->not->toHaveKey('required'); + // Variadic is optional +}); + +it('applies item constraints from parameter-level #[Schema] to variadic parameters', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['numbers'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers']); + expect($schema)->not->toHaveKey('required'); +}); + +it('handles mixed type hints, omitting explicit type in schema and using defaults', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['anyValue'])->toEqual(['description' => 'Any value']); + expect($schema['properties']['optionalAny'])->toEqual(['description' => 'Optional any value', 'default' => 'default']); + + expect($schema['required'])->toEqualCanonicalizing(['anyValue']); +}); + +it('generates schema for complex nested object and array structures defined in parameter-level #[Schema]', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['order'])->toEqual([ + 'type' => 'object', + 'properties' => [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['id', 'name'] + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0] + ], + 'required' => ['product_id', 'quantity', 'price'] + ] + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true + ] + ], + 'required' => ['customer', 'items'] + ]); + + expect($schema['required'])->toEqual(['order']); +}); + +it('demonstrates type precedence: parameter #[Schema] overrides DocBlock, which overrides PHP type hint', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); + $schema = $this->schemaGenerator->generate($method); + + // DocBlock type (integer) should override PHP type (string) + expect($schema['properties']['numericString'])->toEqual(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint']); + + // Schema constraints should be applied with PHP type + expect($schema['properties']['stringWithConstraints'])->toEqual(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints']); + + // Schema should override DocBlock array item type + expect($schema['properties']['arrayWithItems'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides']); + + expect($schema['required'])->toEqualCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems']); +}); + +it('generates an empty properties object for a method with no parameters even if a method-level #[Schema] is present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['description'])->toBe("Gets server status. Takes no arguments."); + expect($schema['properties'])->toBeInstanceOf(stdClass::class); + expect($schema)->not->toHaveKey('required'); +}); + +it('infers parameter type as "any" (omits type) if only constraints are given in #[Schema] without type hint or DocBlock type', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['inferredParam'])->toEqual(['description' => "Some parameter", 'minLength' => 3]); + + expect($schema['required'])->toEqual(['inferredParam']); +}); diff --git a/tests/Integration/StdioServerTransportTest.php b/tests/Integration/StdioServerTransportTest.php new file mode 100644 index 0000000..4740ef4 --- /dev/null +++ b/tests/Integration/StdioServerTransportTest.php @@ -0,0 +1,322 @@ + '2.0', + 'id' => $requestId, + 'method' => $method, + 'params' => $params, + ]); + $process->stdin->write($request . "\n"); +} + +function sendNotificationToServer(Process $process, string $method, array $params = []): void +{ + $notification = json_encode([ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => $params, + ]); + + $process->stdin->write($notification . "\n"); +} + +function readResponseFromServer(Process $process, string $expectedRequestId, LoopInterface $loop): PromiseInterface +{ + $deferred = new Deferred(); + $buffer = ''; + + $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $process, &$dataListener) { + $buffer .= $chunk; + if (str_contains($buffer, "\n")) { + $lines = explode("\n", $buffer); + $buffer = array_pop($lines); + + foreach ($lines as $line) { + if (empty(trim($line))) { + continue; + } + try { + $response = json_decode(trim($line), true); + if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) { + $process->stdout->removeListener('data', $dataListener); + $deferred->resolve($response); + return; + } elseif (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { + // It's a notification, log it or handle if necessary for a specific test, but don't resolve + } + } catch (\JsonException $e) { + $process->stdout->removeListener('data', $dataListener); + $deferred->reject(new \RuntimeException("Failed to decode JSON response: " . $line, 0, $e)); + return; + } + } + } + }; + + $process->stdout->on('data', $dataListener); + + return timeout($deferred->promise(), PROCESS_TIMEOUT_SECONDS, $loop) + ->catch(function ($reason) use ($expectedRequestId) { + if ($reason instanceof \RuntimeException && str_contains($reason->getMessage(), 'Timed out after')) { + throw new \RuntimeException("Timeout waiting for response to request ID '{$expectedRequestId}'"); + } + throw $reason; + }) + ->finally(function () use ($process, $dataListener) { + $process->stdout->removeListener('data', $dataListener); + }); +} + +beforeEach(function () { + $this->loop = Loop::get(); + + if (!is_executable(STDIO_SERVER_SCRIPT_PATH)) { + chmod(STDIO_SERVER_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $command = escapeshellarg($phpPath) . ' ' . escapeshellarg(STDIO_SERVER_SCRIPT_PATH); + $this->process = new Process($command); + $this->process->start($this->loop); + + $this->processErrorOutput = ''; + $this->process->stderr->on('data', function ($chunk) { + $this->processErrorOutput .= $chunk; + }); +}); + +afterEach(function () { + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdin->isWritable()) { + $this->process->stdin->end(); + } + $this->process->stdout->close(); + $this->process->stdin->close(); + $this->process->stderr->close(); + $this->process->terminate(SIGTERM); + await(delay(0.05, $this->loop)); + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + +it('starts the stdio server, initializes, calls a tool, and closes', function () { + // 1. Initialize Request + sendRequestToServer($this->process, 'init-1', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'PestTestClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + $initResponse = await(readResponseFromServer($this->process, 'init-1', $this->loop)); + + expect($initResponse['id'])->toBe('init-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('StdioIntegrationTestServer'); + + // 2. Initialized Notification + sendNotificationToServer($this->process, 'notifications/initialized'); + + await(delay(0.05, $this->loop)); + + // 3. Call a tool + sendRequestToServer($this->process, 'tool-call-1', 'tools/call', [ + 'name' => 'greet_stdio_tool', + 'arguments' => ['name' => 'Integration Tester'] + ]); + $toolResponse = await(readResponseFromServer($this->process, 'tool-call-1', $this->loop)); + + expect($toolResponse['id'])->toBe('tool-call-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Integration Tester!'); + expect($toolResponse['result']['isError'])->toBeFalse(); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can handle invalid JSON request from client', function () { + $this->process->stdin->write("this is not json\n"); + + $response = await(readResponseFromServer($this->process, '', $this->loop)); + + expect($response['id'])->toBe(''); + expect($response['error']['code'])->toBe(-32700); + expect($response['error']['message'])->toContain('Invalid JSON'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('handles request for non-existent method', function () { + sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-err', $this->loop)); + + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'err-meth-1', 'non/existentMethod', []); + $response = await(readResponseFromServer($this->process, 'err-meth-1', $this->loop)); + + expect($response['id'])->toBe('err-meth-1'); + expect($response['error']['code'])->toBe(-32601); + expect($response['error']['message'])->toContain("Method 'non/existentMethod' not found"); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can handle batch requests correctly', function () { + // 1. Initialize + sendRequestToServer($this->process, 'init-batch', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'BatchClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + await(readResponseFromServer($this->process, 'init-batch', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + // 2. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 2']]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $rawBatchRequest = json_encode($batchRequests); + $this->process->stdin->write($rawBatchRequest . "\n"); + + // 3. Read Batch Response + $batchResponsePromise = new Deferred(); + $fullBuffer = ''; + $batchDataListener = function ($chunk) use (&$fullBuffer, $batchResponsePromise, &$batchDataListener) { + $fullBuffer .= $chunk; + if (str_contains($fullBuffer, "\n")) { + $line = trim($fullBuffer); + $fullBuffer = ''; + try { + $decoded = json_decode($line, true); + if (is_array($decoded)) { // Batch response is an array + $this->process->stdout->removeListener('data', $batchDataListener); + $batchResponsePromise->resolve($decoded); + } + } catch (\JsonException $e) { + $this->process->stdout->removeListener('data', $batchDataListener); + $batchResponsePromise->reject(new \RuntimeException("Batch JSON decode failed: " . $line, 0, $e)); + } + } + }; + $this->process->stdout->on('data', $batchDataListener); + + $batchResponseArray = await(timeout($batchResponsePromise->promise(), PROCESS_TIMEOUT_SECONDS, $this->loop)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error + + $response1 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-1'))[0] ?? null; + $response2 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-2'))[0] ?? null; + $response3 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-3'))[0] ?? null; + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can handle tool list request', function () { + sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'tool-list-1', 'tools/list', []); + $toolListResponse = await(readResponseFromServer($this->process, 'tool-list-1', $this->loop)); + + expect($toolListResponse['id'])->toBe('tool-list-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(1); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can read a registered resource', function () { + sendRequestToServer($this->process, 'init-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-res', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'res-read-1', 'resources/read', ['uri' => 'test://stdio/static']); + $resourceResponse = await(readResponseFromServer($this->process, 'res-read-1', $this->loop)); + + expect($resourceResponse['id'])->toBe('res-read-1'); + expect($resourceResponse)->not->toHaveKey('error'); + expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); + expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://stdio/static'); + expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); + expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can get a registered prompt', function () { + sendRequestToServer($this->process, 'init-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-prompt', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'prompt-get-1', 'prompts/get', [ + 'name' => 'simple_stdio_prompt', + 'arguments' => ['name' => 'StdioPromptUser'] + ]); + $promptResponse = await(readResponseFromServer($this->process, 'prompt-get-1', $this->loop)); + + expect($promptResponse['id'])->toBe('prompt-get-1'); + expect($promptResponse)->not->toHaveKey('error'); + expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); + expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); + expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a friendly greeting for StdioPromptUser.'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('handles client not sending initialized notification before other requests', function () { + sendRequestToServer($this->process, 'init-no-ack', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'ForgetfulClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + await(readResponseFromServer($this->process, 'init-no-ack', $this->loop)); + // Client "forgets" to send notifications/initialized + + + sendRequestToServer($this->process, 'tool-call-no-ack', 'tools/call', [ + 'name' => 'greet_stdio_tool', + 'arguments' => ['name' => 'NoAckUser'] + ]); + $toolResponse = await(readResponseFromServer($this->process, 'tool-call-no-ack', $this->loop)); + + expect($toolResponse['id'])->toBe('tool-call-no-ack'); + expect($toolResponse['error']['code'])->toBe(-32600); + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php new file mode 100644 index 0000000..90e296b --- /dev/null +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -0,0 +1,573 @@ +markTestSkipped("Server script not found: " . STREAMABLE_HTTP_SCRIPT_PATH); + } + if (!is_executable(STREAMABLE_HTTP_SCRIPT_PATH)) { + chmod(STREAMABLE_HTTP_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; + $commandScriptPath = escapeshellarg(STREAMABLE_HTTP_SCRIPT_PATH); + $this->port = findFreePort(); + + $jsonModeCommandArgs = [ + escapeshellarg(STREAMABLE_HTTP_HOST), + escapeshellarg((string)$this->port), + escapeshellarg(STREAMABLE_MCP_PATH), + escapeshellarg('true'), // enableJsonResponse = true + ]; + $this->jsonModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $jsonModeCommandArgs); + + $streamModeCommandArgs = [ + escapeshellarg(STREAMABLE_HTTP_HOST), + escapeshellarg((string)$this->port), + escapeshellarg(STREAMABLE_MCP_PATH), + escapeshellarg('false'), // enableJsonResponse = false + ]; + $this->streamModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $streamModeCommandArgs); + + $this->process = null; +}); + +afterEach(function () { + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdout instanceof ReadableStreamInterface) { + $this->process->stdout->close(); + } + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->close(); + } + + $this->process->terminate(SIGTERM); + try { + await(delay(0.02)); + } catch (\Throwable $e) { + } + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + +describe('JSON MODE', function () { + beforeEach(function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); + + await(delay(0.2)); + }); + + it('server starts, initializes via POST JSON, calls a tool, and closes', function () { + // 1. Initialize + $initResult = await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-1')); + + expect($initResult['statusCode'])->toBe(200); + expect($initResult['body']['id'])->toBe('init-json-1'); + expect($initResult['body'])->not->toHaveKey('error'); + expect($initResult['body']['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResult['body']['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); + expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); + + // 2. Initialized notification + $notifResult = await($this->jsonClient->sendNotification('notifications/initialized')); + expect($notifResult['statusCode'])->toBe(202); + + // 3. Call a tool + $toolResult = await($this->jsonClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'JSON Mode User'] + ], 'tool-json-1')); + + expect($toolResult['statusCode'])->toBe(200); + expect($toolResult['body']['id'])->toBe('tool-json-1'); + expect($toolResult['body'])->not->toHaveKey('error'); + expect($toolResult['body']['result']['content'][0]['text'])->toBe('Hello, JSON Mode User!'); + + // Server process is terminated in afterEach + })->group('integration', 'streamable_http_json'); + + + it('return HTTP 400 error response for invalid JSON in POST request', function () { + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-post-1", "method": "tools/list", "params": {"broken"}'; + + $promise = $this->jsonClient->browser->post( + $this->jsonClient->baseUrl, + ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + $malformedJson + ); + + try { + await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe(''); + expect($decodedBody['error']['code'])->toBe(-32700); + expect($decodedBody['error']['message'])->toContain('Invalid JSON'); + } + })->group('integration', 'streamable_http_json'); + + it('returns JSON-RPC error result for request for non-existent method', function () { + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-err')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Request non-existent method + $errorResult = await($this->jsonClient->sendRequest('non/existentToolViaJson', [], 'err-meth-json-1')); + + expect($errorResult['statusCode'])->toBe(200); + expect($errorResult['body']['id'])->toBe('err-meth-json-1'); + expect($errorResult['body']['error']['code'])->toBe(-32601); + expect($errorResult['body']['error']['message'])->toContain("Method 'non/existentToolViaJson' not found"); + })->group('integration', 'streamable_http_json'); + + it('can handle batch requests correctly', function () { + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-batch')); + expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $batchResponse = await($this->jsonClient->sendBatchRequest($batchRequests)); + + + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + expect($batchResponse['statusCode'])->toBe(200); + expect($batchResponse['body'])->toBeArray()->toHaveCount(3); + + $response1 = $findResponseById($batchResponse['body'], 'batch-req-1'); + $response2 = $findResponseById($batchResponse['body'], 'batch-req-2'); + $response3 = $findResponseById($batchResponse['body'], 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('30'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + })->group('integration', 'streamable_http_json'); + + it('can handle tool list request', function () { + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-tools')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $toolListResult = await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); + + expect($toolListResult['statusCode'])->toBe(200); + expect($toolListResult['body']['id'])->toBe('tool-list-json-1'); + expect($toolListResult['body']['result']['tools'])->toBeArray(); + expect(count($toolListResult['body']['result']['tools']))->toBe(2); + expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); + expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + })->group('integration', 'streamable_http_json'); + + it('can read a registered resource', function () { + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-res')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $resourceResult = await($this->jsonClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-json-1')); + + expect($resourceResult['statusCode'])->toBe(200); + expect($resourceResult['body']['id'])->toBe('res-read-json-1'); + $contents = $resourceResult['body']['result']['contents']; + expect($contents[0]['uri'])->toBe('test://streamable/static'); + expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); + })->group('integration', 'streamable_http_json'); + + it('can get a registered prompt', function () { + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-prompt')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $promptResult = await($this->jsonClient->sendRequest('prompts/get', [ + 'name' => 'simple_streamable_prompt', + 'arguments' => ['name' => 'JsonPromptUser', 'style' => 'terse'] + ], 'prompt-get-json-1')); + + expect($promptResult['statusCode'])->toBe(200); + expect($promptResult['body']['id'])->toBe('prompt-get-json-1'); + $messages = $promptResult['body']['result']['messages']; + expect($messages[0]['content']['text'])->toBe('Craft a terse greeting for JsonPromptUser.'); + })->group('integration', 'streamable_http_json'); + + it('rejects subsequent requests if client does not send initialized notification', function () { + // 1. Initialize ONLY + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-noack')); + // Client "forgets" to send notifications/initialized back + + // 2. Attempt to Call a tool + $toolResult = await($this->jsonClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'NoAckJsonUser'] + ], 'tool-json-noack')); + + expect($toolResult['statusCode'])->toBe(200); // HTTP is fine + expect($toolResult['body']['id'])->toBe('tool-json-noack'); + expect($toolResult['body']['error']['code'])->toBe(-32600); // Invalid Request + expect($toolResult['body']['error']['message'])->toContain('Client session not initialized'); + })->group('integration', 'streamable_http_json'); + + it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-sess-test')); + $this->jsonClient->sessionId = null; + + try { + await($this->jsonClient->sendRequest('tools/list', [], 'tools-list-no-session')); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe('tools-list-no-session'); + expect($decodedBody['error']['code'])->toBe(-32600); + expect($decodedBody['error']['message'])->toContain('Mcp-Session-Id header required'); + } + })->group('integration', 'streamable_http_json'); +}); + +describe('STREAM MODE', function () { + beforeEach(function () { + $this->process = new Process($this->streamModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->streamClient = new MockStreamHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); + await(delay(0.2)); + }); + afterEach(function () { + if ($this->streamClient ?? null) { + $this->streamClient->closeMainSseStream(); + } + }); + + it('server starts, initializes via POST JSON, calls a tool, and closes', function () { + // 1. Initialize Request + $initResponse = await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-1')); + + expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); + expect($initResponse['id'])->toBe('init-stream-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); + + // 2. Send Initialized Notification + $notifResult = await($this->streamClient->sendHttpNotification('notifications/initialized')); + expect($notifResult['statusCode'])->toBe(202); + + // 3. Call a tool + $toolResponse = await($this->streamClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'Stream Mode User'] + ], 'tool-stream-1')); + + expect($toolResponse['id'])->toBe('tool-stream-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Stream Mode User!'); + })->group('integration', 'streamable_http_stream'); + + it('return HTTP 400 error response for invalid JSON in POST request', function () { + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-stream-1", "method": "tools/list", "params": {"broken"}'; + + $postPromise = $this->streamClient->browser->post( + $this->streamClient->baseMcpUrl, + ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'], + $malformedJson + ); + + try { + await(timeout($postPromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + } catch (ResponseException $e) { + $httpResponse = $e->getResponse(); + $bodyContent = (string) $httpResponse->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($httpResponse->getStatusCode())->toBe(400); + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe(''); + expect($decodedBody['error']['code'])->toBe(-32700); + expect($decodedBody['error']['message'])->toContain('Invalid JSON'); + } + })->group('integration', 'streamable_http_stream'); + + it('returns JSON-RPC error result for request for non-existent method', function () { + // 1. Initialize + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-err')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + // 2. Send Request + $errorResponse = await($this->streamClient->sendRequest('non/existentToolViaStream', [], 'err-meth-stream-1')); + + expect($errorResponse['id'])->toBe('err-meth-stream-1'); + expect($errorResponse['error']['code'])->toBe(-32601); + expect($errorResponse['error']['message'])->toContain("Method 'non/existentToolViaStream' not found"); + })->group('integration', 'streamable_http_stream'); + + it('can handle batch requests correctly', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeBatchClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-batch')); + expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $batchResponseArray = await($this->streamClient->sendBatchRequest($batchRequests)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); + $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); + $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('30'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + })->group('integration', 'streamable_http_stream'); + + it('can handle tool list request', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-tools')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $toolListResponse = await($this->streamClient->sendRequest('tools/list', [], 'tool-list-stream-1')); + + expect($toolListResponse['id'])->toBe('tool-list-stream-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray(); + expect(count($toolListResponse['result']['tools']))->toBe(2); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); + expect($toolListResponse['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + })->group('integration', 'streamable_http_stream'); + + it('can read a registered resource', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-res')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $resourceResponse = await($this->streamClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-stream-1')); + + expect($resourceResponse['id'])->toBe('res-read-stream-1'); + $contents = $resourceResponse['result']['contents']; + expect($contents[0]['uri'])->toBe('test://streamable/static'); + expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); + })->group('integration', 'streamable_http_stream'); + + it('can get a registered prompt', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-prompt')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $promptResponse = await($this->streamClient->sendRequest('prompts/get', [ + 'name' => 'simple_streamable_prompt', + 'arguments' => ['name' => 'StreamPromptUser', 'style' => 'formal'] + ], 'prompt-get-stream-1')); + + expect($promptResponse['id'])->toBe('prompt-get-stream-1'); + $messages = $promptResponse['result']['messages']; + expect($messages[0]['content']['text'])->toBe('Craft a formal greeting for StreamPromptUser.'); + })->group('integration', 'streamable_http_stream'); + + it('rejects subsequent requests if client does not send initialized notification', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-noack')); + + $toolResponse = await($this->streamClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'NoAckStreamUser'] + ], 'tool-stream-noack')); + + expect($toolResponse['id'])->toBe('tool-stream-noack'); + expect($toolResponse['error']['code'])->toBe(-32600); + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + })->group('integration', 'streamable_http_stream'); + + it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-sess-test')); + $validSessionId = $this->streamClient->sessionId; + $this->streamClient->sessionId = null; + + try { + await($this->streamClient->sendRequest('tools/list', [], 'tools-list-no-session-stream')); + $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + // Body can't be a json since the header accepts only text/event-stream + } + + $this->streamClient->sessionId = $validSessionId; + })->group('integration', 'streamable_http_stream'); +}); + +it('responds to OPTIONS request with CORS headers', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + $browser = new Browser(); + $optionsUrl = $this->jsonClient->baseUrl; + + $promise = $browser->request('OPTIONS', $optionsUrl); + $response = await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + + expect($response->getStatusCode())->toBe(204); + expect($response->getHeaderLine('Access-Control-Allow-Origin'))->toBe('*'); + expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('POST'); + expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('GET'); + expect($response->getHeaderLine('Access-Control-Allow-Headers'))->toContain('Mcp-Session-Id'); +})->group('integration', 'streamable_http'); + +it('returns 404 for unknown paths', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + $browser = new Browser(); + $unknownUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . $this->port . "/completely/unknown/path"; + + $promise = $browser->get($unknownUrl); + + try { + await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + $this->fail("Request to unknown path should have failed with 404."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $decodedBody = json_decode((string)$e->getResponse()->getBody(), true); + expect($decodedBody['error']['message'])->toContain('Not found'); + } +})->group('integration', 'streamable_http'); + +it('can delete client session with DELETE request', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-delete-test')); + $sessionIdForDelete = $this->jsonClient->sessionId; + expect($sessionIdForDelete)->toBeString(); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Establish a GET SSE connection + $sseUrl = $this->jsonClient->baseUrl; + $browserForSse = (new Browser())->withTimeout(3); + $ssePromise = $browserForSse->requestStreaming('GET', $sseUrl, [ + 'Accept' => 'text/event-stream', + 'Mcp-Session-Id' => $sessionIdForDelete + ]); + $ssePsrResponse = await(timeout($ssePromise, 3)); + expect($ssePsrResponse->getStatusCode())->toBe(200); + expect($ssePsrResponse->getHeaderLine('Content-Type'))->toBe('text/event-stream'); + + $sseStream = $ssePsrResponse->getBody(); + assert($sseStream instanceof ReadableStreamInterface); + + $isSseStreamClosed = false; + $sseStream->on('close', function () use (&$isSseStreamClosed) { + $isSseStreamClosed = true; + }); + + // 3. Send DELETE request + $deleteResponse = await($this->jsonClient->sendDeleteRequest()); + expect($deleteResponse['statusCode'])->toBe(204); + + // 4. Assert that the GET SSE stream was closed + await(delay(0.1)); + expect($isSseStreamClosed)->toBeTrue("The GET SSE stream for session {$sessionIdForDelete} was not closed after DELETE request."); + + // 5. Assert that the client session was deleted + try { + await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); + $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + expect($decodedBody['error']['code'])->toBe(-32600); + expect($decodedBody['error']['message'])->toContain('Invalid or expired session'); + } +})->group('integration', 'streamable_http_json'); diff --git a/tests/Mocks/Clients/MockJsonHttpClient.php b/tests/Mocks/Clients/MockJsonHttpClient.php new file mode 100644 index 0000000..34bfbc2 --- /dev/null +++ b/tests/Mocks/Clients/MockJsonHttpClient.php @@ -0,0 +1,114 @@ +browser = (new Browser())->withTimeout($timeout); + $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; + } + + public function sendRequest(string $method, array $params = [], ?string $id = null): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + if ($id !== null) { + $payload['id'] = $id; + } + + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; + if ($this->sessionId && $method !== 'initialize') { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + $body = json_encode($payload); + + return $this->browser->post($this->baseUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($method) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + + if ($method === 'initialize' && $statusCode === 200) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + } + + if ($statusCode === 202) { + if ($bodyContent !== '') { + throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); + } + return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; + } + + try { + $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); + return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); + } + }); + } + + public function sendBatchRequest(array $batchRequestObjects): PromiseInterface + { + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + $body = json_encode($batchRequestObjects); + + return $this->browser->post($this->baseUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + if ($statusCode === 202) { + if ($bodyContent !== '') { + throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); + } + return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; + } + + try { + $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); + return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); + } + }); + } + + public function sendDeleteRequest(): PromiseInterface + { + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + return $this->browser->delete($this->baseUrl, $headers) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()]; + }); + } + + public function sendNotification(string $method, array $params = []): PromiseInterface + { + return $this->sendRequest($method, $params, null); + } + + public function connectSseForNotifications(): PromiseInterface + { + return resolve(null); + } +} diff --git a/tests/Mocks/Clients/MockSseClient.php b/tests/Mocks/Clients/MockSseClient.php new file mode 100644 index 0000000..ad54374 --- /dev/null +++ b/tests/Mocks/Clients/MockSseClient.php @@ -0,0 +1,236 @@ +browser = (new Browser())->withTimeout($timeout); + } + + public function connect(string $sseBaseUrl): PromiseInterface + { + return $this->browser->requestStreaming('GET', $sseBaseUrl) + ->then(function (ResponseInterface $response) { + if ($response->getStatusCode() !== 200) { + $body = (string) $response->getBody(); + throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}"); + } + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream"); + $this->stream = $stream; + $this->stream->on('data', [$this, 'handleSseData']); + $this->stream->on('close', function () { + $this->stream = null; + }); + return $this; + }); + } + + public function handleSseData(string $chunk): void + { + $this->buffer .= $chunk; + + while (($eventPos = strpos($this->buffer, "\n\n")) !== false) { + $eventBlock = substr($this->buffer, 0, $eventPos); + $this->buffer = substr($this->buffer, $eventPos + 2); + + $lines = explode("\n", $eventBlock); + $event = ['type' => 'message', 'data' => '', 'id' => null]; + + foreach ($lines as $line) { + if (str_starts_with($line, "event:")) { + $event['type'] = trim(substr($line, strlen("event:"))); + } elseif (str_starts_with($line, "data:")) { + $event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:"))); + } elseif (str_starts_with($line, "id:")) { + $event['id'] = trim(substr($line, strlen("id:"))); + } + } + $this->receivedSseEvents[] = $event; + + if ($event['type'] === 'endpoint' && $event['data']) { + $this->endpointUrl = $event['data']; + $query = parse_url($this->endpointUrl, PHP_URL_QUERY); + if ($query) { + parse_str($query, $params); + $this->clientId = $params['clientId'] ?? null; + } + } elseif ($event['type'] === 'message' && $event['data']) { + try { + $decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR); + $this->receivedMessages[] = $decodedJson; + } catch (\JsonException $e) { + } + } + } + } + + public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface + { + $deferred = new Deferred(); + $startTime = microtime(true); + + $checkMessages = null; + $checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) { + foreach ($this->receivedMessages as $i => $msg) { + if (isset($msg['id']) && $msg['id'] === $expectedRequestId) { + unset($this->receivedMessages[$i]); // Consume message + $this->receivedMessages = array_values($this->receivedMessages); + $deferred->resolve($msg); + return; + } + } + + if (microtime(true) - $startTime > $timeoutSecs) { + $deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'")); + return; + } + + if ($this->stream) { + Loop::addTimer(0.05, $checkMessages); + } else { + $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'")); + } + }; + + $checkMessages(); // Start checking + return $deferred->promise(); + } + + public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface + { + $deferred = new Deferred(); + $startTime = microtime(true); + + $checkMessages = null; + $checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) { + foreach ($this->receivedMessages as $i => $msg) { + if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) { + $isLikelyBatchResponse = true; + if (empty($msg) && $expectedItemCount === 0) { + } elseif (empty($msg) && $expectedItemCount > 0) { + $isLikelyBatchResponse = false; + } else { + foreach ($msg as $item) { + if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) { + $isLikelyBatchResponse = false; + break; + } + } + } + + if ($isLikelyBatchResponse) { + unset($this->receivedMessages[$i]); + $this->receivedMessages = array_values($this->receivedMessages); + $deferred->resolve($msg); + return; + } + } + } + + if (microtime(true) - $startTime > $timeoutSecs) { + $deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items.")); + return; + } + + if ($this->stream) { + Loop::addTimer(0.05, $checkMessages); + } else { + $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response.")); + } + }; + + $checkMessages(); + return $deferred->promise(); + } + + public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $payload = [ + 'jsonrpc' => '2.0', + 'id' => $requestId, + 'method' => $method, + 'params' => $params, + ]; + $body = json_encode($payload); + + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) use ($requestId) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return $response; + }); + } + + public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $body = json_encode($batchRequestObjects); + + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return $response; + }); + } + + public function sendHttpNotification(string $method, array $params = []): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $payload = [ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => $params, + ]; + $body = json_encode($payload); + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return null; + }); + } + + public function close(): void + { + if ($this->stream) { + $this->stream->close(); + $this->stream = null; + } + } +} diff --git a/tests/Mocks/Clients/MockStreamHttpClient.php b/tests/Mocks/Clients/MockStreamHttpClient.php new file mode 100644 index 0000000..1d0f47d --- /dev/null +++ b/tests/Mocks/Clients/MockStreamHttpClient.php @@ -0,0 +1,252 @@ +browser = (new Browser())->withTimeout($timeout); + $this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}"; + } + + public function connectMainSseStream(): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first.")); + } + + return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [ + 'Accept' => 'text/event-stream', + 'Mcp-Session-Id' => $this->sessionId + ]) + ->then(function (ResponseInterface $response) { + if ($response->getStatusCode() !== 200) { + $body = (string) $response->getBody(); + throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}"); + } + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + $this->mainSseGetStream = $stream; + + $this->mainSseGetStream->on('data', function ($chunk) { + $this->mainSseGetBuffer .= $chunk; + $this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications); + }); + return $this; + }); + } + + private function processBufferForNotifications(string &$buffer, array &$targetArray): void + { + while (($eventPos = strpos($buffer, "\n\n")) !== false) { + $eventBlock = substr($buffer, 0, $eventPos); + $buffer = substr($buffer, $eventPos + 2); + $lines = explode("\n", $eventBlock); + $eventData = ''; + foreach ($lines as $line) { + if (str_starts_with($line, "data:")) { + $eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:"))); + } + } + if (!empty($eventData)) { + try { + $decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR); + if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) { + $targetArray[] = $decodedJson; + } + } catch (\JsonException $e) { /* ignore non-json data lines or log */ + } + } + } + } + + + public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; + $body = json_encode($payload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($id) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}"); + } + + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, $id, "Initialize"); + }); + } + + public function sendRequest(string $method, array $params, string $id): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; + if ($this->sessionId) $headers['Mcp-Session-Id'] = $this->sessionId; + + $body = json_encode($payload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($id, $method) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + $bodyContent = (string) $response->getBody(); + throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}"); + } + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, $id, $method); + }); + } + + public function sendBatchRequest(array $batchPayload): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set. Initialize first for batch request.")); + } + + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId]; + $body = json_encode($batchPayload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}"); + } + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, null, "Batch", true); + }); + } + + private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface + { + $deferred = new Deferred(); + $buffer = ''; + $streamClosed = false; + + $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) { + if ($streamClosed) return; + $buffer .= $chunk; + + if (str_contains($buffer, "event: message\n")) { + if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) { + $jsonData = trim($matches[1]); + + try { + $decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR); + $isValid = false; + if ($expectBatchArray) { + $isValid = is_array($decoded) && !isset($decoded['jsonrpc']); + } else { + $isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId; + } + + if ($isValid) { + $deferred->resolve($decoded); + $stream->removeListener('data', $dataListener); + $stream->close(); + return; + } + } catch (\JsonException $e) { + $deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e)); + $stream->removeListener('data', $dataListener); + $stream->close(); + return; + } + } + } + }; + + $stream->on('data', $dataListener); + $stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) { + $streamClosed = true; + $deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received.")); + }); + $stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) { + $streamClosed = true; + $deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null)); + }); + + return timeout($deferred->promise(), 2, Loop::get()) + ->finally(function () use ($stream, $dataListener) { + if ($stream->isReadable()) { + $stream->removeListener('data', $dataListener); + } + }); + } + + public function sendHttpNotification(string $method, array $params = []): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set for notification. Initialize first.")); + } + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId]; + $body = json_encode($payload); + + return $this->browser->post($this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 202) { + throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody()); + } + + return ['statusCode' => $statusCode, 'body' => null]; + }); + } + + public function sendDeleteRequest(): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set for DELETE request. Initialize first.")); + } + + $headers = ['Mcp-Session-Id' => $this->sessionId]; + + return $this->browser->request('DELETE', $this->baseMcpUrl, $headers) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()]; + }); + } + + public function closeMainSseStream(): void + { + if ($this->mainSseGetStream) { + $this->mainSseGetStream->close(); + $this->mainSseGetStream = null; + } + } +} diff --git a/tests/Mocks/Clock/FixedClock.php b/tests/Mocks/Clock/FixedClock.php new file mode 100644 index 0000000..24b4271 --- /dev/null +++ b/tests/Mocks/Clock/FixedClock.php @@ -0,0 +1,78 @@ +currentTime = $initialTime; + } else { + $this->currentTime = new DateTimeImmutable($initialTime, $timezone); + } + } + + public function now(): DateTimeImmutable + { + return $this->currentTime; + } + + public function setCurrentTime(string|DateTimeImmutable $newTime, ?DateTimeZone $timezone = null): void + { + if ($newTime instanceof DateTimeImmutable) { + $this->currentTime = $newTime; + } else { + $this->currentTime = new DateTimeImmutable($newTime, $timezone); + } + } + + public function advance(DateInterval $interval): void + { + $this->currentTime = $this->currentTime->add($interval); + } + + public function rewind(DateInterval $interval): void + { + $this->currentTime = $this->currentTime->sub($interval); + } + + public function addSecond(): void + { + $this->advance(new DateInterval("PT1S")); + } + + public function addSeconds(int $seconds): void + { + $this->advance(new DateInterval("PT{$seconds}S")); + } + + public function addMinutes(int $minutes): void + { + $this->advance(new DateInterval("PT{$minutes}M")); + } + + public function addHours(int $hours): void + { + $this->advance(new DateInterval("PT{$hours}H")); + } + + public function subSeconds(int $seconds): void + { + $this->rewind(new DateInterval("PT{$seconds}S")); + } + + public function subMinutes(int $minutes): void + { + $this->rewind(new DateInterval("PT{$minutes}M")); + } +} diff --git a/tests/Mocks/DiscoveryStubs/AbstractStub.php b/tests/Mocks/DiscoveryStubs/AbstractStub.php deleted file mode 100644 index c28acb4..0000000 --- a/tests/Mocks/DiscoveryStubs/AbstractStub.php +++ /dev/null @@ -1,11 +0,0 @@ - 'user', 'content' => "Generate something about {$topic}"], - ]; - } -} diff --git a/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php b/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php deleted file mode 100644 index bbd0397..0000000 --- a/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php +++ /dev/null @@ -1,22 +0,0 @@ - $id, 'data' => 'Invoked template data']; - } -} diff --git a/tests/Mocks/DiscoveryStubs/InvokableToolStub.php b/tests/Mocks/DiscoveryStubs/InvokableToolStub.php deleted file mode 100644 index 2b1bb03..0000000 --- a/tests/Mocks/DiscoveryStubs/InvokableToolStub.php +++ /dev/null @@ -1,23 +0,0 @@ - 'user', 'content' => "Prompt for {$topic}"]]; - } - - public function templateHandler(string $id): array - { - return ['id' => $id, 'content' => 'Template data']; - } -} diff --git a/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php b/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php deleted file mode 100644 index e4b9ae3..0000000 --- a/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php +++ /dev/null @@ -1,14 +0,0 @@ - $arrayWithItems DocBlock specifies string[] but Schema overrides with number constraints - */ - public function typePrecedenceTest( - string $numericString, // PHP says string - - #[Schema(format: Format::EMAIL)] - string $stringWithFormat, // PHP + Schema - - #[Schema(items: new ArrayItems(minimum: 1, maximum: 100))] - array $arrayWithItems // Schema overrides DocBlock - ): void { - } -} \ No newline at end of file diff --git a/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php b/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php deleted file mode 100644 index 00860bf..0000000 --- a/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php +++ /dev/null @@ -1,160 +0,0 @@ - $p4 Generic array map (docblock) - * @param BackedStringEnum[] $p5 Array of enums (docblock) - * @param ?bool[] $p6 Array of nullable booleans (docblock) - */ - public function arrayTypes( - array $p1, - array $p2, - array $p3, - array $p4, - array $p5, - array $p6 - ): void { - } - - /** - * Method with various enum types (requires PHP 8.1+). - * @param BackedStringEnum $p1 Backed string enum - * @param BackedIntEnum $p2 Backed int enum - * @param UnitEnum $p3 Unit enum - * @param ?BackedStringEnum $p4 Nullable backed string enum - * @param BackedIntEnum $p5 Optional backed int enum - * @param UnitEnum|null $p6 Optional unit enum with null default - */ - public function enumTypes( - BackedStringEnum $p1, - BackedIntEnum $p2, - UnitEnum $p3, - ?BackedStringEnum $p4, - BackedIntEnum $p5 = BackedIntEnum::First, - ?UnitEnum $p6 = null - ): void { - } - - /** - * Method with variadic parameters. - * @param string ...$items Variadic strings - */ - public function variadicParam(string ...$items): void - { - } - - /** - * Method with mixed type hint. - * @param mixed $p1 Mixed type - * @param mixed $p2 Optional mixed type - */ - public function mixedType(mixed $p1, mixed $p2 = 'hello'): void - { - } - - /** - * Method using only docblocks for type/description. - * @param string $p1 Only docblock type - * @param $p2 Only docblock description - */ - public function docBlockOnly($p1, $p2): void - { - } - - /** - * Method with docblock type overriding PHP type hint. - * @param string $p1 Docblock overrides int - */ - public function docBlockOverrides(int $p1): void - { - } - - /** - * Method with parameters implying formats. - * @param string $email Email address - * @param string $url URL string - * @param string $dateTime ISO Date time string - */ - public function formatParams(string $email, string $url, string $dateTime): void - { - } - - // Intersection types might not be directly supported by JSON schema - // public function intersectionType(MyInterface&MyOtherInterface $p1): void {} -} diff --git a/tests/Pest.php b/tests/Pest.php index e9875a0..5020906 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,58 +1,94 @@ getProperty($propertyName); + $property->setAccessible(true); + return $property->getValue($object); } -function setupTempDir(): void +function delay($time, ?LoopInterface $loop = null) { - if (is_dir(TEST_DISCOVERY_DIR)) { - deleteDirectory(TEST_DISCOVERY_DIR); + if ($loop === null) { + $loop = Loop::get(); } - mkdir(TEST_DISCOVERY_DIR, 0777, true); -} -function cleanupTempDir(): void -{ - if (is_dir(TEST_DISCOVERY_DIR)) { - deleteDirectory(TEST_DISCOVERY_DIR); - } + /** @var TimerInterface $timer */ + $timer = null; + return new Promise(function ($resolve) use ($loop, $time, &$timer) { + $timer = $loop->addTimer($time, function () use ($resolve) { + $resolve(null); + }); + }, function () use (&$timer, $loop) { + $loop->cancelTimer($timer); + $timer = null; + + throw new \RuntimeException('Timer cancelled'); + }); } -/** - * Creates a test file in the temporary discovery directory by copying a stub. - * - * @param string $stubName The name of the stub file (without .php) in TEST_STUBS_DIR. - * @param string|null $targetFileName The desired name for the file in TEST_DISCOVERY_DIR (defaults to stubName.php). - * @return string The full path to the created file. - * - * @throws \Exception If the stub file does not exist. - */ -function createDiscoveryTestFile(string $stubName, ?string $targetFileName = null): string +function timeout(PromiseInterface $promise, $time, ?LoopInterface $loop = null) { - $stubPath = TEST_STUBS_DIR.'/'.$stubName.'.php'; - $targetName = $targetFileName ?? ($stubName.'.php'); - $targetPath = TEST_DISCOVERY_DIR.'/'.$targetName; - - if (! file_exists($stubPath)) { - throw new \Exception("Discovery test stub file not found: {$stubPath}"); + $canceller = null; + if (\method_exists($promise, 'cancel')) { + $canceller = function () use (&$promise) { + $promise->cancel(); + $promise = null; + }; } - if (! copy($stubPath, $targetPath)) { - throw new \Exception("Failed to copy discovery test stub '{$stubName}' to '{$targetName}'"); + if ($loop === null) { + $loop = Loop::get(); } - return $targetPath; + return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) { + $timer = null; + $promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $resolve($v); + }, function ($v) use (&$timer, $loop, $reject) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $reject($v); + }); + + if ($timer === false) { + return; + } + + // start timeout timer which will cancel the input promise + $timer = $loop->addTimer($time, function () use ($time, &$promise, $reject) { + $reject(new \RuntimeException('Timed out after ' . $time . ' seconds')); + + if (\method_exists($promise, 'cancel')) { + $promise->cancel(); + } + $promise = null; + }); + }, $canceller); +} + +function findFreePort() +{ + $server = new SocketServer('127.0.0.1:0'); + $address = $server->getAddress(); + $port = $address ? parse_url($address, PHP_URL_PORT) : null; + $server->close(); + if (!$port) { + throw new \RuntimeException("Could not find a free port for testing."); + } + return (int)$port; } diff --git a/tests/Unit/Attributes/McpResourceTemplateTest.php b/tests/Unit/Attributes/McpResourceTemplateTest.php index 99ba685..37e70f9 100644 --- a/tests/Unit/Attributes/McpResourceTemplateTest.php +++ b/tests/Unit/Attributes/McpResourceTemplateTest.php @@ -10,7 +10,6 @@ $name = 'test-template-name'; $description = 'This is a test template description.'; $mimeType = 'application/json'; - $annotations = ['group' => 'files']; // Act $attribute = new McpResourceTemplate( @@ -18,7 +17,6 @@ name: $name, description: $description, mimeType: $mimeType, - annotations: $annotations ); // Assert @@ -26,7 +24,6 @@ expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); - expect($attribute->annotations)->toBe($annotations); }); it('instantiates with null values for name and description', function () { @@ -36,7 +33,6 @@ name: null, description: null, mimeType: null, - annotations: [] ); // Assert @@ -44,7 +40,6 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); it('instantiates with missing optional arguments', function () { @@ -57,5 +52,4 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Unit/Attributes/McpResourceTest.php b/tests/Unit/Attributes/McpResourceTest.php index b541ddb..be025fe 100644 --- a/tests/Unit/Attributes/McpResourceTest.php +++ b/tests/Unit/Attributes/McpResourceTest.php @@ -11,7 +11,6 @@ $description = 'This is a test resource description.'; $mimeType = 'text/plain'; $size = 1024; - $annotations = ['priority' => 5]; // Act $attribute = new McpResource( @@ -20,7 +19,6 @@ description: $description, mimeType: $mimeType, size: $size, - annotations: $annotations ); // Assert @@ -29,7 +27,6 @@ expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); expect($attribute->size)->toBe($size); - expect($attribute->annotations)->toBe($annotations); }); it('instantiates with null values for name and description', function () { @@ -40,7 +37,6 @@ description: null, mimeType: null, size: null, - annotations: [] ); // Assert @@ -49,7 +45,6 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); it('instantiates with missing optional arguments', function () { @@ -63,5 +58,4 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 41b1f81..57112e0 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -1,23 +1,23 @@ name = 'TestServer'; - $this->version = '1.1.0'; + $this->serverInfo = Implementation::make('TestServer', '1.1.0'); $this->logger = Mockery::mock(LoggerInterface::class); $this->loop = Mockery::mock(LoopInterface::class); $this->cache = Mockery::mock(CacheInterface::class); $this->container = Mockery::mock(ContainerInterface::class); - $this->capabilities = Capabilities::forServer(); + $this->capabilities = ServerCapabilities::make(); }); afterEach(function () { @@ -25,49 +25,29 @@ }); it('constructs configuration object with all properties', function () { - $ttl = 1800; $paginationLimit = 100; $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, cache: $this->cache, container: $this->container, - definitionCacheTtl: $ttl, paginationLimit: $paginationLimit ); - expect($config->serverName)->toBe($this->name); - expect($config->serverVersion)->toBe($this->version); + expect($config->serverInfo)->toBe($this->serverInfo); expect($config->capabilities)->toBe($this->capabilities); expect($config->logger)->toBe($this->logger); expect($config->loop)->toBe($this->loop); expect($config->cache)->toBe($this->cache); expect($config->container)->toBe($this->container); - expect($config->definitionCacheTtl)->toBe($ttl); expect($config->paginationLimit)->toBe($paginationLimit); }); -it('constructs configuration object with default TTL', function () { - $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, - capabilities: $this->capabilities, - logger: $this->logger, - loop: $this->loop, - cache: $this->cache, - container: $this->container - ); - - expect($config->definitionCacheTtl)->toBe(3600); // Default value -}); - it('constructs configuration object with default pagination limit', function () { $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -80,8 +60,7 @@ it('constructs configuration object with null cache', function () { $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -93,15 +72,13 @@ }); it('constructs configuration object with specific capabilities', function () { - $customCaps = Capabilities::forServer( + $customCaps = ServerCapabilities::make( resourcesSubscribe: true, - loggingEnabled: true, - instructions: 'Use wisely.' + logging: true, ); $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $customCaps, logger: $this->logger, loop: $this->loop, @@ -111,6 +88,5 @@ expect($config->capabilities)->toBe($customCaps); expect($config->capabilities->resourcesSubscribe)->toBeTrue(); - expect($config->capabilities->loggingEnabled)->toBeTrue(); - expect($config->capabilities->instructions)->toBe('Use wisely.'); + expect($config->capabilities->logging)->toBeTrue(); }); diff --git a/tests/Unit/Definitions/PromptDefinitionTest.php b/tests/Unit/Definitions/PromptDefinitionTest.php deleted file mode 100644 index b2d363c..0000000 --- a/tests/Unit/Definitions/PromptDefinitionTest.php +++ /dev/null @@ -1,204 +0,0 @@ - new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: $promptName, - description: 'Desc', - arguments: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Prompt name '{$promptName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-prompt_name1', false], - ['validPrompt', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit name and description', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpPrompt(name: 'explicit-prompt-name', description: 'Explicit Description'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - // Mocks for argument processing (needed for fromReflection to run) - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->getName())->toBe('explicit-prompt-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - // Assert arguments based on reflection (templateMethod has 1 param: $id) - expect($definition->getArguments())->toBeArray()->toHaveCount(1); - expect($definition->getArguments()[0]->getName())->toBe('id'); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpPrompt(); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getArguments())->toBeArray()->toHaveCount(1); // templateMethod has 1 param -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpPrompt(); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->getName())->toBe('tool1'); - expect($definition->getDescription())->toBeNull(); - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('tool1'); - expect($definition->getArguments())->toBeArray()->toBeEmpty(); // tool1 has no params -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - // Use a real argument definition based on the stub method - $reflectionParam = new ReflectionParameter([AllElementsStub::class, 'templateMethod'], 'id'); - $arg1 = PromptArgumentDefinition::fromReflection($reflectionParam, null); // Assume null tag for simplicity - - $original = new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: 'serial-prompt', - description: 'Testing serialization', - arguments: [$arg1] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'promptName' => $original->getName(), - 'description' => $original->getDescription(), - 'arguments' => $mcpArray['arguments'], // Use the toArray version of arguments - ]; - - $reconstructed = PromptDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); // Should work with real argument object - expect($reconstructed->getArguments()[0]->getName())->toBe('id'); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - // Create real arguments based on stub - $reflectionParam = new ReflectionParameter([AllElementsStub::class, 'templateMethod'], 'id'); - $arg1 = PromptArgumentDefinition::fromReflection($reflectionParam, null); - - $definition = new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: 'mcp-prompt', - description: 'MCP Description', - arguments: [$arg1] - ); - $definitionMinimal = new PromptDefinition( - className: ToolOnlyStub::class, - methodName: 'tool1', - promptName: 'mcp-minimal', - description: null, - arguments: [] - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'name' => 'mcp-prompt', - 'description' => 'MCP Description', - 'arguments' => [ - ['name' => 'id', 'required' => true], - ], - ]); - expect($arrayMinimal)->toBe([ - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'arguments']); -}); diff --git a/tests/Unit/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php deleted file mode 100644 index 7b2275c..0000000 --- a/tests/Unit/Definitions/ResourceDefinitionTest.php +++ /dev/null @@ -1,255 +0,0 @@ - new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'file:///valid/uri', - name: $resourceName, - description: 'Desc', - mimeType: 'text/plain', - size: 100, - annotations: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource name '{$resourceName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-res_name1', false], - ['validRes', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -test('constructor validates URI pattern', function (string $uri, bool $shouldFail) { - $action = fn () => new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: $uri, - name: 'valid-name', - description: 'Desc', - mimeType: 'text/plain', - size: 100, - annotations: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource URI '{$uri}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['file:///valid/path', false], - ['config://app/settings', false], - ['custom+scheme://data?id=1', false], - ['noscheme', true], // Missing :// - ['invalid-scheme:/path', true], // Missing // - ['file:/invalid//path', true], // Missing // - ['http://', false], // Valid scheme, empty authority/path is allowed by regex - ['http://host:port/path', false], - [' ', true], // Empty/Whitespace -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit values from attribute', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'resourceMethod'); - $attribute = new McpResource( - uri: 'test://explicit/uri', - name: 'explicit-res-name', - description: 'Explicit Description', - mimeType: 'application/json', - size: 1234, - annotations: ['audience' => 'model'] - ); - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getUri())->toBe('test://explicit/uri'); - expect($definition->getName())->toBe('explicit-res-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('resourceMethod'); - expect($definition->getMimeType())->toBe('application/json'); - expect($definition->getSize())->toBe(1234); - expect($definition->getAnnotations())->toBe(['audience' => 'model']); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'resourceMethod'); - $attribute = new McpResource(uri: 'test://default/uri'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function resourceMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getUri())->toBe('test://default/uri'); - expect($definition->getName())->toBe('resourceMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('resourceMethod'); - expect($definition->getMimeType())->toBeNull(); - expect($definition->getSize())->toBeNull(); - expect($definition->getAnnotations())->toBe([]); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ResourceOnlyStub::class, 'resource2'); - $attribute = new McpResource(uri: 'test://no/desc'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getName())->toBe('resource2'); - expect($definition->getDescription())->toBeNull(); - expect($definition->getClassName())->toBe(ResourceOnlyStub::class); - expect($definition->getMethodName())->toBe('resource2'); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'serial://test/resource', - name: 'serial-res', - description: 'Testing serialization', - mimeType: 'image/jpeg', - size: 9876, - annotations: ['p' => 1] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'uri' => $original->getUri(), - 'name' => $original->getName(), - 'description' => $original->getDescription(), - 'mimeType' => $original->getMimeType(), - 'size' => $original->getSize(), - 'annotations' => $original->getAnnotations(), - ]; - $reconstructed = ResourceDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); - expect($reconstructed->getSize())->toBe($original->getSize()); - expect($reconstructed->getAnnotations())->toBe($original->getAnnotations()); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'mcp://resource', - name: 'mcp-res', - description: 'MCP Description', - mimeType: 'text/markdown', - size: 555, - annotations: ['a' => 'b'] - ); - $definitionMinimal = new ResourceDefinition( - className: ResourceOnlyStub::class, - methodName: 'resource2', - uri: 'mcp://minimal', - name: 'mcp-minimal', - description: null, - mimeType: null, - size: null, - annotations: [] - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'uri' => 'mcp://resource', - 'name' => 'mcp-res', - 'description' => 'MCP Description', - 'mimeType' => 'text/markdown', - 'size' => 555, - 'annotations' => ['a' => 'b'], - ]); - expect($arrayMinimal)->toBe([ - 'uri' => 'mcp://minimal', - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'size', 'annotations']); -}); diff --git a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php deleted file mode 100644 index d8eafaa..0000000 --- a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php +++ /dev/null @@ -1,245 +0,0 @@ - new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'user://{userId}/profile', - name: $templateName, - description: 'Desc', - mimeType: 'application/json', - annotations: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource name '{$templateName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-tmpl_name1', false], - ['validTmpl', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -test('constructor validates URI template pattern', function (string $uriTemplate, bool $shouldFail) { - $action = fn () => new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: $uriTemplate, - name: 'valid-name', - description: 'Desc', - mimeType: 'application/json', - annotations: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource URI template '{$uriTemplate}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['file:///{path}', false], - ['config://{setting}/value', false], - ['user://{user_id}/data/{data_id}', false], - ['noscheme/{id}', true], // Missing :// - ['file://no_placeholder', true], // Missing {} - ['file://{id', true], // Missing closing } - ['file://id}', true], // Missing opening { - ['http://{path}/sub', false], - ['http://host:port/{path}', false], - [' ', true], // Empty/Whitespace -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit values from attribute', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate( - uriTemplate: 'test://explicit/{id}/uri', - name: 'explicit-tmpl-name', - description: 'Explicit Description', - mimeType: 'application/xml', - annotations: ['priority' => 10] - ); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getUriTemplate())->toBe('test://explicit/{id}/uri'); - expect($definition->getName())->toBe('explicit-tmpl-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getMimeType())->toBe('application/xml'); - expect($definition->getAnnotations())->toBe(['priority' => 10]); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate(uriTemplate: 'test://default/{tmplId}'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getUriTemplate())->toBe('test://default/{tmplId}'); - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getMimeType())->toBeNull(); - expect($definition->getAnnotations())->toBe([]); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - // Use the same stub method, but mock the parser to return null for summary - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate(uriTemplate: 'test://no/desc/{id}'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); // Mock no summary - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $attribute->annotations, - $this->docBlockParser - ); - - // Assert - expect($definition->getName())->toBe('templateMethod'); // Still defaults to method name - expect($definition->getDescription())->toBeNull(); // No description available - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'serial://{type}/resource', - name: 'serial-tmpl', - description: 'Testing serialization', - mimeType: 'text/csv', - annotations: ['test' => true] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'uriTemplate' => $original->getUriTemplate(), - 'name' => $original->getName(), - 'description' => $original->getDescription(), - 'mimeType' => $original->getMimeType(), - 'annotations' => $original->getAnnotations(), - ]; - $reconstructed = ResourceTemplateDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); - expect($reconstructed->getAnnotations())->toBe($original->getAnnotations()); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'mcp://{entity}/{id}', - name: 'mcp-tmpl', - description: 'MCP Description', - mimeType: 'application/vnd.api+json', - annotations: ['version' => '1.0'] - ); - $definitionMinimal = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'mcp://minimal/{key}', - name: 'mcp-minimal', - description: null, - mimeType: null, - annotations: [] - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'uriTemplate' => 'mcp://{entity}/{id}', - 'name' => 'mcp-tmpl', - 'description' => 'MCP Description', - 'mimeType' => 'application/vnd.api+json', - 'annotations' => ['version' => '1.0'], - ]); - expect($arrayMinimal)->toBe([ - 'uriTemplate' => 'mcp://minimal/{key}', - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'annotations']); -}); diff --git a/tests/Unit/Definitions/ToolDefinitionTest.php b/tests/Unit/Definitions/ToolDefinitionTest.php deleted file mode 100644 index 0d467a7..0000000 --- a/tests/Unit/Definitions/ToolDefinitionTest.php +++ /dev/null @@ -1,215 +0,0 @@ - new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: $toolName, - description: 'Desc', - inputSchema: ['type' => 'object'] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Tool name '{$toolName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-tool_name1', false], - ['validTool', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); - $this->schemaGenerator = Mockery::mock(SchemaGenerator::class); -}); - -test('fromReflection creates definition with explicit name and description', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpTool(name: 'explicit-tool-name', description: 'Explicit Description'); - $expectedSchema = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->getName())->toBe('explicit-tool-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getInputSchema())->toBe($expectedSchema); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpTool(); - - $expectedSchema = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file to make the test robust - $stubContent = file_get_contents(__DIR__ . '/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; // First line is summary - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to actual summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getInputSchema())->toBe($expectedSchema); -}); - -test('fromReflection uses class short name as default tool name for invokable classes', function () { - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, '__invoke'); - - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn(['type' => 'object']); - - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - null, - "Some description", - $this->docBlockParser, - $this->schemaGenerator - ); - - expect($definition->getName())->toBe('ToolOnlyStub'); - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('__invoke'); - expect($definition->getInputSchema())->toBe(['type' => 'object']); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpTool(); - $expectedSchema = ['type' => 'object', 'properties' => []]; // tool1 has no params - $docComment = $reflectionMethod->getDocComment() ?: null; // Will be null/empty - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->getName())->toBe('tool1'); - expect($definition->getDescription())->toBeNull(); // No description available - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('tool1'); - expect($definition->getInputSchema())->toBe($expectedSchema); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: 'serial-tool', - description: 'Testing serialization', - inputSchema: ['type' => 'object', 'required' => ['id'], 'properties' => ['id' => ['type' => 'string']]] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'toolName' => $original->getName(), - 'description' => $original->getDescription(), - 'inputSchema' => $original->getInputSchema(), - ]; - $reconstructed = ToolDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); - expect($reconstructed->getInputSchema())->toBe($original->getInputSchema()); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: 'mcp-tool', - description: 'MCP Description', - inputSchema: ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]] - ); - $definitionNoDesc = new ToolDefinition( - className: ToolOnlyStub::class, - methodName: 'tool1', - toolName: 'mcp-tool-no-desc', - description: null, - inputSchema: ['type' => 'object'] - ); - - // Act - $array = $definition->toArray(); - $arrayNoDesc = $definitionNoDesc->toArray(); - - // Assert - expect($array)->toBe([ - 'name' => 'mcp-tool', - 'description' => 'MCP Description', - 'inputSchema' => ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]], - ]); - expect($arrayNoDesc)->toBe([ - 'name' => 'mcp-tool-no-desc', - 'inputSchema' => ['type' => 'object'], - ]); - expect($arrayNoDesc)->not->toHaveKey('description'); -}); diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php new file mode 100644 index 0000000..2c1946c --- /dev/null +++ b/tests/Unit/DispatcherTest.php @@ -0,0 +1,480 @@ +configuration = Mockery::mock(Configuration::class); + /** @var MockInterface&Registry $registry */ + $this->registry = Mockery::mock(Registry::class); + /** @var MockInterface&SubscriptionManager $subscriptionManager */ + $this->subscriptionManager = Mockery::mock(SubscriptionManager::class); + /** @var MockInterface&SchemaValidator $schemaValidator */ + $this->schemaValidator = Mockery::mock(SchemaValidator::class); + /** @var MockInterface&SessionInterface $session */ + $this->session = Mockery::mock(SessionInterface::class); + /** @var MockInterface&ContainerInterface $container */ + $this->container = Mockery::mock(ContainerInterface::class); + + $configuration = new Configuration( + serverInfo: Implementation::make('DispatcherTestServer', '1.0'), + capabilities: ServerCapabilities::make(), + paginationLimit: DISPATCHER_PAGINATION_LIMIT, + logger: new NullLogger(), + loop: Loop::get(), + cache: null, + container: $this->container + ); + + $this->dispatcher = new Dispatcher( + $configuration, + $this->registry, + $this->subscriptionManager, + $this->schemaValidator + ); +}); + +it('routes to handleInitialize for initialize request', function () { + $request = new JsonRpcRequest( + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'client', 'version' => '1.0'], + 'capabilities' => [], + ] + ); + $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn ($value) => $value->name === 'client' && $value->version === '1.0'))->once(); + + $result = $this->dispatcher->handleRequest($request, $this->session); + expect($result)->toBeInstanceOf(InitializeResult::class); + expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($result->serverInfo->name)->toBe('DispatcherTestServer'); +}); + +it('routes to handlePing for ping request', function () { + $request = new JsonRpcRequest('2.0', 'id1', 'ping', []); + $result = $this->dispatcher->handleRequest($request, $this->session); + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('throws MethodNotFound for unknown request method', function () { + $rawRequest = new JsonRpcRequest('2.0', 'id1', 'unknown/method', []); + $this->dispatcher->handleRequest($rawRequest, $this->session); +})->throws(McpServerException::class, "Method 'unknown/method' not found."); + +it('routes to handleNotificationInitialized for initialized notification', function () { + $notification = new JsonRpcNotification('2.0', 'notifications/initialized', []); + $this->session->shouldReceive('set')->with('initialized', true)->once(); + $this->dispatcher->handleNotification($notification, $this->session); +}); + +it('does nothing for unknown notification method', function () { + $rawNotification = new JsonRpcNotification('2.0', 'unknown/notification', []); + $this->session->shouldNotReceive('set'); + $this->dispatcher->handleNotification($rawNotification, $this->session); +}); + + +it('can handle initialize request', function () { + $clientInfo = Implementation::make('TestClient', '0.9.9'); + $request = InitializeRequest::make(1, Protocol::LATEST_PROTOCOL_VERSION, ClientCapabilities::make(), $clientInfo, []); + $this->session->shouldReceive('set')->with('client_info', $clientInfo)->once(); + + $result = $this->dispatcher->handleInitialize($request, $this->session); + expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($result->serverInfo->name)->toBe('DispatcherTestServer'); + expect($result->capabilities)->toBeInstanceOf(ServerCapabilities::class); +}); + +it('can handle tool list request and return paginated tools', function () { + $toolSchemas = [ + ToolSchema::make('tool1', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool2', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool3', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool4', ['type' => 'object', 'properties' => []]), + ]; + $this->registry->shouldReceive('getTools')->andReturn($toolSchemas); + + $request = ListToolsRequest::make(1); + $result = $this->dispatcher->handleToolList($request); + expect($result->tools)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect($result->tools[0]->name)->toBe('tool1'); + expect($result->nextCursor)->toBeString(); + + $nextCursor = $result->nextCursor; + $requestPage2 = ListToolsRequest::make(2, $nextCursor); + $resultPage2 = $this->dispatcher->handleToolList($requestPage2); + expect($resultPage2->tools)->toHaveCount(count($toolSchemas) - DISPATCHER_PAGINATION_LIMIT); + expect($resultPage2->tools[0]->name)->toBe('tool4'); + expect($resultPage2->nextCursor)->toBeNull(); +}); + +it('can handle tool call request and return result', function () { + $toolName = 'my-calculator'; + $args = ['a' => 10, 'b' => 5]; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn([]); // No validation errors + $registeredToolMock->shouldReceive('call')->with($this->container, $args)->andReturn([TextContent::make("Result: 15")]); + + $request = CallToolRequest::make(1, $toolName, $args); + $result = $this->dispatcher->handleToolCall($request); + + expect($result)->toBeInstanceOf(CallToolResult::class); + expect($result->content[0]->text)->toBe("Result: 15"); + expect($result->isError)->toBeFalse(); +}); + +it('can handle tool call request and throw exception if tool not found', function () { + $this->registry->shouldReceive('getTool')->with('unknown-tool')->andReturn(null); + $request = CallToolRequest::make(1, 'unknown-tool', []); + $this->dispatcher->handleToolCall($request); +})->throws(McpServerException::class, "Tool 'unknown-tool' not found."); + +it('can handle tool call request and throw exception if argument validation fails', function () { + $toolName = 'strict-tool'; + $args = ['param' => 'wrong_type']; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['param' => ['type' => 'integer']]]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $validationErrors = [['pointer' => '/param', 'keyword' => 'type', 'message' => 'Expected integer']]; + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn($validationErrors); + + $request = CallToolRequest::make(1, $toolName, $args); + try { + $this->dispatcher->handleToolCall($request); + } catch (McpServerException $e) { + expect($e->getMessage())->toContain("Invalid parameters for tool 'strict-tool'"); + expect($e->getData()['validation_errors'])->toBeArray(); + } +}); + +it('can handle tool call request and return error if tool execution throws exception', function () { + $toolName = 'failing-tool'; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]); + $registeredToolMock->shouldReceive('call')->andThrow(new \RuntimeException("Tool crashed!")); + + $request = CallToolRequest::make(1, $toolName, []); + $result = $this->dispatcher->handleToolCall($request); + + expect($result->isError)->toBeTrue(); + expect($result->content[0]->text)->toBe("Tool execution failed: Tool crashed!"); +}); + +it('can handle tool call request and return error if result formatting fails', function () { + $toolName = 'bad-result-tool'; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]); + $registeredToolMock->shouldReceive('call')->andThrow(new \JsonException("Unencodable.")); + + + $request = CallToolRequest::make(1, $toolName, []); + $result = $this->dispatcher->handleToolCall($request); + + expect($result->isError)->toBeTrue(); + expect($result->content[0]->text)->toBe("Failed to serialize tool result: Unencodable."); +}); + + +it('can handle resources list request and return paginated resources', function () { + $resourceSchemas = [ + ResourceSchema::make('res://1', 'Resource1'), + ResourceSchema::make('res://2', 'Resource2'), + ResourceSchema::make('res://3', 'Resource3'), + ResourceSchema::make('res://4', 'Resource4'), + ResourceSchema::make('res://5', 'Resource5') + ]; + $this->registry->shouldReceive('getResources')->andReturn($resourceSchemas); + + $requestP1 = ListResourcesRequest::make(1); + $resultP1 = $this->dispatcher->handleResourcesList($requestP1); + expect($resultP1->resources)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn ($r) => $r->name, $resultP1->resources))->toEqual(['Resource1', 'Resource2', 'Resource3']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListResourcesRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handleResourcesList($requestP2); + expect($resultP2->resources)->toHaveCount(2); + expect(array_map(fn ($r) => $r->name, $resultP2->resources))->toEqual(['Resource4', 'Resource5']); + expect($resultP2->nextCursor)->toBeNull(); +}); + +it('can handle resources list request and return empty if registry has no resources', function () { + $this->registry->shouldReceive('getResources')->andReturn([]); + $request = ListResourcesRequest::make(1); + $result = $this->dispatcher->handleResourcesList($request); + expect($result->resources)->toBeEmpty(); + expect($result->nextCursor)->toBeNull(); +}); + +it('can handle resource template list request and return paginated templates', function () { + $templateSchemas = [ + ResourceTemplateSchema::make('tpl://{id}/1', 'Template1'), + ResourceTemplateSchema::make('tpl://{id}/2', 'Template2'), + ResourceTemplateSchema::make('tpl://{id}/3', 'Template3'), + ResourceTemplateSchema::make('tpl://{id}/4', 'Template4'), + ]; + $this->registry->shouldReceive('getResourceTemplates')->andReturn($templateSchemas); + + // Page 1 + $requestP1 = ListResourceTemplatesRequest::make(1); + $resultP1 = $this->dispatcher->handleResourceTemplateList($requestP1); + expect($resultP1->resourceTemplates)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn ($rt) => $rt->name, $resultP1->resourceTemplates))->toEqual(['Template1', 'Template2', 'Template3']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListResourceTemplatesRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handleResourceTemplateList($requestP2); + expect($resultP2->resourceTemplates)->toHaveCount(1); + expect(array_map(fn ($rt) => $rt->name, $resultP2->resourceTemplates))->toEqual(['Template4']); + expect($resultP2->nextCursor)->toBeNull(); +}); + +it('can handle resource read request and return resource contents', function () { + $uri = 'file://data.txt'; + $resourceSchema = ResourceSchema::make($uri, 'file_resource'); + $registeredResourceMock = Mockery::mock(RegisteredResource::class, [$resourceSchema, 'MyResourceHandler', 'read', false]); + $resourceContents = [TextContent::make('File content')]; + + $this->registry->shouldReceive('getResource')->with($uri)->andReturn($registeredResourceMock); + $registeredResourceMock->shouldReceive('read')->with($this->container, $uri)->andReturn($resourceContents); + + $request = ReadResourceRequest::make(1, $uri); + $result = $this->dispatcher->handleResourceRead($request); + + expect($result)->toBeInstanceOf(ReadResourceResult::class); + expect($result->contents)->toEqual($resourceContents); +}); + +it('can handle resource read request and throw exception if resource not found', function () { + $this->registry->shouldReceive('getResource')->with('unknown://uri')->andReturn(null); + $request = ReadResourceRequest::make(1, 'unknown://uri'); + $this->dispatcher->handleResourceRead($request); +})->throws(McpServerException::class, "Resource URI 'unknown://uri' not found."); + +it('can handle resource subscribe request and call subscription manager', function () { + $uri = 'news://updates'; + $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID); + $this->subscriptionManager->shouldReceive('subscribe')->with(DISPATCHER_SESSION_ID, $uri)->once(); + $request = ResourceSubscribeRequest::make(1, $uri); + $result = $this->dispatcher->handleResourceSubscribe($request, $this->session); + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('can handle prompts list request and return paginated prompts', function () { + $promptSchemas = [ + PromptSchema::make('promptA', '', []), + PromptSchema::make('promptB', '', []), + PromptSchema::make('promptC', '', []), + PromptSchema::make('promptD', '', []), + PromptSchema::make('promptE', '', []), + PromptSchema::make('promptF', '', []), + ]; // 6 prompts + $this->registry->shouldReceive('getPrompts')->andReturn($promptSchemas); + + // Page 1 + $requestP1 = ListPromptsRequest::make(1); + $resultP1 = $this->dispatcher->handlePromptsList($requestP1); + expect($resultP1->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn ($p) => $p->name, $resultP1->prompts))->toEqual(['promptA', 'promptB', 'promptC']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListPromptsRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handlePromptsList($requestP2); + expect($resultP2->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); // 3 more + expect(array_map(fn ($p) => $p->name, $resultP2->prompts))->toEqual(['promptD', 'promptE', 'promptF']); + expect($resultP2->nextCursor)->toBeNull(); // End of list +}); + +it('can handle prompt get request and return prompt messages', function () { + $promptName = 'daily-summary'; + $args = ['date' => '2024-07-16']; + $promptSchema = PromptSchema::make($promptName, 'summary_prompt', [PromptArgument::make('date', required: true)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $promptMessages = [PromptMessage::make(Role::User, TextContent::make("Summary for 2024-07-16"))]; + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $registeredPromptMock->shouldReceive('get')->with($this->container, $args)->andReturn($promptMessages); + + $request = GetPromptRequest::make(1, $promptName, $args); + $result = $this->dispatcher->handlePromptGet($request, $this->session); + + expect($result)->toBeInstanceOf(GetPromptResult::class); + expect($result->messages)->toEqual($promptMessages); + expect($result->description)->toBe($promptSchema->description); +}); + +it('can handle prompt get request and throw exception if required argument is missing', function () { + $promptName = 'needs-topic'; + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('topic', required: true)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + + $request = GetPromptRequest::make(1, $promptName, ['other_arg' => 'value']); // 'topic' is missing + $this->dispatcher->handlePromptGet($request, $this->session); +})->throws(McpServerException::class, "Missing required argument 'topic' for prompt 'needs-topic'."); + + +it('can handle logging set level request and set log level on session', function () { + $level = LoggingLevel::Debug; + $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID); + $this->session->shouldReceive('set')->with('log_level', 'debug')->once(); + + $request = SetLogLevelRequest::make(1, $level); + $result = $this->dispatcher->handleLoggingSetLevel($request, $this->session); + + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('can handle completion complete request for prompt and delegate to provider', function () { + $promptName = 'my-completable-prompt'; + $argName = 'tagName'; + $currentValue = 'php'; + $completions = ['php-mcp', 'php-fig']; + $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class); + $providerClass = get_class($mockCompletionProvider); + + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock->shouldReceive('getCompletionProvider')->with($argName)->andReturn($providerClass); + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); + $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result)->toBeInstanceOf(CompletionCompleteResult::class); + expect($result->values)->toEqual($completions); + expect($result->total)->toBe(count($completions)); + expect($result->hasMore)->toBeFalse(); +}); + +it('can handle completion complete request for resource template and delegate to provider', function () { + $templateUri = 'item://{itemId}/category/{catName}'; + $uriVarName = 'catName'; + $currentValue = 'boo'; + $completions = ['books', 'boomerangs']; + $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class); + $providerClass = get_class($mockCompletionProvider); + + $templateSchema = ResourceTemplateSchema::make($templateUri, 'item-template'); + $registeredTemplateMock = Mockery::mock(RegisteredResourceTemplate::class, [$templateSchema, 'MyResourceTemplateHandler', 'get', false]); + $registeredTemplateMock->shouldReceive('getVariableNames')->andReturn(['itemId', 'catName']); + $registeredTemplateMock->shouldReceive('getCompletionProvider')->with($uriVarName)->andReturn($providerClass); + + $this->registry->shouldReceive('getResourceTemplate')->with($templateUri)->andReturn($registeredTemplateMock); + $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); + $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); + + $request = CompletionCompleteRequest::make(1, ResourceReference::make($templateUri), ['name' => $uriVarName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result->values)->toEqual($completions); +}); + +it('can handle completion complete request and return empty if no provider', function () { + $promptName = 'no-provider-prompt'; + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('arg')]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock->shouldReceive('getCompletionProvider')->andReturn(null); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => 'arg', 'value' => '']); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + expect($result->values)->toBeEmpty(); +}); + + +it('decodeCursor handles null and invalid cursors', function () { + $method = new \ReflectionMethod(Dispatcher::class, 'decodeCursor'); + $method->setAccessible(true); + + expect($method->invoke($this->dispatcher, null))->toBe(0); + expect($method->invoke($this->dispatcher, 'not_base64_$$$'))->toBe(0); + expect($method->invoke($this->dispatcher, base64_encode('invalid_format')))->toBe(0); + expect($method->invoke($this->dispatcher, base64_encode('offset=123')))->toBe(123); +}); + +it('encodeNextCursor generates correct cursor or null', function () { + $method = new \ReflectionMethod(Dispatcher::class, 'encodeNextCursor'); + $method->setAccessible(true); + $limit = DISPATCHER_PAGINATION_LIMIT; + + expect($method->invoke($this->dispatcher, 0, $limit, 10, $limit))->toBe(base64_encode('offset=3')); + expect($method->invoke($this->dispatcher, 0, $limit, $limit, $limit))->toBeNull(); + expect($method->invoke($this->dispatcher, $limit, 2, $limit + 2 + 1, $limit))->toBe(base64_encode('offset=' . ($limit + 2))); + expect($method->invoke($this->dispatcher, $limit, 1, $limit + 1, $limit))->toBeNull(); + expect($method->invoke($this->dispatcher, 0, 0, 10, $limit))->toBeNull(); +}); diff --git a/tests/Unit/Elements/RegisteredElementTest.php b/tests/Unit/Elements/RegisteredElementTest.php new file mode 100644 index 0000000..4a896fa --- /dev/null +++ b/tests/Unit/Elements/RegisteredElementTest.php @@ -0,0 +1,234 @@ +container = Mockery::mock(ContainerInterface::class); + $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler()); +}); + +it('can be constructed as manual or discovered', function () { + $elManual = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', true); + $elDiscovered = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', false); + expect($elManual->isManual)->toBeTrue(); + expect($elDiscovered->isManual)->toBeFalse(); + expect($elDiscovered->handlerClass)->toBe(VariousTypesHandler::class); + expect($elDiscovered->handlerMethod)->toBe('noArgsMethod'); +}); + +it('prepares arguments in correct order for simple required types', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; + $result = $element->handle($this->container, $args); + + $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true]; + + expect($result)->toBe($expectedResult); +}); + +it('uses default values for missing optional arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'optionalArgsWithDefaults'); + + $result1 = $element->handle($this->container, ['pString' => 'override']); + expect($result1['pString'])->toBe('override'); + expect($result1['pInt'])->toBe(100); + expect($result1['pNullableBool'])->toBeTrue(); + expect($result1['pFloat'])->toBe(3.14); + + $result2 = $element->handle($this->container, []); + expect($result2['pString'])->toBe('default_string'); + expect($result2['pInt'])->toBe(100); + expect($result2['pNullableBool'])->toBeTrue(); + expect($result2['pFloat'])->toBe(3.14); +}); + +it('passes null for nullable arguments if not provided', function () { + $elementNoDefaults = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $result2 = $elementNoDefaults->handle($this->container, []); + expect($result2['pString'])->toBeNull(); + expect($result2['pInt'])->toBeNull(); + expect($result2['pArray'])->toBeNull(); +}); + +it('passes null explicitly for nullable arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null]); + expect($result['pString'])->toBeNull(); + expect($result['pInt'])->toBeNull(); + expect($result['pArray'])->toBeNull(); +}); + +it('handles mixed type arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'mixedTypeArg'); + $obj = new stdClass(); + $testValues = [ + 'a string', + 123, + true, + null, + ['an', 'array'], + $obj + ]; + foreach ($testValues as $value) { + $result = $element->handle($this->container, ['pMixed' => $value]); + expect($result['pMixed'])->toBe($value); + } +}); + +it('throws McpServerException for missing required argument', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123]); +})->throws(McpServerException::class, 'Missing required argument `pBool`'); + +dataset('valid_type_casts', [ + 'string_from_int' => ['strParam', 123, '123'], + 'int_from_valid_string' => ['intParam', '456', 456], + 'int_from_neg_string' => ['intParam', '-10', -10], + 'int_from_float_whole' => ['intParam', 77.0, 77], + 'bool_from_int_1' => ['boolProp', 1, true], + 'bool_from_string_true' => ['boolProp', 'true', true], + 'bool_from_string_TRUE' => ['boolProp', 'TRUE', true], + 'bool_from_int_0' => ['boolProp', 0, false], + 'bool_from_string_false' => ['boolProp', 'false', false], + 'bool_from_string_FALSE' => ['boolProp', 'FALSE', false], + 'float_from_valid_string' => ['floatParam', '7.89', 7.89], + 'float_from_int' => ['floatParam', 10, 10.0], + 'array_passthrough' => ['arrayParam', ['x', 'y'], ['x', 'y']], + 'object_passthrough' => ['objectParam', (object)['a' => 1], (object)['a' => 1]], + 'string_for_int_cast_specific' => ['stringForIntCast', '999', 999], + 'string_for_float_cast_specific' => ['stringForFloatCast', '123.45', 123.45], + 'string_for_bool_true_cast_specific' => ['stringForBoolTrueCast', '1', true], + 'string_for_bool_false_cast_specific' => ['stringForBoolFalseCast', '0', false], + 'int_for_string_cast_specific' => ['intForStringCast', 55, '55'], + 'int_for_float_cast_specific' => ['intForFloatCast', 66, 66.0], + 'bool_for_string_cast_specific' => ['boolForStringCast', true, '1'], + 'backed_string_enum_valid_val' => ['backedStringEnumParam', 'A', BackedStringEnum::OptionA], + 'backed_int_enum_valid_val' => ['backedIntEnumParam', 1, BackedIntEnum::First], + 'unit_enum_valid_val' => ['unitEnumParam', 'Yes', UnitEnum::Yes], +]); + +it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) { + $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + + $allArgs = [ + 'strParam' => 'default string', + 'intParam' => 0, + 'boolProp' => false, + 'floatParam' => 0.0, + 'arrayParam' => [], + 'backedStringEnumParam' => BackedStringEnum::OptionA, + 'backedIntEnumParam' => BackedIntEnum::First, + 'unitEnumParam' => 'Yes', + 'nullableStringParam' => null, + 'mixedParam' => 'default mixed', + 'objectParam' => new stdClass(), + 'stringForIntCast' => '0', + 'stringForFloatCast' => '0.0', + 'stringForBoolTrueCast' => 'false', + 'stringForBoolFalseCast' => 'true', + 'intForStringCast' => 0, + 'intForFloatCast' => 0, + 'boolForStringCast' => false, + 'valueForBackedStringEnum' => 'A', + 'valueForBackedIntEnum' => 1, + ]; + $testArgs = array_merge($allArgs, [$paramName => $inputValue]); + + $result = $element->handle($this->container, $testArgs); + expect($result[$paramName])->toEqual($expectedValue); +})->with('valid_type_casts'); + + +dataset('invalid_type_casts', [ + 'int_from_alpha_string' => ['intParam', 'abc', '/Cannot cast value to integer/i'], + 'int_from_float_non_whole' => ['intParam', 12.3, '/Cannot cast value to integer/i'], + 'bool_from_string_random' => ['boolProp', 'random', '/Cannot cast value to boolean/i'], + 'bool_from_int_invalid' => ['boolProp', 2, '/Cannot cast value to boolean/i'], + 'float_from_alpha_string' => ['floatParam', 'xyz', '/Cannot cast value to float/i'], + 'array_from_string' => ['arrayParam', 'not_an_array', '/Cannot cast value to array/i'], + 'backed_string_enum_invalid_val' => ['backedStringEnumParam', 'Z', "/Invalid value 'Z' for backed enum .*BackedStringEnum/i"], + 'backed_int_enum_invalid_val' => ['backedIntEnumParam', 99, "/Invalid value '99' for backed enum .*BackedIntEnum/i"], + 'unit_enum_invalid_string_val' => ['unitEnumParam', 'Maybe', "/Invalid value 'Maybe' for unit enum .*UnitEnum/i"], +]); + +it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) { + $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + $allArgs = [ /* fill with defaults as in valid_type_casts */ + 'strParam' => 's', + 'intParam' => 1, + 'boolProp' => true, + 'floatParam' => 1.1, + 'arrayParam' => [], + 'backedStringEnumParam' => BackedStringEnum::OptionA, + 'backedIntEnumParam' => BackedIntEnum::First, + 'unitEnumParam' => UnitEnum::Yes, + 'nullableStringParam' => null, + 'mixedParam' => 'mix', + 'objectParam' => new stdClass(), + 'stringForIntCast' => '0', + 'stringForFloatCast' => '0.0', + 'stringForBoolTrueCast' => 'false', + 'stringForBoolFalseCast' => 'true', + 'intForStringCast' => 0, + 'intForFloatCast' => 0, + 'boolForStringCast' => false, + 'valueForBackedStringEnum' => 'A', + 'valueForBackedIntEnum' => 1, + ]; + $testArgs = array_merge($allArgs, [$paramName => $invalidValue]); + + try { + $element->handle($this->container, $testArgs); + } catch (McpServerException $e) { + expect($e->getMessage())->toMatch($expectedMsgRegex); + } +})->with('invalid_type_casts'); + +it('casts to BackedStringEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1]); + expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); +}); + +it('throws for invalid BackedStringEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1]); +})->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); + +it('casts to BackedIntEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2]); + expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); +}); + +it('throws for invalid BackedIntEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999]); +})->throws(McpServerException::class, "Invalid value '999' for backed enum"); + +it('casts to UnitEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $result = $element->handle($this->container, ['pUnitEnum' => 'Yes']); + expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); +}); + +it('throws for invalid UnitEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $element->handle($this->container, ['pUnitEnum' => 'Invalid']); +})->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); + + +it('throws ReflectionException if handler method does not exist', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'nonExistentMethod'); + $element->handle($this->container, []); +})->throws(\ReflectionException::class, "Method does not exist"); diff --git a/tests/Unit/Elements/RegisteredPromptTest.php b/tests/Unit/Elements/RegisteredPromptTest.php new file mode 100644 index 0000000..47bb3cc --- /dev/null +++ b/tests/Unit/Elements/RegisteredPromptTest.php @@ -0,0 +1,237 @@ +container = Mockery::mock(ContainerInterface::class); + $this->container->shouldReceive('get') + ->with(PromptHandlerFixture::class) + ->andReturn(new PromptHandlerFixture()) + ->byDefault(); + + $this->promptSchema = PromptSchema::make( + 'test-greeting-prompt', + 'Generates a greeting.', + [PromptArgument::make('name', 'The name to greet.', true)] + ); +}); + +it('constructs correctly with schema, handler, and completion providers', function () { + $providers = ['name' => CompletionProviderFixture::class]; + $prompt = RegisteredPrompt::make( + $this->promptSchema, + PromptHandlerFixture::class, + 'promptWithArgumentCompletion', + false, + $providers + ); + + expect($prompt->schema)->toBe($this->promptSchema); + expect($prompt->handlerClass)->toBe(PromptHandlerFixture::class); + expect($prompt->handlerMethod)->toBe('promptWithArgumentCompletion'); + expect($prompt->isManual)->toBeFalse(); + expect($prompt->completionProviders)->toEqual($providers); + expect($prompt->getCompletionProvider('name'))->toBe(CompletionProviderFixture::class); + expect($prompt->getCompletionProvider('nonExistentArg'))->toBeNull(); +}); + +it('can be made as a manual registration', function () { + $manualPrompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting', true); + expect($manualPrompt->isManual)->toBeTrue(); +}); + +it('calls handler with prepared arguments via get()', function () { + $handlerMock = Mockery::mock(PromptHandlerFixture::class); + $handlerMock->shouldReceive('generateSimpleGreeting') + ->with('Alice', 'warm') + ->once() + ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]); + $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); + + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting'); + $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm']); + + expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); +}); + +it('formats single PromptMessage object from handler', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSinglePromptMessageObject'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toHaveCount(1); + expect($messages[0])->toBeInstanceOf(PromptMessage::class); + expect($messages[0]->content->text)->toBe("Single PromptMessage object."); +}); + +it('formats array of PromptMessage objects from handler as is', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toHaveCount(2); + expect($messages[0]->content->text)->toBe("First message object."); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class); +}); + +it('formats empty array from handler as empty array', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnEmptyArrayForPrompt'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toBeEmpty(); +}); + +it('formats simple user/assistant map from handler', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSimpleUserAssistantMap'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(2); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content->text)->toBe("This is the user's turn."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content->text)->toBe("And this is the assistant's reply."); +}); + +it('formats user/assistant map with Content objects', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_img_data"); +}); + +it('formats user/assistant map with mixed content (string and Content object)', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("aud_data"); +}); + +it('formats user/assistant map with array content', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_arr_img_data"); +}); + +it('formats list of raw message arrays with various content types', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArrays'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(6); + expect($messages[0]->content->text)->toBe("First raw message string."); + expect($messages[1]->content)->toBeInstanceOf(TextContent::class); + expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("raw_img_data"); + expect($messages[3]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("raw_aud_data"); + expect($messages[4]->content)->toBeInstanceOf(EmbeddedResource::class); + expect($messages[4]->content->resource->blob)->toBe(base64_encode('pdf-data')); + expect($messages[5]->content)->toBeInstanceOf(EmbeddedResource::class); + expect($messages[5]->content->resource->text)->toBe('{"theme":"dark"}'); +}); + +it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(5); + expect($messages[0]->content->text)->toBe("123"); + expect($messages[1]->content->text)->toBe("true"); + expect($messages[2]->content->text)->toBe("(null)"); + expect($messages[3]->content->text)->toBe("3.14"); + expect($messages[4]->content->text)->toBe(json_encode(['key' => 'value'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + +it('formats mixed array of PromptMessage objects and raw message arrays', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(4); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); + expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array."); + expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("pm_img"); + expect($messages[3]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Raw message with typed content."); +}); + + +dataset('prompt_format_errors', [ + 'non_array_return' => ['promptReturnsNonArray', '/Prompt generator method must return an array/'], + 'invalid_role_in_array' => ['promptReturnsInvalidRole', "/Invalid role 'system'/"], + 'invalid_content_structure_in_array' => ['promptReturnsArrayWithInvalidContentStructure', "/Invalid message format at index 0. Expected an array with 'role' and 'content' keys./"], // More specific from formatMessage + 'invalid_typed_content_in_array' => ['promptReturnsArrayWithInvalidTypedContent', "/Invalid 'image' content at index 0: Missing or invalid 'data' string/"], + 'invalid_resource_content_in_array' => ['promptReturnsArrayWithInvalidResourceContent', "/Invalid resource at index 0: Must contain 'text' or 'blob'./"], +]); + +it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) { + $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn'; + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, $methodName); + + if (is_callable($handlerMethodOrCallable)) { + $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn( + Mockery::mock(PromptHandlerFixture::class, [$methodName => $handlerMethodOrCallable()]) + ); + } + + try { + $prompt->get($this->container, []); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toMatch($expectedErrorPattern); + } + + expect($prompt->toArray())->toBeArray(); +})->with('prompt_format_errors'); + + +it('propagates exceptions from handler during get()', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'promptHandlerThrows'); + $prompt->get($this->container, []); +})->throws(\LogicException::class, "Prompt generation failed inside handler."); + + +it('can be serialized to array and deserialized with completion providers', function () { + $schema = PromptSchema::make( + 'serialize-prompt', + 'Test SerDe', + [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')] + ); + $providers = ['arg1' => CompletionProviderFixture::class]; + $original = RegisteredPrompt::make( + $schema, + PromptHandlerFixture::class, + 'generateSimpleGreeting', + true, + $providers + ); + + $array = $original->toArray(); + + expect($array['schema']['name'])->toBe('serialize-prompt'); + expect($array['schema']['arguments'])->toHaveCount(2); + expect($array['handlerClass'])->toBe(PromptHandlerFixture::class); + expect($array['handlerMethod'])->toBe('generateSimpleGreeting'); + expect($array['isManual'])->toBeTrue(); + expect($array['completionProviders'])->toEqual($providers); + + $rehydrated = RegisteredPrompt::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); + expect($rehydrated->completionProviders)->toEqual($providers); +}); + +it('fromArray returns false on failure for prompt', function () { + $badData = ['schema' => ['name' => 'fail'], 'handlerClass' => null, 'handlerMethod' => null]; + expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredResourceTemplateTest.php b/tests/Unit/Elements/RegisteredResourceTemplateTest.php new file mode 100644 index 0000000..bc74a52 --- /dev/null +++ b/tests/Unit/Elements/RegisteredResourceTemplateTest.php @@ -0,0 +1,222 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ResourceHandlerFixture(); + $this->container->shouldReceive('get') + ->with(ResourceHandlerFixture::class) + ->andReturn($this->handlerInstance) + ->byDefault(); + + $this->templateUri = 'item://{category}/{itemId}/details'; + $this->resourceTemplateSchema = ResourceTemplate::make( + $this->templateUri, + 'item-details-template', + mimeType: 'application/json' + ); + + $this->defaultHandlerMethod = 'getUserDocument'; + $this->matchingTemplateSchema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); +}); + +it('constructs correctly with schema, handler, and completion providers', function () { + $completionProviders = [ + 'userId' => CompletionProviderFixture::class, + 'documentId' => 'Another\ProviderClass' + ]; + + $schema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); + + $template = RegisteredResourceTemplate::make( + schema: $schema, + handlerClass: ResourceHandlerFixture::class, + handlerMethod: 'getUserDocument', + completionProviders: $completionProviders + ); + + expect($template->schema)->toBe($schema); + expect($template->handlerClass)->toBe(ResourceHandlerFixture::class); + expect($template->handlerMethod)->toBe('getUserDocument'); + expect($template->isManual)->toBeFalse(); + expect($template->completionProviders)->toEqual($completionProviders); + expect($template->getCompletionProvider('userId'))->toBe(CompletionProviderFixture::class); + expect($template->getCompletionProvider('documentId'))->toBe('Another\ProviderClass'); + expect($template->getCompletionProvider('nonExistentVar'))->toBeNull(); +}); + +it('can be made as a manual registration', function () { + $schema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); + + $manualTemplate = RegisteredResourceTemplate::make( + schema: $schema, + handlerClass: ResourceHandlerFixture::class, + handlerMethod: 'getUserDocument', + isManual: true + ); + + expect($manualTemplate->isManual)->toBeTrue(); +}); + +dataset('uri_template_matching_cases', [ + 'simple_var' => ['user://{userId}', 'user://12345', ['userId' => '12345']], + 'simple_var_alpha' => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']], + 'no_match_missing_var_part' => ['user://{userId}', 'user://', null], + 'no_match_prefix' => ['user://{userId}', 'users://12345', null], + 'multi_var' => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']], + 'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment + 'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null], + 'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']], + 'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null], + 'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']], + 'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null], + 'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null], + 'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']], +]); + +it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) { + $schema = ResourceTemplate::make($templateString, 'test-match'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + + if ($expectedVariables !== null) { + expect($template->matches($uriToTest))->toBeTrue(); + $reflection = new \ReflectionClass($template); + $prop = $reflection->getProperty('uriVariables'); + $prop->setAccessible(true); + expect($prop->getValue($template))->toEqual($expectedVariables); + } else { + expect($template->matches($uriToTest))->toBeFalse(); + } +})->with('uri_template_matching_cases'); + +it('gets variable names from compiled template', function () { + $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']); +}); + +it('reads resource using handler with extracted URI variables', function () { + $uriTemplate = 'item://{category}/{itemId}?format={format}'; + $uri = 'item://electronics/tv-123?format=json_pretty'; + $schema = ResourceTemplate::make($uriTemplate, 'item-details-template'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + + expect($template->matches($uri))->toBeTrue(); + + $resultContents = $template->read($this->container, $uri); + + expect($resultContents)->toBeArray()->toHaveCount(1); + + $content = $resultContents[0]; + expect($content)->toBeInstanceOf(TextResourceContents::class); + expect($content->uri)->toBe($uri); + expect($content->mimeType)->toBe('application/json'); + + $decodedText = json_decode($content->text, true); + expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty."); + expect($decodedText['category_received'])->toBe('electronics'); + expect($decodedText['itemId_received'])->toBe('tv-123'); + expect($decodedText['format_received'])->toBe('json_pretty'); +}); + +it('uses mimeType from schema if handler result does not specify one', function () { + $uriTemplate = 'item://{category}/{itemId}?format={format}'; + $uri = 'item://books/bestseller?format=json_pretty'; + $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + expect($template->matches($uri))->toBeTrue(); + + $resultContents = $template->read($this->container, $uri); + expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml'); +}); + +it('formats a simple string result from handler correctly for template', function () { + $uri = 'item://tools/hammer'; + $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'returnStringText'); + expect($template->matches($uri))->toBeTrue(); + + $mockHandler = Mockery::mock(ResourceHandlerFixture::class); + $mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler'); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $template->read($this->container, $uri); + expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class) + ->and($resultContents[0]->text)->toBe('Simple content from template handler') + ->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema +}); + +it('propagates exceptions from handler during read', function () { + $uri = 'item://tools/hammer'; + $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'handlerThrowsException'); + expect($template->matches($uri))->toBeTrue(); + $template->read($this->container, $uri); +})->throws(\DomainException::class, "Cannot read resource"); + +it('can be serialized to array and deserialized', function () { + $schema = ResourceTemplate::make( + 'obj://{type}/{id}', + 'my-template', + mimeType: 'application/template+json', + annotations: Annotations::make(priority: 0.7) + ); + + $providers = ['type' => CompletionProviderFixture::class]; + + $original = RegisteredResourceTemplate::make( + $schema, + ResourceHandlerFixture::class, + 'getUserDocument', + true, + $providers + ); + + $array = $original->toArray(); + + expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}'); + expect($array['schema']['name'])->toBe('my-template'); + expect($array['schema']['mimeType'])->toBe('application/template+json'); + expect($array['schema']['annotations']['priority'])->toBe(0.7); + expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); + expect($array['handlerMethod'])->toBe('getUserDocument'); + expect($array['isManual'])->toBeTrue(); + expect($array['completionProviders'])->toEqual($providers); + + $rehydrated = RegisteredResourceTemplate::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class); + expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); + expect($rehydrated->completionProviders)->toEqual($providers); +}); + +it('fromArray returns false on failure', function () { + $badData = ['schema' => ['uriTemplate' => 'fail'], 'handlerMethod' => null]; + expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredResourceTest.php b/tests/Unit/Elements/RegisteredResourceTest.php new file mode 100644 index 0000000..5a72143 --- /dev/null +++ b/tests/Unit/Elements/RegisteredResourceTest.php @@ -0,0 +1,230 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ResourceHandlerFixture(); + $this->container->shouldReceive('get') + ->with(ResourceHandlerFixture::class) + ->andReturn($this->handlerInstance) + ->byDefault(); + + $this->testUri = 'test://resource/item.txt'; + $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain'); + $this->registeredResource = RegisteredResource::make( + $this->resourceSchema, + ResourceHandlerFixture::class, + 'returnStringText' + ); +}); + +afterEach(function () { + if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) { + @unlink(ResourceHandlerFixture::$unlinkableSplFile); + ResourceHandlerFixture::$unlinkableSplFile = null; + } +}); + +it('constructs correctly and exposes schema', function () { + expect($this->registeredResource->schema)->toBe($this->resourceSchema); + expect($this->registeredResource->handlerClass)->toBe(ResourceHandlerFixture::class); + expect($this->registeredResource->handlerMethod)->toBe('returnStringText'); + expect($this->registeredResource->isManual)->toBeFalse(); +}); + +it('can be made as a manual registration', function () { + $manualResource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnStringText', true); + expect($manualResource->isManual)->toBeTrue(); +}); + +it('passes URI to handler if handler method expects it', function () { + $resource = RegisteredResource::make( + ResourceSchema::make($this->testUri, 'needs-uri'), + ResourceHandlerFixture::class, + 'resourceHandlerNeedsUri' + ); + + $handlerMock = Mockery::mock(ResourceHandlerFixture::class); + $handlerMock->shouldReceive('resourceHandlerNeedsUri') + ->with($this->testUri) + ->once() + ->andReturn("Confirmed URI: {$this->testUri}"); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); + + $result = $resource->read($this->container, $this->testUri); + expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}"); +}); + +it('does not require handler method to accept URI', function () { + $resource = RegisteredResource::make( + ResourceSchema::make($this->testUri, 'no-uri-param'), + ResourceHandlerFixture::class, + 'resourceHandlerDoesNotNeedUri' + ); + $handlerMock = Mockery::mock(ResourceHandlerFixture::class); + $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI"); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); + + $result = $resource->read($this->container, $this->testUri); + expect($result[0]->text)->toBe("Success no URI"); +}); + + +dataset('resource_handler_return_types', [ + 'string_text' => ['returnStringText', 'text/plain', fn ($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], + 'string_json_guess' => ['returnStringJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], + 'string_html_guess' => ['returnStringHtml', 'text/html', fn ($text, $uri) => expect($text)->toContain("{$uri}"), null], + 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content + 'empty_array' => ['returnEmptyArray', 'application/json', fn ($text) => expect($text)->toBe('[]'), null], + 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], + 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], + 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn ($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], + 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn ($text) => expect($text)->toBe('Direct TextResourceContents'), null], + 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn ($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], + 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn ($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], +]); + +it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) { + $schema = ResourceSchema::make($this->testUri, 'format-test'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, $handlerMethod); + + $resultContents = $resource->read($this->container, $this->testUri); + + expect($resultContents)->toBeArray()->toHaveCount(1); + $content = $resultContents[0]; + + expect($content->uri)->toBe($this->testUri); + expect($content->mimeType)->toBe($expectedMime); + + if ($textAssertion) { + expect($content)->toBeInstanceOf(TextResourceContents::class); + $textAssertion($content->text, $this->testUri); + } + if ($blobAssertion) { + expect($content)->toBeInstanceOf(BlobResourceContents::class); + $blobAssertion($content->blob, $this->testUri); + } +})->with('resource_handler_return_types'); + +it('formats SplFileInfo based on schema MIME type (text)', function () { + $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $result = $resource->read($this->container, $this->testUri); + + expect($result[0])->toBeInstanceOf(TextResourceContents::class); + expect($result[0]->mimeType)->toBe('text/markdown'); + expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}"); +}); + +it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () { + $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $result = $resource->read($this->container, $this->testUri); + + expect($result[0])->toBeInstanceOf(BlobResourceContents::class); + expect($result[0]->mimeType)->toBe('image/png'); + expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}"); +}); + +it('formats array of ResourceContents as is', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfResourceContents'); + $results = $resource->read($this->container, $this->testUri); + expect($results)->toHaveCount(2); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC'); + expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata')); +}); + +it('formats array of EmbeddedResources by extracting their inner resource', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources'); + $results = $resource->read($this->container, $this->testUri); + expect($results)->toHaveCount(2); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe(''); + expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata')); +}); + +it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes'); + $results = $resource->read($this->container, $this->testUri); + + expect($results)->toBeArray()->toHaveCount(4); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece"); + expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**"); + expect($results[2])->toBeInstanceOf(TextResourceContents::class); + expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]); + expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2"); +}); + + +it('propagates McpServerException from handler during read', function () { + $resource = RegisteredResource::make( + $this->resourceSchema, + ResourceHandlerFixture::class, + 'resourceHandlerNeedsUri' + ); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn( + Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) { + $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error")); + }) + ); + $resource->read($this->container, $this->testUri); +})->throws(McpServerException::class, "Test error"); + +it('propagates other exceptions from handler during read', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'handlerThrowsException'); + $resource->read($this->container, $this->testUri); +})->throws(\DomainException::class, "Cannot read resource"); + +it('throws RuntimeException for unformattable handler result', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnUnformattableType'); + $resource->read($this->container, $this->testUri); +})->throws(\RuntimeException::class, "Cannot format resource read result for URI"); + + +it('can be serialized to array and deserialized', function () { + $original = RegisteredResource::make( + ResourceSchema::make( + 'uri://test', + 'my-resource', + 'desc', + 'app/foo', + ), + ResourceHandlerFixture::class, + 'getStaticText', + true + ); + + $array = $original->toArray(); + + expect($array['schema']['uri'])->toBe('uri://test'); + expect($array['schema']['name'])->toBe('my-resource'); + expect($array['schema']['description'])->toBe('desc'); + expect($array['schema']['mimeType'])->toBe('app/foo'); + expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); + expect($array['handlerMethod'])->toBe('getStaticText'); + expect($array['isManual'])->toBeTrue(); + + $rehydrated = RegisteredResource::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredResource::class); + expect($rehydrated->schema->uri)->toEqual($original->schema->uri); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); +}); + +it('fromArray returns false on failure', function () { + $badData = ['schema' => ['uri' => 'fail'], 'handlerClass' => null]; + expect(RegisteredResource::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredToolTest.php b/tests/Unit/Elements/RegisteredToolTest.php new file mode 100644 index 0000000..46256b5 --- /dev/null +++ b/tests/Unit/Elements/RegisteredToolTest.php @@ -0,0 +1,180 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ToolHandlerFixture(); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class) + ->andReturn($this->handlerInstance)->byDefault(); + + $this->toolSchema = Tool::make( + name: 'test-tool', + inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]] + ); + + $this->registeredTool = RegisteredTool::make( + $this->toolSchema, + ToolHandlerFixture::class, + 'greet' + ); +}); + +it('constructs correctly and exposes schema', function () { + expect($this->registeredTool->schema)->toBe($this->toolSchema); + expect($this->registeredTool->handlerClass)->toBe(ToolHandlerFixture::class); + expect($this->registeredTool->handlerMethod)->toBe('greet'); + expect($this->registeredTool->isManual)->toBeFalse(); +}); + +it('can be made as a manual registration', function () { + $manualTool = RegisteredTool::make($this->toolSchema, ToolHandlerFixture::class, 'greet', true); + expect($manualTool->isManual)->toBeTrue(); +}); + +it('calls the handler with prepared arguments', function () { + $tool = RegisteredTool::make( + Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]), + ToolHandlerFixture::class, + 'sum' + ); + $mockHandler = Mockery::mock(ToolHandlerFixture::class); + $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10']); // '10' will be cast to int by prepareArguments + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15'); +}); + +it('calls handler with no arguments if tool takes none and none provided', function () { + $tool = RegisteredTool::make( + Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'noParamsTool' + ); + $mockHandler = Mockery::mock(ToolHandlerFixture::class); + $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $tool->call($this->container, []); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + + +dataset('tool_handler_return_values', [ + 'string' => ['returnString', "This is a string result."], + 'integer' => ['returnInteger', "12345"], + 'float' => ['returnFloat', "67.89"], + 'boolean_true' => ['returnBooleanTrue', "true"], + 'boolean_false' => ['returnBooleanFalse', "false"], + 'null' => ['returnNull', "(null)"], + 'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], + 'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], +]); + +it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) { + $tool = RegisteredTool::make( + Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + $handlerMethod + ); + + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText); +})->with('tool_handler_return_values'); + +it('returns single Content object from handler as array with one Content object', function () { + $tool = RegisteredTool::make( + Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnTextContent' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent."); +}); + +it('returns array of Content objects from handler as is', function () { + $tool = RegisteredTool::make( + Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnArrayOfContent' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(2); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1"); + expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata"); +}); + +it('formats mixed array from handler into array of Content objects', function () { + $tool = RegisteredTool::make( + Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnMixedArray' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(8); + + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string"); + expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved + expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123"); + expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true"); + expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)"); + expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved + expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + +it('formats empty array from handler into TextContent with "[]"', function () { + $tool = RegisteredTool::make( + Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnEmptyArray' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]'); +}); + +it('throws JsonException during formatResult if handler returns unencodable value', function () { + $tool = RegisteredTool::make( + Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'toolUnencodableResult' + ); + $tool->call($this->container, []); +})->throws(JsonException::class); + +it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () { + $tool = RegisteredTool::make( + Tool::make('exception-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'toolThatThrows' + ); + + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture()); + + $tool->call($this->container, []); +})->throws(InvalidArgumentException::class, "Something went wrong in the tool."); diff --git a/tests/Unit/JsonRpc/BatchTest.php b/tests/Unit/JsonRpc/BatchTest.php deleted file mode 100644 index cb4c902..0000000 --- a/tests/Unit/JsonRpc/BatchTest.php +++ /dev/null @@ -1,162 +0,0 @@ -getRequests())->toBeArray(); - expect($batch->getRequests())->toBeEmpty(); - expect($batch->count())->toBe(0); -}); - -test('batch construction with requests array', function () { - $request = new Request('2.0', 1, 'test.method'); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request, $notification]); - - expect($batch->getRequests())->toHaveCount(2); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[1])->toBeInstanceOf(Notification::class); - expect($batch->count())->toBe(2); -}); - -test('addRequest adds a request to the batch', function () { - $batch = new Batch(); - $request = new Request('2.0', 1, 'test.method'); - - $batch->addRequest($request); - - expect($batch->getRequests())->toHaveCount(1); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); -}); - -test('addRequest is chainable', function () { - $batch = new Batch(); - $request = new Request('2.0', 1, 'test.method'); - $notification = new Notification('2.0', 'test.notification'); - - $result = $batch->addRequest($request)->addRequest($notification); - - expect($result)->toBe($batch); - expect($batch->getRequests())->toHaveCount(2); -}); - -test('getRequestsWithIds returns only requests with IDs', function () { - $request1 = new Request('2.0', 1, 'test.method1'); - $request2 = new Request('2.0', 2, 'test.method2'); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request1, $notification, $request2]); - - $requestsWithIds = $batch->getRequestsWithIds(); - expect($requestsWithIds)->toHaveCount(2); - expect($requestsWithIds[0])->toBeInstanceOf(Request::class); - expect($requestsWithIds[0]->id)->toBe(1); - expect($requestsWithIds[2])->toBeInstanceOf(Request::class); - expect($requestsWithIds[2]->id)->toBe(2); -}); - -test('getNotifications returns only notifications', function () { - $request = new Request('2.0', 1, 'test.method'); - $notification1 = new Notification('2.0', 'test.notification1'); - $notification2 = new Notification('2.0', 'test.notification2'); - - $batch = new Batch([$request, $notification1, $notification2]); - - $notifications = $batch->getNotifications(); - expect($notifications)->toHaveCount(2); - expect($notifications[1])->toBeInstanceOf(Notification::class); - expect($notifications[1]->method)->toBe('test.notification1'); - expect($notifications[2])->toBeInstanceOf(Notification::class); - expect($notifications[2]->method)->toBe('test.notification2'); -}); - -test('count returns correct number of requests', function () { - $batch = new Batch(); - expect($batch->count())->toBe(0); - - $batch->addRequest(new Request('2.0', 1, 'test.method')); - expect($batch->count())->toBe(1); - - $batch->addRequest(new Notification('2.0', 'test.notification')); - expect($batch->count())->toBe(2); -}); - -test('fromArray creates batch from array of requests and notifications', function () { - $data = [ - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method1', - 'params' => [], - ], - [ - 'jsonrpc' => '2.0', - 'method' => 'test.notification', - 'params' => [], - ], - [ - 'jsonrpc' => '2.0', - 'id' => 2, - 'method' => 'test.method2', - 'params' => ['param1' => 'value1'], - ], - ]; - - $batch = Batch::fromArray($data); - - expect($batch->count())->toBe(3); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[0]->id)->toBe(1); - expect($batch->getRequests()[1])->toBeInstanceOf(Notification::class); - expect($batch->getRequests()[1]->method)->toBe('test.notification'); - expect($batch->getRequests()[2])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[2]->id)->toBe(2); - expect($batch->getRequests()[2]->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray throws exception for empty array', function () { - expect(fn () => Batch::fromArray([]))->toThrow(ProtocolException::class); -}); - -test('fromArray throws exception for non-array item', function () { - $data = [ - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ], - 'not an array', - ]; - - expect(fn () => Batch::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns array of request representations', function () { - $request = new Request('2.0', 1, 'test.method', ['param1' => 'value1']); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request, $notification]); - - $array = $batch->toArray(); - - expect($array)->toBeArray(); - expect($array)->toHaveCount(2); - expect($array[0])->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); - expect($array[1])->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.notification', - ]); -}); diff --git a/tests/Unit/JsonRpc/ErrorTest.php b/tests/Unit/JsonRpc/ErrorTest.php deleted file mode 100644 index ab297d1..0000000 --- a/tests/Unit/JsonRpc/ErrorTest.php +++ /dev/null @@ -1,68 +0,0 @@ - 'error details']); - - expect($error->code)->toBe(100); - expect($error->message)->toBe('Test error message'); - expect($error->data)->toBe(['details' => 'error details']); -}); - -test('error can be created without data', function () { - $error = new Error(100, 'Test error message'); - - expect($error->data)->toBeNull(); -}); - -test('fromArray creates valid error from complete data', function () { - $data = [ - 'code' => 100, - 'message' => 'Test error message', - 'data' => ['details' => 'error details'], - ]; - - $error = Error::fromArray($data); - - expect($error->code)->toBe(100); - expect($error->message)->toBe('Test error message'); - expect($error->data)->toBe(['details' => 'error details']); -}); - -test('fromArray handles missing data', function () { - $data = [ - 'code' => 100, - 'message' => 'Test error message', - ]; - - $error = Error::fromArray($data); - - expect($error->data)->toBeNull(); -}); - -test('toArray returns correct structure with data', function () { - $error = new Error(100, 'Test error message', ['details' => 'error details']); - - $array = $error->toArray(); - - expect($array)->toBe([ - 'code' => 100, - 'message' => 'Test error message', - 'data' => ['details' => 'error details'], - ]); -}); - -test('toArray omits null data', function () { - $error = new Error(100, 'Test error message'); - - $array = $error->toArray(); - - expect($array)->toBe([ - 'code' => 100, - 'message' => 'Test error message', - ]); - expect($array)->not->toHaveKey('data'); -}); diff --git a/tests/Unit/JsonRpc/MessageTest.php b/tests/Unit/JsonRpc/MessageTest.php deleted file mode 100644 index 208231c..0000000 --- a/tests/Unit/JsonRpc/MessageTest.php +++ /dev/null @@ -1,35 +0,0 @@ -jsonrpc)->toBe('2.0'); -}); - -test('toArray returns correct structure', function () { - $message = new Message('2.0'); - - $array = $message->toArray(); - - expect($array)->toBe(['jsonrpc' => '2.0']); -}); - -test('jsonSerialize returns same result as toArray', function () { - $message = new Message('2.0'); - - $array = $message->toArray(); - $json = $message->jsonSerialize(); - - expect($json)->toBe($array); -}); - -test('message can be json encoded directly', function () { - $message = new Message('2.0'); - - $json = json_encode($message); - - expect($json)->toBe('{"jsonrpc":"2.0"}'); -}); diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php deleted file mode 100644 index ecc2549..0000000 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ /dev/null @@ -1,116 +0,0 @@ - 'value1']); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('notification can be created without params', function () { - $notification = new Notification('2.0', 'test.method'); - - expect($notification->params)->toBe([]); -}); - -test('make static method creates notification with default jsonrpc version', function () { - $notification = Notification::make('test.method', ['param1' => 'value1']); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('make static method handles empty params', function () { - $notification = Notification::make('test.method'); - - expect($notification->params)->toBe([]); -}); - -test('fromArray creates valid notification from complete data', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]; - - $notification = Notification::fromArray($data); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray handles missing params', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - ]; - - $notification = Notification::fromArray($data); - - expect($notification->params)->toBe([]); -}); - -test('fromArray throws ProtocolException for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for missing jsonrpc', function () { - $data = ['method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for missing method', function () { - $data = ['jsonrpc' => '2.0']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for non-string method', function () { - $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException if params is not an array/object', function () { - $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns correct structure with params', function () { - $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); - - $array = $notification->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); -}); - -test('toArray omits empty params', function () { - $notification = new Notification('2.0', 'test.method'); - - $array = $notification->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - ]); - expect($array)->not->toHaveKey('params'); -}); - -test('notification can be json encoded', function () { - $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); - - $json = json_encode($notification); - - expect($json)->toBe('{"jsonrpc":"2.0","method":"test.method","params":{"param1":"value1"}}'); -}); diff --git a/tests/Unit/JsonRpc/RequestTest.php b/tests/Unit/JsonRpc/RequestTest.php deleted file mode 100644 index 9290dfc..0000000 --- a/tests/Unit/JsonRpc/RequestTest.php +++ /dev/null @@ -1,112 +0,0 @@ - 'value1']); - - expect($request->jsonrpc)->toBe('2.0'); - expect($request->id)->toBe(1); - expect($request->method)->toBe('test.method'); - expect($request->params)->toBe(['param1' => 'value1']); -}); - -test('request can be created with string id', function () { - $request = new Request('2.0', 'abc123', 'test.method'); - - expect($request->id)->toBe('abc123'); -}); - -test('request can be created without params', function () { - $request = new Request('2.0', 1, 'test.method'); - - expect($request->params)->toBe([]); -}); - -test('fromArray creates valid request from complete data', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]; - - $request = Request::fromArray($data); - - expect($request->jsonrpc)->toBe('2.0'); - expect($request->id)->toBe(1); - expect($request->method)->toBe('test.method'); - expect($request->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray handles missing params', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ]; - - $request = Request::fromArray($data); - - expect($request->params)->toBe([]); -}); - -test('fromArray throws ProtocolException for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'id' => 1, 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing jsonrpc', function () { - $data = ['id' => 1, 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing method', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1]; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for non-string method', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 123]; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing id', function () { - $data = ['jsonrpc' => '2.0', 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for non-array params', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'test.method', 'params' => 'invalid']; - // This check was correct - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns correct structure with params', function () { - $request = new Request('2.0', 1, 'test.method', ['param1' => 'value1']); - - $array = $request->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); -}); - -test('toArray omits empty params', function () { - $request = new Request('2.0', 1, 'test.method'); - - $array = $request->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ]); - expect($array)->not->toHaveKey('params'); -}); diff --git a/tests/Unit/JsonRpc/ResponseTest.php b/tests/Unit/JsonRpc/ResponseTest.php deleted file mode 100644 index 017e842..0000000 --- a/tests/Unit/JsonRpc/ResponseTest.php +++ /dev/null @@ -1,276 +0,0 @@ -jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeInstanceOf(EmptyResult::class); - expect($response->error)->toBeNull(); -}); - -test('response construction sets all properties for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', 1, null, $error); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -test('response construction allows null ID for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', null, null, $error); - - expect($response->id)->toBeNull(); - expect($response->error)->toBe($error); - expect($response->result)->toBeNull(); -}); - -test('response constructor throws exception if ID present but no result/error', function () { - expect(fn () => new Response('2.0', 1, null, null)) - ->toThrow(InvalidArgumentException::class, 'must have either result or error'); -}); - -test('response constructor throws exception if ID null but no error', function () { - expect(fn () => new Response('2.0', null, null, null)) - ->toThrow(InvalidArgumentException::class, 'must have an error object'); -}); - -test('response constructor throws exception if ID null and result present', function () { - expect(fn () => new Response('2.0', null, ['data'], null)) - ->toThrow(InvalidArgumentException::class, 'response with null ID must have an error object'); -}); - -test('response throws exception if both result and error are provided with ID', function () { - $result = new EmptyResult(); - $error = new Error(100, 'Test error'); - expect(fn () => new Response('2.0', 1, $result, $error))->toThrow(InvalidArgumentException::class); -}); - -test('success static method creates success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeInstanceOf(EmptyResult::class); - expect($response->error)->toBeNull(); -}); - -test('error static method creates error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -test('error static method creates error response with null ID', function () { - $error = new Error(100, 'Parse error'); - $response = Response::error($error, null); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBeNull(); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -// --- Status Check Tests --- - -test('isSuccess returns true for success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - expect($response->isSuccess())->toBeTrue(); -}); - -test('isSuccess returns false for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); // Use factory - expect($response->isSuccess())->toBeFalse(); -}); - -test('isError returns true for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - expect($response->isError())->toBeTrue(); -}); - -test('isError returns false for success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - expect($response->isError())->toBeFalse(); -}); - -// --- fromArray Tests (Updated) --- - -test('fromArray creates valid success response with RAW result data', function () { - $rawResultData = ['key' => 'value', 'items' => [1, 2]]; - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => $rawResultData, - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toEqual($rawResultData); - expect($response->result)->not->toBeInstanceOf(Result::class); - expect($response->error)->toBeNull(); - expect($response->isSuccess())->toBeTrue(); -}); - -test('fromArray creates valid error response with ID', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => ['code' => 100, 'message' => 'Test error'], - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); - expect($response->error->code)->toBe(100); - expect($response->error->message)->toBe('Test error'); - expect($response->isError())->toBeTrue(); -}); - -test('fromArray creates valid error response with null ID', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => ['code' => -32700, 'message' => 'Parse error'], - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBeNull(); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); - expect($response->error->code)->toBe(-32700); - expect($response->error->message)->toBe('Parse error'); - expect($response->isError())->toBeTrue(); -}); - -test('fromArray throws exception for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'id' => 1, 'result' => []]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws exception for response with ID but missing result/error', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain either "result" or "error"'); -}); - -test('fromArray throws exception for response with null ID but missing error', function () { - $data = ['jsonrpc' => '2.0', 'id' => null]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); -}); - -test('fromArray throws exception for response with null ID and result present', function () { - $data = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc', 'error' => ['code' => -32700, 'message' => 'e']]; - $dataOnlyResult = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc']; - expect(fn () => Response::fromArray($dataOnlyResult)) - ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); -}); - -test('fromArray throws exception for invalid ID type', function () { - $data = ['jsonrpc' => '2.0', 'id' => [], 'result' => 'ok']; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "id" field type'); -}); - -test('fromArray throws exception for non-object error', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'error' => 'not an object']; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "error" field'); -}); - -test('fromArray throws exception for invalid error object structure', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'error' => ['code_missing' => -1]]; - expect(fn () => Response::fromArray($data)) - ->toThrow(ProtocolException::class, 'Invalid "error" object structure'); -}); - -// --- toArray / jsonSerialize Tests --- - -test('toArray returns correct structure for success response with raw result', function () { - $rawResult = ['some' => 'data']; - $response = new Response('2.0', 1, $rawResult); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => $rawResult, - ]); -}); - -test('toArray returns correct structure when using success factory (with Result obj)', function () { - $resultObject = new EmptyResult(); - $response = Response::success($resultObject, 1); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [], - ]); -}); - -test('toArray returns correct structure for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => ['code' => 100, 'message' => 'Test error'], - ]); -}); - -test('toArray returns correct structure for error response with null ID', function () { - $error = new Error(-32700, 'Parse error'); - $response = Response::error($error, null); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => ['code' => -32700, 'message' => 'Parse error'], - ]); -}); - -test('jsonSerialize returns same result as toArray', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - - $array = $response->toArray(); - $json = $response->jsonSerialize(); - - expect($json)->toBe($array); -}); diff --git a/tests/Unit/JsonRpc/ResultTest.php b/tests/Unit/JsonRpc/ResultTest.php deleted file mode 100644 index 67318da..0000000 --- a/tests/Unit/JsonRpc/ResultTest.php +++ /dev/null @@ -1,59 +0,0 @@ -toBeInstanceOf(Result::class); -}); - -test('Result implementation must define toArray method', function () { - $result = new EmptyResult(); - - expect($result->toArray())->toBe([]); -}); - -test('jsonSerialize calls toArray method', function () { - $result = new EmptyResult(); - - $serialized = $result->jsonSerialize(); - - expect($serialized)->toBe([]); -}); - -test('Result can be json encoded directly', function () { - $result = new EmptyResult(); - - $json = json_encode($result); - - expect($json)->toBe('[]'); -}); - -// Define a custom Result implementation for testing -class TestResult extends Result -{ - private array $data; - - public function __construct(array $data) - { - $this->data = $data; - } - - public function toArray(): array - { - return $this->data; - } -} - -test('Custom Result implementation works correctly', function () { - $data = ['key' => 'value', 'nested' => ['nested_key' => 'nested_value']]; - $result = new TestResult($data); - - expect($result->toArray())->toBe($data); - expect($result->jsonSerialize())->toBe($data); - expect(json_encode($result))->toBe('{"key":"value","nested":{"nested_key":"nested_value"}}'); -}); diff --git a/tests/Unit/JsonRpc/Results/EmptyResultTest.php b/tests/Unit/JsonRpc/Results/EmptyResultTest.php deleted file mode 100644 index 933232d..0000000 --- a/tests/Unit/JsonRpc/Results/EmptyResultTest.php +++ /dev/null @@ -1,40 +0,0 @@ -toBeInstanceOf(Result::class); -}); - -test('EmptyResult constructor takes no parameters', function () { - $result = new EmptyResult(); - - expect($result)->toBeInstanceOf(EmptyResult::class); -}); - -test('toArray returns an empty array', function () { - $result = new EmptyResult(); - - expect($result->toArray())->toBe([]); - expect($result->toArray())->toBeEmpty(); -}); - -test('jsonSerialize returns an empty array', function () { - $result = new EmptyResult(); - - expect($result->jsonSerialize())->toBe([]); - expect($result->jsonSerialize())->toBeEmpty(); -}); - -test('json_encode produces an empty JSON object', function () { - $result = new EmptyResult(); - - $json = json_encode($result); - - expect($json)->toBe('[]'); -}); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 17f320e..138b37c 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -4,245 +4,539 @@ use Mockery; use Mockery\MockInterface; +use PhpMcp\Schema\Implementation; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\ServerTransportInterface; +use PhpMcp\Server\Dispatcher; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; -use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\BatchResponse; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Notification\ResourceListChangedNotification; +use PhpMcp\Schema\Notification\ResourceUpdatedNotification; +use PhpMcp\Schema\Notification\ToolListChangedNotification; +use PhpMcp\Schema\Result\EmptyResult; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\RequestProcessor; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Session\SubscriptionManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; use function React\Async\await; use function React\Promise\resolve; +use function React\Promise\reject; + +const SESSION_ID = 'session-test-789'; +const SUPPORTED_VERSION_PROTO = Protocol::LATEST_PROTOCOL_VERSION; +const SERVER_NAME_PROTO = 'Test Protocol Server'; +const SERVER_VERSION_PROTO = '0.3.0'; + +function createRequest(string $method, array $params = [], string|int $id = 'req-proto-1'): Request +{ + return new Request('2.0', $id, $method, $params); +} + +function createNotification(string $method, array $params = []): Notification +{ + return new Notification('2.0', $method, $params); +} + +function expectErrorResponse(mixed $response, int $expectedCode, string|int|null $expectedId = 'req-proto-1'): void +{ + test()->expect($response)->toBeInstanceOf(Error::class); + test()->expect($response->id)->toBe($expectedId); + test()->expect($response->code)->toBe($expectedCode); + test()->expect($response->jsonrpc)->toBe('2.0'); +} + +function expectSuccessResponse(mixed $response, mixed $expectedResult, string|int|null $expectedId = 'req-proto-1'): void +{ + test()->expect($response)->toBeInstanceOf(Response::class); + test()->expect($response->id)->toBe($expectedId); + test()->expect($response->jsonrpc)->toBe('2.0'); + test()->expect($response->result)->toBe($expectedResult); +} + beforeEach(function () { - $this->requestProcessor = Mockery::mock(RequestProcessor::class); - $this->clientStateManager = Mockery::mock(ClientStateManager::class); + /** @var MockInterface&Registry $registry */ $this->registry = Mockery::mock(Registry::class); - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&SessionManager $sessionManager */ + $this->sessionManager = Mockery::mock(SessionManager::class); + /** @var MockInterface&Dispatcher $dispatcher */ + $this->dispatcher = Mockery::mock(Dispatcher::class); + /** @var MockInterface&SubscriptionManager $subscriptionManager */ + $this->subscriptionManager = Mockery::mock(SubscriptionManager::class); + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + /** @var MockInterface&ServerTransportInterface $transport */ + $this->transport = Mockery::mock(ServerTransportInterface::class); + /** @var MockInterface&SessionInterface $session */ + $this->session = Mockery::mock(SessionInterface::class); - /** @var MockInterface&CacheInterface */ + /** @var MockInterface&LoopInterface $loop */ + $loop = Mockery::mock(LoopInterface::class); + /** @var MockInterface&CacheInterface $cache */ $cache = Mockery::mock(CacheInterface::class); - $loop = Loop::get(); + /** @var MockInterface&ContainerInterface $container */ $container = Mockery::mock(ContainerInterface::class); $this->configuration = new Configuration( - 'Test Server', - '1.0.0', - Capabilities::forServer(), - $this->logger, - $loop, - $cache, - $container, + serverInfo: Implementation::make(SERVER_NAME_PROTO, SERVER_VERSION_PROTO), + capabilities: ServerCapabilities::make(), + logger: $this->logger, + loop: $loop, + cache: $cache, + container: $container ); - $this->transport = Mockery::mock(ServerTransportInterface::class); + $this->sessionManager->shouldReceive('getSession')->with(SESSION_ID)->andReturn($this->session)->byDefault(); + $this->sessionManager->shouldReceive('on')->withAnyArgs()->byDefault(); - $this->protocol = new Protocol( - $this->configuration, - $this->registry, - $this->clientStateManager, - $this->requestProcessor, - ); + $this->registry->shouldReceive('on')->withAnyArgs()->byDefault(); + + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(true)->byDefault(); + $this->session->shouldReceive('save')->byDefault(); $this->transport->shouldReceive('on')->withAnyArgs()->byDefault(); $this->transport->shouldReceive('removeListener')->withAnyArgs()->byDefault(); - $this->transport->shouldReceive('sendToClientAsync') + $this->transport->shouldReceive('sendMessage') ->withAnyArgs() ->andReturn(resolve(null)) ->byDefault(); + $this->protocol = new Protocol( + $this->configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + $this->protocol->bindTransport($this->transport); }); -afterEach(function () { - Mockery::close(); +it('listens to SessionManager events on construction', function () { + $this->sessionManager->shouldHaveReceived('on')->with('session_deleted', Mockery::type('callable')); }); -it('can handle a valid request', function () { - $clientId = 'client-req-1'; - $requestId = 123; - $method = 'test/method'; - $params = ['a' => 1]; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method, 'params' => $params]); - $expectedResponse = Response::success(new EmptyResult(), $requestId); - $expectedResponseJson = json_encode($expectedResponse->toArray()); +it('listens to Registry events on construction', function () { + $this->registry->shouldHaveReceived('on')->with('list_changed', Mockery::type('callable')); +}); - $this->requestProcessor->shouldReceive('process')->once()->with(Mockery::type(Request::class), $clientId)->andReturn($expectedResponse); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, $expectedResponseJson . "\n")->andReturn(resolve(null)); +it('binds to a transport and attaches listeners', function () { + $newTransport = Mockery::mock(ServerTransportInterface::class); + $newTransport->shouldReceive('on')->with('message', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('client_connected', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('client_disconnected', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('error', Mockery::type('callable'))->once(); - $this->protocol->handleRawMessage($rawJson, $clientId); - // Mockery verifies calls + $this->protocol->bindTransport($newTransport); }); -it('can handle a valid notification', function () { - $clientId = 'client-notif-1'; - $method = 'notify/event'; - $params = ['b' => 2]; - $rawJson = json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]); +it('unbinds from a previous transport when binding a new one', function () { + $this->transport->shouldReceive('removeListener')->times(4); - $this->requestProcessor->shouldReceive('process')->once()->with(Mockery::type(Notification::class), $clientId)->andReturn(null); - $this->transport->shouldNotReceive('sendToClientAsync'); + $newTransport = Mockery::mock(ServerTransportInterface::class); + $newTransport->shouldReceive('on')->times(4); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->bindTransport($newTransport); }); -it('sends a parse error response for invalid JSON', function () { - $clientId = 'client-err-parse'; - $rawJson = '{"jsonrpc":"2.0", "id":'; +it('unbinds transport and removes listeners', function () { + $this->transport->shouldReceive('removeListener')->with('message', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('client_connected', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('client_disconnected', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('error', Mockery::type('callable'))->once(); - $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->protocol->unbindTransport(); - $this->protocol->handleRawMessage($rawJson, $clientId); + $reflection = new \ReflectionClass($this->protocol); + $transportProp = $reflection->getProperty('transport'); + $transportProp->setAccessible(true); + expect($transportProp->getValue($this->protocol))->toBeNull(); }); -it('sends an invalid request error response for a request with missing method', function () { - $clientId = 'client-err-invalid'; - $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method +it('processes a valid Request message', function () { + $request = createRequest('test/method', ['param' => 1]); + $result = new EmptyResult(); + $expectedResponse = Response::make($request->id, $result); - $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->dispatcher->shouldReceive('handleRequest')->once() + ->with(Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'), $this->session) + ->andReturn($result); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(fn ($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -it('sends a mcp error response for a method not found', function () { - $clientId = 'client-err-mcp'; - $requestId = 789; - $method = 'nonexistent/method'; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method]); - $mcpException = McpServerException::methodNotFound($method); +it('processes a valid Notification message', function () { + $notification = createNotification('test/notify', ['data' => 'info']); + + $this->dispatcher->shouldReceive('handleNotification')->once() + ->with(Mockery::on(fn ($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session) + ->andReturnNull(); + + $this->transport->shouldNotReceive('sendMessage'); - $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->protocol->processMessage($notification, SESSION_ID); + $this->session->shouldHaveReceived('save'); +}); + +it('processes a BatchRequest with mixed requests and notifications', function () { + $req1 = createRequest('req/1', [], 'batch-id-1'); + $notif1 = createNotification('notif/1'); + $req2 = createRequest('req/2', [], 'batch-id-2'); + $batchRequest = new BatchRequest([$req1, $notif1, $req2]); + + $result1 = new EmptyResult(); + $result2 = new EmptyResult(); + + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'), $this->session)->andReturn($result1); + $this->dispatcher->shouldReceive('handleNotification')->once()->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session); + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'), $this->session)->andReturn($result2); + + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (BatchResponse $response) use ($req1, $req2, $result1, $result2) { + expect(count($response->items))->toBe(2); + expect($response->items[0]->id)->toBe($req1->id); + expect($response->items[0]->result)->toBe($result1); + expect($response->items[1]->id)->toBe($req2->id); + expect($response->items[1]->result)->toBe($result2); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->processMessage($batchRequest, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -it('sends an internal error response on processor exception', function () { - $clientId = 'client-err-internal'; - $requestId = 101; - $method = 'explode/now'; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method]); - $internalException = new \RuntimeException('Borked'); +it('processes a BatchRequest with only notifications and sends no response', function () { + $notif1 = createNotification('notif/only1'); + $notif2 = createNotification('notif/only2'); + $batchRequest = new BatchRequest([$notif1, $notif2]); - $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); + $this->dispatcher->shouldReceive('handleNotification')->twice(); + $this->transport->shouldNotReceive('sendMessage'); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->processMessage($batchRequest, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -// --- Test Event Handlers (Now call the handler directly) --- -it('logs info when a client connects', function () { - $clientId = 'client-connect-test'; - $this->logger->shouldReceive('info')->once()->with('Client connected', ['clientId' => $clientId]); - $this->protocol->handleClientConnected($clientId); // Call method directly +it('sends error response if session is not found', function () { + $request = createRequest('test/method'); + $this->sessionManager->shouldReceive('getSession')->with('unknown-client')->andReturn(null); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); + expect($error->message)->toContain('Invalid or expired session'); + return true; + }), 'unknown-client', ['status_code' => 404, 'is_initialize_request' => false]) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, 'unknown-client', ['is_initialize_request' => false]); + $this->session->shouldNotHaveReceived('save'); }); -it('cleans up state when a client disconnects', function () { - $clientId = 'client-disconnect-test'; - $reason = 'Connection closed by peer'; +it('sends error response if session is not initialized for non-initialize request', function () { + $request = createRequest('tools/list'); + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); - $this->logger->shouldReceive('info')->once()->with('Client disconnected', ['clientId' => $clientId, 'reason' => $reason]); - $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); + expect($error->message)->toContain('Client session not initialized'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleClientDisconnected($clientId, $reason); // Call method directly + $this->protocol->processMessage($request, SESSION_ID); }); -it('cleans up client state when a transport error occurs', function () { - $clientId = 'client-transporterror-test'; - $error = new \RuntimeException('Socket error'); +it('sends error response if capability for request method is disabled', function () { + $request = createRequest('tools/list'); + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: ServerCapabilities::make(tools: false), + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); - $this->logger->shouldReceive('error')->once()->with('Transport error for client', Mockery::any()); - $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + $protocol->bindTransport($this->transport); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); + expect($error->message)->toContain('Tools are not enabled'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleTransportError($error, $clientId); // Call method directly + $protocol->processMessage($request, SESSION_ID); }); -it('logs a general error when a transport error occurs', function () { - $error = new \RuntimeException('Listener setup failed'); +it('sends exceptions thrown while handling request as JSON-RPC error', function () { + $request = createRequest('fail/method'); + $exception = McpServerException::methodNotFound('fail/method'); + + $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); - $this->logger->shouldReceive('error')->once()->with('General transport error', Mockery::any()); - $this->clientStateManager->shouldNotReceive('cleanupClient'); + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); + expect($error->message)->toContain('Method not found'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleTransportError($error, null); // Call method directly + $this->protocol->processMessage($request, SESSION_ID); + + + $request = createRequest('explode/method'); + $exception = new \RuntimeException('Something bad happened'); + + $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INTERNAL_ERROR, $request->id); + expect($error->message)->toContain('Internal error processing method explode/method'); + expect($error->data)->toBe('Something bad happened'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); }); -it('attaches listeners when binding a new transport', function () { - $newTransport = Mockery::mock(ServerTransportInterface::class); - $newTransport->shouldReceive('on')->times(4); - $this->protocol->bindTransport($newTransport); - expect(true)->toBeTrue(); +it('sends a notification successfully', function () { + $notification = createNotification('event/occurred', ['value' => true]); + + $this->transport->shouldReceive('sendMessage')->once() + ->with($notification, SESSION_ID, []) + ->andReturn(resolve(null)); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + await($promise); }); -it('removes listeners when unbinding a transport', function () { - $this->transport->shouldReceive('on')->times(4); - $this->protocol->bindTransport($this->transport); - $this->transport->shouldReceive('removeListener')->times(4); +it('rejects sending notification if transport not bound', function () { $this->protocol->unbindTransport(); - expect(true)->toBeTrue(); + $notification = createNotification('event/occurred'); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + + await($promise->then(null, function (McpServerException $e) { + expect($e->getMessage())->toContain('Transport not bound'); + })); }); -it('unbinds previous transport when binding a new one', function () { - $transport1 = Mockery::mock(ServerTransportInterface::class); - $transport2 = Mockery::mock(ServerTransportInterface::class); - $transport1->shouldReceive('on')->times(4); - $this->protocol->bindTransport($transport1); - $transport1->shouldReceive('removeListener')->times(4); - $transport2->shouldReceive('on')->times(4); - $this->protocol->bindTransport($transport2); - expect(true)->toBeTrue(); +it('rejects sending notification if transport send fails', function () { + $notification = createNotification('event/occurred'); + $transportException = new \PhpMcp\Server\Exception\TransportException('Send failed'); + $this->transport->shouldReceive('sendMessage')->once()->andReturn(reject($transportException)); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + await($promise->then(null, function (McpServerException $e) use ($transportException) { + expect($e->getMessage())->toContain('Failed to send notification: Send failed'); + expect($e->getPrevious())->toBe($transportException); + })); }); -it('encodes and sends a notification', function () { - $clientId = 'client-send-notif'; - $notification = new Notification('2.0', 'state/update', ['value' => true]); - $expectedJson = json_encode($notification->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $expectedFrame = $expectedJson . "\n"; +it('notifies resource updated to subscribers', function () { + $uri = 'test://resource/123'; + $subscribers = ['client-sub-1', 'client-sub-2']; + $this->subscriptionManager->shouldReceive('getSubscribers')->with($uri)->andReturn($subscribers); + + $expectedNotification = ResourceUpdatedNotification::make($uri); - $this->transport->shouldReceive('sendToClientAsync') - ->once() - ->with($clientId, $expectedFrame) + $this->transport->shouldReceive('sendMessage')->twice() + ->with(Mockery::on(function (Notification $notification) use ($expectedNotification) { + expect($notification->method)->toBe($expectedNotification->method); + expect($notification->params)->toBe($expectedNotification->params); + return true; + }), Mockery::anyOf(...$subscribers), []) ->andReturn(resolve(null)); - $promise = $this->protocol->sendNotification($clientId, $notification); - await($promise); + $this->protocol->notifyResourceUpdated($uri); +}); - expect(true)->toBeTrue(); -})->group('usesLoop'); +it('handles client connected event', function () { + $this->logger->shouldReceive('info')->with('Client connected', ['sessionId' => SESSION_ID])->once(); + $this->sessionManager->shouldReceive('createSession')->with(SESSION_ID)->once(); -it('rejects on encoding error when sending a notification', function () { - $clientId = 'client-send-notif-err'; - $resource = fopen('php://memory', 'r'); // Unencodable resource - $notification = new Notification('2.0', 'bad/data', ['res' => $resource]); + $this->protocol->handleClientConnected(SESSION_ID); +}); - $this->transport->shouldNotReceive('sendToClientAsync'); +it('handles client disconnected event', function () { + $reason = 'Connection closed'; + $this->logger->shouldReceive('info')->with('Client disconnected', ['clientId' => SESSION_ID, 'reason' => $reason])->once(); + $this->sessionManager->shouldReceive('deleteSession')->with(SESSION_ID)->once(); - // Act - $promise = $this->protocol->sendNotification($clientId, $notification); - await($promise); + $this->protocol->handleClientDisconnected(SESSION_ID, $reason); +}); - if (is_resource($resource)) { - fclose($resource); - } -})->group('usesLoop')->throws(McpServerException::class, 'Failed to encode notification'); +it('handles transport error event with client ID', function () { + $error = new \RuntimeException('Socket error'); + $this->logger->shouldReceive('error') + ->with('Transport error for client', ['error' => 'Socket error', 'exception_class' => \RuntimeException::class, 'clientId' => SESSION_ID]) + ->once(); -it('rejects if transport not bound when sending a notification', function () { - $this->protocol->unbindTransport(); - $notification = new Notification('2.0', 'test'); + $this->protocol->handleTransportError($error, SESSION_ID); +}); - $promise = $this->protocol->sendNotification('client-id', $notification); - await($promise); -})->throws(McpServerException::class, 'Transport not bound'); +it('handles transport error event without client ID', function () { + $error = new \RuntimeException('Listener setup failed'); + $this->logger->shouldReceive('error') + ->with('General transport error', ['error' => 'Listener setup failed', 'exception_class' => \RuntimeException::class]) + ->once(); + + $this->protocol->handleTransportError($error, null); +}); + +it('handles list changed event from registry and notifies subscribers', function (string $listType, string $expectedNotificationClass) { + $listChangeUri = "mcp://changes/{$listType}"; + $subscribers = ['client-sub-A', 'client-sub-B']; + + $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); + $capabilities = ServerCapabilities::make( + toolsListChanged: true, + resourcesListChanged: true, + promptsListChanged: true, + ); + + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $capabilities, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + + $this->transport->shouldReceive('sendMessage') + ->with(Mockery::type($expectedNotificationClass), Mockery::anyOf(...$subscribers), []) + ->times(count($subscribers)) + ->andReturn(resolve(null)); + + $protocol->handleListChanged($listType); +})->with([ + 'tools' => ['tools', ToolListChangedNotification::class], + 'resources' => ['resources', ResourceListChangedNotification::class], +]); + +it('does not send list changed notification if capability is disabled', function (string $listType) { + $listChangeUri = "mcp://changes/{$listType}"; + $subscribers = ['client-sub-A']; + $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); + + $caps = ServerCapabilities::make( + toolsListChanged: $listType !== 'tools', + resourcesListChanged: $listType !== 'resources', + promptsListChanged: $listType !== 'prompts', + ); + + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $caps, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + $this->transport->shouldNotReceive('sendMessage'); +})->with(['tools', 'resources', 'prompts',]); + + +it('allows initialize request when session not initialized', function () { + $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION_PROTO]); + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); + + $this->dispatcher->shouldReceive('handleRequest')->once() + ->with(Mockery::type(Request::class), $this->session) + ->andReturn(new EmptyResult()); + + $this->transport->shouldReceive('sendMessage')->once() + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); +}); + +it('allows initialize and ping regardless of capabilities', function (string $method) { + $request = createRequest($method); + $capabilities = ServerCapabilities::make( + tools: false, + resources: false, + prompts: false, + logging: false, + ); + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $capabilities, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + + $this->dispatcher->shouldReceive('handleRequest')->once()->andReturn(new EmptyResult()); + $this->transport->shouldReceive('sendMessage')->once() + ->andReturn(resolve(null)); + + $protocol->processMessage($request, SESSION_ID); +})->with(['initialize', 'ping']); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 9e1b6eb..8be6ee6 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -4,49 +4,54 @@ use Mockery; use Mockery\MockInterface; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; -use PhpMcp\Server\JsonRpc\Notification; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredResource; +use PhpMcp\Server\Elements\RegisteredResourceTemplate; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Registry; -use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; -const DISCOVERED_CACHE_KEY = 'mcp_server_discovered_elements'; +const DISCOVERED_CACHE_KEY_REG = 'mcp_server_discovered_elements'; -function createTestTool(string $name = 'test-tool'): ToolDefinition +function createTestToolSchema(string $name = 'test-tool'): Tool { - return new ToolDefinition('TestClass', 'toolMethod', $name, 'Desc ' . $name, ['type' => 'object']); + return Tool::make(name: $name, inputSchema: ['type' => 'object'], description: 'Desc ' . $name); } -function createTestResource(string $uri = 'test://res', string $name = 'test-res'): ResourceDefinition + +function createTestResourceSchema(string $uri = 'test://res', string $name = 'test-res'): Resource { - return new ResourceDefinition('TestClass', 'resourceMethod', $uri, $name, 'Desc ' . $name, 'text/plain', 100, []); + return Resource::make(uri: $uri, name: $name, description: 'Desc ' . $name, mimeType: 'text/plain'); } -function createTestPrompt(string $name = 'test-prompt'): PromptDefinition + +function createTestPromptSchema(string $name = 'test-prompt'): Prompt { - return new PromptDefinition('TestClass', 'promptMethod', $name, 'Desc ' . $name, []); + return Prompt::make(name: $name, description: 'Desc ' . $name, arguments: []); } -function createTestTemplate(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplateDefinition + +function createTestTemplateSchema(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplate { - return new ResourceTemplateDefinition('TestClass', 'templateMethod', $uriTemplate, $name, 'Desc ' . $name, 'application/json', []); + return ResourceTemplate::make(uriTemplate: $uriTemplate, name: $name, description: 'Desc ' . $name, mimeType: 'application/json'); } beforeEach(function () { - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - /** @var MockInterface&CacheInterface */ + /** @var MockInterface&CacheInterface $cache */ $this->cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&ClientStateManager */ - $this->clientStateManager = Mockery::mock(ClientStateManager::class)->shouldIgnoreMissing(); - $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY)->andReturn(null)->byDefault(); - $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY, Mockery::any())->andReturn(true)->byDefault(); - $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY)->andReturn(true)->byDefault(); + // Default cache behavior: miss on get, success on set/delete + $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(null)->byDefault(); + $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY_REG, Mockery::any())->andReturn(true)->byDefault(); + $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(true)->byDefault(); - $this->registry = new Registry($this->logger, $this->cache, $this->clientStateManager); - $this->registryNoCache = new Registry($this->logger, null, $this->clientStateManager); + $this->registry = new Registry($this->logger, $this->cache); + $this->registryNoCache = new Registry($this->logger, null); }); function getRegistryProperty(Registry $reg, string $propName) @@ -54,350 +59,403 @@ function getRegistryProperty(Registry $reg, string $propName) $reflector = new \ReflectionClass($reg); $prop = $reflector->getProperty($propName); $prop->setAccessible(true); - return $prop->getValue($reg); } -// --- Basic Registration & Retrieval --- +it('registers manual tool correctly', function () { + $toolSchema = createTestToolSchema('manual-tool-1'); + $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', true); -it('registers manual tool and marks as manual', function () { - // Arrange - $tool = createTestTool('manual-tool-1'); + $registeredTool = $this->registry->getTool('manual-tool-1'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->schema)->toBe($toolSchema) + ->and($registeredTool->isManual)->toBeTrue(); + expect($this->registry->getTools())->toHaveKey('manual-tool-1'); +}); - // Act - $this->registry->registerTool($tool, true); // Register as manual +it('registers discovered tool correctly', function () { + $toolSchema = createTestToolSchema('discovered-tool-1'); + $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', false); - // Assert - expect($this->registry->findTool('manual-tool-1'))->toBe($tool); - expect($this->registry->allTools())->toHaveCount(1); - expect(getRegistryProperty($this->registry, 'manualToolNames'))->toHaveKey('manual-tool-1'); + $registeredTool = $this->registry->getTool('discovered-tool-1'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->schema)->toBe($toolSchema) + ->and($registeredTool->isManual)->toBeFalse(); }); -it('registers discovered tool', function () { - // Arrange - $tool = createTestTool('discovered-tool-1'); +it('registers manual resource correctly', function () { + $resourceSchema = createTestResourceSchema('manual://res/1'); + $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', true); + + $registeredResource = $this->registry->getResource('manual://res/1'); + expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) + ->and($registeredResource->schema)->toBe($resourceSchema) + ->and($registeredResource->isManual)->toBeTrue(); + expect($this->registry->getResources())->toHaveKey('manual://res/1'); +}); - // Act - $this->registry->registerTool($tool, false); // Register as discovered +it('registers discovered resource correctly', function () { + $resourceSchema = createTestResourceSchema('discovered://res/1'); + $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', false); - // Assert - expect($this->registry->findTool('discovered-tool-1'))->toBe($tool); - expect($this->registry->allTools())->toHaveCount(1); - expect(getRegistryProperty($this->registry, 'manualToolNames'))->toBeEmpty(); + $registeredResource = $this->registry->getResource('discovered://res/1'); + expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) + ->and($registeredResource->schema)->toBe($resourceSchema) + ->and($registeredResource->isManual)->toBeFalse(); }); -it('registers manual resource and marks as manual', function () { - // Arrange - $res = createTestResource('manual://res/1'); +it('registers manual prompt correctly', function () { + $promptSchema = createTestPromptSchema('manual-prompt-1'); + $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], true); + + $registeredPrompt = $this->registry->getPrompt('manual-prompt-1'); + expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($registeredPrompt->schema)->toBe($promptSchema) + ->and($registeredPrompt->isManual)->toBeTrue(); + expect($this->registry->getPrompts())->toHaveKey('manual-prompt-1'); +}); - // Act - $this->registry->registerResource($res, true); +it('registers discovered prompt correctly', function () { + $promptSchema = createTestPromptSchema('discovered-prompt-1'); + $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], false); - // Assert - expect($this->registry->findResourceByUri('manual://res/1'))->toBe($res); - expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toHaveKey('manual://res/1'); + $registeredPrompt = $this->registry->getPrompt('discovered-prompt-1'); + expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($registeredPrompt->schema)->toBe($promptSchema) + ->and($registeredPrompt->isManual)->toBeFalse(); }); -it('registers discovered resource', function () { - // Arrange - $res = createTestResource('discovered://res/1'); +it('registers manual resource template correctly', function () { + $templateSchema = createTestTemplateSchema('manual://tmpl/{id}'); + $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], true); + + $registeredTemplate = $this->registry->getResourceTemplate('manual://tmpl/{id}'); + expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($registeredTemplate->schema)->toBe($templateSchema) + ->and($registeredTemplate->isManual)->toBeTrue(); + expect($this->registry->getResourceTemplates())->toHaveKey('manual://tmpl/{id}'); +}); - // Act - $this->registry->registerResource($res, false); +it('registers discovered resource template correctly', function () { + $templateSchema = createTestTemplateSchema('discovered://tmpl/{id}'); + $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], false); - // Assert - expect($this->registry->findResourceByUri('discovered://res/1'))->toBe($res); - expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toBeEmpty(); + $registeredTemplate = $this->registry->getResourceTemplate('discovered://tmpl/{id}'); + expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($registeredTemplate->schema)->toBe($templateSchema) + ->and($registeredTemplate->isManual)->toBeFalse(); }); -it('registers manual prompt and marks as manual', function () { - // Arrange - $prompt = createTestPrompt('manual-prompt'); +test('getResource finds exact URI match before template match', function () { + $exactResourceSchema = createTestResourceSchema('test://item/exact'); + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); - // Act - $this->registry->registerPrompt($prompt, true); + $this->registry->registerResource($exactResourceSchema, 'H', 'm'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); - // Assert - expect($this->registry->findPrompt('manual-prompt'))->toBe($prompt); - expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toHaveKey('manual-prompt'); + $found = $this->registry->getResource('test://item/exact'); + expect($found)->toBeInstanceOf(RegisteredResource::class) + ->and($found->schema->uri)->toBe('test://item/exact'); }); -it('registers discovered prompt', function () { - // Arrange - $prompt = createTestPrompt('discovered-prompt'); +test('getResource finds template match if no exact URI match', function () { + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); + + $found = $this->registry->getResource('test://item/123'); + expect($found)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($found->schema->uriTemplate)->toBe('test://item/{itemId}'); +}); - // Act - $this->registry->registerPrompt($prompt, false); +test('getResource returns null if no match and templates excluded', function () { + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); - // Assert - expect($this->registry->findPrompt('discovered-prompt'))->toBe($prompt); - expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toBeEmpty(); + $found = $this->registry->getResource('test://item/123', false); + expect($found)->toBeNull(); }); -it('registers manual template and marks as manual', function () { - // Arrange - $template = createTestTemplate('manual://tmpl/{id}'); +test('getResource returns null if no match at all', function () { + $found = $this->registry->getResource('nonexistent://uri'); + expect($found)->toBeNull(); +}); - // Act - $this->registry->registerResourceTemplate($template, true); +it('hasElements returns true if any manual elements exist', function () { + expect($this->registry->hasElements())->toBeFalse(); + $this->registry->registerTool(createTestToolSchema('manual-only'), 'H', 'm', true); + expect($this->registry->hasElements())->toBeTrue(); +}); - // Assert - expect($this->registry->findResourceTemplateByUri('manual://tmpl/123')['definition'] ?? null)->toBe($template); - expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toHaveKey('manual://tmpl/{id}'); +it('hasElements returns true if any discovered elements exist', function () { + expect($this->registry->hasElements())->toBeFalse(); + $this->registry->registerTool(createTestToolSchema('discovered-only'), 'H', 'm', false); + expect($this->registry->hasElements())->toBeTrue(); }); -it('registers discovered template', function () { - // Arrange - $template = createTestTemplate('discovered://tmpl/{id}'); +it('overrides existing discovered element with manual registration', function (string $type) { + $nameOrUri = $type === 'resource' ? 'conflict://res' : 'conflict-element'; + $templateUri = 'conflict://tmpl/{id}'; + + $discoveredSchema = match ($type) { + 'tool' => createTestToolSchema($nameOrUri), + 'resource' => createTestResourceSchema($nameOrUri), + 'prompt' => createTestPromptSchema($nameOrUri), + 'template' => createTestTemplateSchema($templateUri), + }; + $manualSchema = clone $discoveredSchema; + + match ($type) { + 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), + 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + }; + + match ($type) { + 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), + 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), + 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + }; + + $registeredElement = match ($type) { + 'tool' => $this->registry->getTool($nameOrUri), + 'resource' => $this->registry->getResource($nameOrUri), + 'prompt' => $this->registry->getPrompt($nameOrUri), + 'template' => $this->registry->getResourceTemplate($templateUri), + }; + + expect($registeredElement->schema)->toBe($manualSchema); + expect($registeredElement->isManual)->toBeTrue(); +})->with(['tool', 'resource', 'prompt', 'template']); + +it('does not override existing manual element with discovered registration', function (string $type) { + $nameOrUri = $type === 'resource' ? 'manual-priority://res' : 'manual-priority-element'; + $templateUri = 'manual-priority://tmpl/{id}'; + + $manualSchema = match ($type) { + 'tool' => createTestToolSchema($nameOrUri), + 'resource' => createTestResourceSchema($nameOrUri), + 'prompt' => createTestPromptSchema($nameOrUri), + 'template' => createTestTemplateSchema($templateUri), + }; + $discoveredSchema = clone $manualSchema; + + match ($type) { + 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), + 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), + 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + }; + + match ($type) { + 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), + 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + }; + + $registeredElement = match ($type) { + 'tool' => $this->registry->getTool($nameOrUri), + 'resource' => $this->registry->getResource($nameOrUri), + 'prompt' => $this->registry->getPrompt($nameOrUri), + 'template' => $this->registry->getResourceTemplate($templateUri), + }; + + expect($registeredElement->schema)->toBe($manualSchema); + expect($registeredElement->isManual)->toBeTrue(); +})->with(['tool', 'resource', 'prompt', 'template']); + + +it('loads discovered elements from cache correctly on construction', function () { + $toolSchema1 = createTestToolSchema('cached-tool-1'); + $resourceSchema1 = createTestResourceSchema('cached://res/1'); + $cachedData = [ + 'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, 'H', 'm'))], + 'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, 'H', 'm'))], + 'prompts' => [], + 'resourceTemplates' => [], + ]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData); - // Act - $this->registry->registerResourceTemplate($template, false); + $registry = new Registry($this->logger, $this->cache); - // Assert - expect($this->registry->findResourceTemplateByUri('discovered://tmpl/abc')['definition'] ?? null)->toBe($template); - expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toBeEmpty(); + expect($registry->getTool('cached-tool-1'))->toBeInstanceOf(RegisteredTool::class) + ->and($registry->getTool('cached-tool-1')->isManual)->toBeFalse(); + expect($registry->getResource('cached://res/1'))->toBeInstanceOf(RegisteredResource::class) + ->and($registry->getResource('cached://res/1')->isManual)->toBeFalse(); + expect($registry->hasElements())->toBeTrue(); }); -test('hasElements returns true if manual elements exist', function () { - // Arrange - expect($this->registry->hasElements())->toBeFalse(); // Starts empty +it('skips loading cached element if manual one with same key is registered later', function () { + $conflictName = 'conflict-tool'; + $cachedToolSchema = createTestToolSchema($conflictName); + $manualToolSchema = createTestToolSchema($conflictName); // Different instance - // Act - $this->registry->registerTool(createTestTool('manual-only'), true); + $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, 'H', 'm'))]]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData); - // Assert - expect($this->registry->hasElements())->toBeTrue(); -}); + $registry = new Registry($this->logger, $this->cache); -test('hasElements returns true if discovered elements exist', function () { - // Arrange - expect($this->registry->hasElements())->toBeFalse(); + expect($registry->getTool($conflictName)->schema->name)->toBe($cachedToolSchema->name); + expect($registry->getTool($conflictName)->isManual)->toBeFalse(); - // Act - $this->registry->registerTool(createTestTool('discovered-only'), false); + $registry->registerTool($manualToolSchema, 'H', 'm', true); - // Assert - expect($this->registry->hasElements())->toBeTrue(); + expect($registry->getTool($conflictName)->schema->name)->toBe($manualToolSchema->name); + expect($registry->getTool($conflictName)->isManual)->toBeTrue(); }); -// --- Registration Precedence --- -it('overrides existing discovered element with manual registration', function () { - // Arrange - $toolName = 'override-test'; - $discoveredTool = createTestTool($toolName); // Version 1 (Discovered) - $manualTool = createTestTool($toolName); // Version 2 (Manual) - different instance +it('saves only non-manual elements to cache', function () { + $manualToolSchema = createTestToolSchema('manual-save'); + $discoveredToolSchema = createTestToolSchema('discovered-save'); + $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, 'H', 'm', false); - // Act - $this->registry->registerTool($discoveredTool, false); // Register discovered first + $this->registry->registerTool($manualToolSchema, 'H', 'm', true); + $this->registry->registerTool($discoveredToolSchema, 'H', 'm', false); - // Assert - expect($this->registry->findTool($toolName))->toBe($discoveredTool); + $expectedCachedData = [ + 'tools' => ['discovered-save' => json_encode($expectedRegisteredDiscoveredTool)], + 'resources' => [], + 'prompts' => [], + 'resourceTemplates' => [], + ]; - $this->logger->shouldReceive('warning')->with(Mockery::pattern("/Replacing existing discovered tool '{$toolName}' with manual/"))->once(); + $this->cache->shouldReceive('set')->once() + ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData) + ->andReturn(true); - // Act - $this->registry->registerTool($manualTool, true); + $result = $this->registry->save(); + expect($result)->toBeTrue(); +}); - // Assert manual version is now stored - expect($this->registry->findTool($toolName))->toBe($manualTool); - // Assert it's marked as manual - $reflector = new \ReflectionClass($this->registry); - $manualNamesProp = $reflector->getProperty('manualToolNames'); - $manualNamesProp->setAccessible(true); - expect($manualNamesProp->getValue($this->registry))->toHaveKey($toolName); +it('does not attempt to save to cache if cache is null', function () { + $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), 'H', 'm', false); + $result = $this->registryNoCache->save(); + expect($result)->toBeFalse(); }); -it('does not override existing manual element with discovered registration', function () { - // Arrange - $toolName = 'manual-priority'; - $manualTool = createTestTool($toolName); // Version 1 (Manual) - $discoveredTool = createTestTool($toolName); // Version 2 (Discovered) +it('handles invalid (non-array) data from cache gracefully during load', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn('this is not an array'); + $this->logger->shouldReceive('warning')->with(Mockery::pattern('/Invalid or missing data found in registry cache/'), Mockery::any())->once(); - // Act - $this->registry->registerTool($manualTool, true); // Register manual first + $registry = new Registry($this->logger, $this->cache); - // Assert - expect($this->registry->findTool($toolName))->toBe($manualTool); + expect($registry->hasElements())->toBeFalse(); +}); - // Expect debug log when ignoring - $this->logger->shouldReceive('debug')->with(Mockery::pattern("/Ignoring discovered tool '{$toolName}' as it conflicts/"))->once(); +it('handles cache unserialization errors gracefully during load', function () { + $badSerializedData = ['tools' => ['bad-tool' => 'not a serialized object']]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($badSerializedData); - // Attempt to register discovered version - $this->registry->registerTool($discoveredTool, false); + $registry = new Registry($this->logger, $this->cache); - // Assert manual version is STILL stored - expect($this->registry->findTool($toolName))->toBe($manualTool); - // Assert it's still marked as manual - $reflector = new \ReflectionClass($this->registry); - $manualNamesProp = $reflector->getProperty('manualToolNames'); - $manualNamesProp->setAccessible(true); - expect($manualNamesProp->getValue($this->registry))->toHaveKey($toolName); + expect($registry->hasElements())->toBeFalse(); }); -// --- Caching Logic --- +it('handles cache general exceptions during load gracefully', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException('Cache unavailable')); -it('loads discovered elements from cache correctly', function () { - // Arrange - $cachedTool = createTestTool('cached-tool-constructor'); - $cachedResource = createTestResource('cached://res-constructor'); - $cachedData = [ - 'tools' => [$cachedTool->getName() => $cachedTool], - 'resources' => [$cachedResource->getUri() => $cachedResource], - 'prompts' => [], - 'resourceTemplates' => [], - ]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); - - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); - - // Assertions - expect($registry->findTool('cached-tool-constructor'))->toBeInstanceOf(ToolDefinition::class); - expect($registry->findResourceByUri('cached://res-constructor'))->toBeInstanceOf(ResourceDefinition::class); - expect($registry->discoveryRanOrCached())->toBeTrue(); - // Check nothing was marked as manual - expect(getRegistryProperty($registry, 'manualToolNames'))->toBeEmpty(); - expect(getRegistryProperty($registry, 'manualResourceUris'))->toBeEmpty(); + $registry = new Registry($this->logger, $this->cache); + + expect($registry->hasElements())->toBeFalse(); }); -it('skips cache items conflicting with LATER manual registration', function () { - // Arrange - $conflictName = 'conflict-tool'; - $manualTool = createTestTool($conflictName); - $cachedToolData = createTestTool($conflictName); // Tool with same name in cache +it('handles cache InvalidArgumentException during load gracefully', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); + + $registry = new Registry($this->logger, $this->cache); + expect($registry->hasElements())->toBeFalse(); +}); - $cachedData = ['tools' => [$conflictName => $cachedToolData]]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); +it('clears non-manual elements and deletes cache file', function () { + $this->registry->registerTool(createTestToolSchema('manual-clear'), 'H', 'm', true); + $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); - // Assert the cached item IS initially loaded (because manual isn't there *yet*) - $toolBeforeManual = $registry->findTool($conflictName); - expect($toolBeforeManual)->toBeInstanceOf(ToolDefinition::class); - expect(getRegistryProperty($registry, 'manualToolNames'))->toBeEmpty(); // Not manual yet + $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn(true); - // NOW, register the manual one (simulating builder doing it after constructing Registry) - $this->logger->shouldReceive('warning')->with(Mockery::pattern("/Replacing existing discovered tool '{$conflictName}'/"))->once(); // Expect replace warning - $registry->registerTool($manualTool, true); + $this->registry->clear(); - // Assert manual version is now present and marked correctly - expect($registry->findTool($conflictName))->toBe($manualTool); - expect(getRegistryProperty($registry, 'manualToolNames'))->toHaveKey($conflictName); + expect($this->registry->getTool('manual-clear'))->not->toBeNull(); + expect($this->registry->getTool('discovered-clear'))->toBeNull(); }); -it('saves only non-manual elements to cache', function () { - // Arrange - $manualTool = createTestTool('manual-save'); - $discoveredTool = createTestTool('discovered-save'); - $expectedCachedData = [ - 'tools' => ['discovered-save' => $discoveredTool], - 'resources' => [], - 'prompts' => [], - 'resourceTemplates' => [], - ]; - // Act - $this->registry->registerTool($manualTool, true); - $this->registry->registerTool($discoveredTool, false); +it('handles cache exceptions during clear gracefully', function () { + $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); + $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException("Cache delete failed")); - $this->cache->shouldReceive('set')->once() - ->with(DISCOVERED_CACHE_KEY, $expectedCachedData) // Expect EXACT filtered data - ->andReturn(true); + $this->registry->clear(); - $result = $this->registry->saveDiscoveredElementsToCache(); - expect($result)->toBeTrue(); + expect($this->registry->getTool('discovered-clear'))->toBeNull(); }); -it('ignores non-array cache data', function () { - // Arrange - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn('invalid string data'); +it('emits list_changed event when a new tool is registered', function () { + $emitted = null; + $this->registry->on('list_changed', function ($listType) use (&$emitted) { + $emitted = $listType; + }); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); + $this->registry->registerTool(createTestToolSchema('notifying-tool'), 'H', 'm'); + expect($emitted)->toBe('tools'); +}); + +it('emits list_changed event when a new resource is registered', function () { + $emitted = null; + $this->registry->on('list_changed', function ($listType) use (&$emitted) { + $emitted = $listType; + }); - // Assert - expect($registry->discoveryRanOrCached())->toBeFalse(); // Marked loaded - expect($registry->hasElements())->toBeFalse(); // But empty + $this->registry->registerResource(createTestResourceSchema('notify://res'), 'H', 'm'); + expect($emitted)->toBe('resources'); }); -it('ignores cache on hydration error', function () { - // Arrange - $invalidToolData = ['toolName' => 'good-name', 'description' => 'good-desc', 'inputSchema' => 'not-an-array', 'className' => 'TestClass', 'methodName' => 'toolMethod']; // Invalid schema - $cachedData = ['tools' => ['good-name' => $invalidToolData]]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); +it('does not emit list_changed event if notifications are disabled', function () { + $this->registry->disableNotifications(); + $emitted = false; + $this->registry->on('list_changed', function () use (&$emitted) { + $emitted = true; + }); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); + $this->registry->registerTool(createTestToolSchema('silent-tool'), 'H', 'm'); + expect($emitted)->toBeFalse(); - // Assert - expect($registry->discoveryRanOrCached())->toBeFalse(); - expect($registry->hasElements())->toBeFalse(); // Hydration failed + $this->registry->enableNotifications(); }); -it('removes only non-manual elements and optionally clears cache', function ($deleteCacheFile) { - // Arrange - $manualTool = createTestTool('manual-clear'); - $discoveredTool = createTestTool('discovered-clear'); - $manualResource = createTestResource('manual://clear'); - $discoveredResource = createTestResource('discovered://clear'); - - // Act - $this->registry->registerTool($manualTool, true); - $this->registry->registerTool($discoveredTool, false); - $this->registry->registerResource($manualResource, true); - $this->registry->registerResource($discoveredResource, false); - - // Assert - expect($this->registry->allTools())->toHaveCount(2); - expect($this->registry->allResources())->toHaveCount(2); - - if ($deleteCacheFile) { - $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY)->once()->andReturn(true); - } else { - $this->cache->shouldNotReceive('delete'); - } - - // Act - $this->registry->clearDiscoveredElements($deleteCacheFile); - - // Assert: Manual elements remain, discovered are gone - expect($this->registry->findTool('manual-clear'))->toBe($manualTool); - expect($this->registry->findTool('discovered-clear'))->toBeNull(); - expect($this->registry->findResourceByUri('manual://clear'))->toBe($manualResource); - expect($this->registry->findResourceByUri('discovered://clear'))->toBeNull(); - expect($this->registry->allTools())->toHaveCount(1); - expect($this->registry->allResources())->toHaveCount(1); - expect($this->registry->discoveryRanOrCached())->toBeFalse(); // Flag should be reset - -})->with([ - 'Delete Cache File' => [true], - 'Keep Cache File' => [false], -]); - -// --- Notifier Tests --- - -it('sends notifications when tools, resources, and prompts are registered', function () { - // Arrange - $tool = createTestTool('notify-tool'); - $resource = createTestResource('notify://res'); - $prompt = createTestPrompt('notify-prompt'); - - $this->clientStateManager->shouldReceive('queueMessageForAll')->times(3)->with(Mockery::type('string')); - - // Act - $this->registry->registerTool($tool); - $this->registry->registerResource($resource); - $this->registry->registerPrompt($prompt); +it('computes different hashes for different collections', function () { + $method = new \ReflectionMethod(Registry::class, 'computeHash'); + $method->setAccessible(true); + + $hash1 = $method->invoke($this->registry, ['a' => 1, 'b' => 2]); + $hash2 = $method->invoke($this->registry, ['b' => 2, 'a' => 1]); + $hash3 = $method->invoke($this->registry, ['a' => 1, 'c' => 3]); + + expect($hash1)->toBeString()->not->toBeEmpty(); + expect($hash2)->toBe($hash1); + expect($hash3)->not->toBe($hash1); + expect($method->invoke($this->registry, []))->toBe(''); }); -it('does not send notifications when notifications are disabled', function () { - // Arrange - $this->registry->disableNotifications(); +it('recomputes and emits list_changed only when content actually changes', function () { + $tool1 = createTestToolSchema('tool1'); + $tool2 = createTestToolSchema('tool2'); + $callCount = 0; + + $this->registry->on('list_changed', function ($listType) use (&$callCount) { + if ($listType === 'tools') { + $callCount++; + } + }); + + $this->registry->registerTool($tool1, 'H', 'm1'); + expect($callCount)->toBe(1); - $this->clientStateManager->shouldNotReceive('queueMessageForAll'); + $this->registry->registerTool($tool1, 'H', 'm1'); + expect($callCount)->toBe(1); - // Act - $this->registry->registerTool(createTestTool('notify-tool')); + $this->registry->registerTool($tool2, 'H', 'm2'); + expect($callCount)->toBe(2); }); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index 2649fa5..d5884f5 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -3,271 +3,395 @@ namespace PhpMcp\Server\Tests\Unit; use Mockery; -use PhpMcp\Server\Attributes\McpTool; -use PhpMcp\Server\Configuration; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\ServerCapabilities; +use PhpMcp\Server\Attributes\CompletionProvider; +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionHandlerInterface; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\ConfigurationException; -use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Server\Protocol; +use PhpMcp\Server\Registry; use PhpMcp\Server\Server; use PhpMcp\Server\ServerBuilder; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\CacheSessionHandler; +use PhpMcp\Server\Session\SessionManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; use ReflectionClass; -class DummyHandlerClass +class SB_DummyHandlerClass { - public function handle() {} + public function handle(string $arg): string + { + return "handled: {$arg}"; + } + + public function noArgsHandler(): string + { + return "no-args"; + } + + public function handlerWithCompletion( + string $name, + #[CompletionProvider(providerClass: SB_DummyCompletionProvider::class)] + string $uriParam + ): array { + return []; + } } -class DummyInvokableClass + +class SB_DummyInvokableClass { - public function __invoke() {} + public function __invoke(int $id): array + { + return ['id' => $id]; + } } -class HandlerWithDeps -{ - public function __construct(public LoggerInterface $log) {} - #[McpTool(name: 'depTool')] - public function run() {} +class SB_DummyCompletionProvider implements CompletionProviderInterface +{ + public function getCompletions(string $currentValue, SessionInterface $session): array + { + return []; + } } + beforeEach(function () { $this->builder = new ServerBuilder(); }); -function getBuilderProperty(ServerBuilder $builder, string $propertyName) -{ - $reflector = new ReflectionClass($builder); - $property = $reflector->getProperty($propertyName); - $property->setAccessible(true); - - return $property->getValue($builder); -} -it('sets server info', function () { +it('sets server info correctly', function () { $this->builder->withServerInfo('MyServer', '1.2.3'); - expect(getBuilderProperty($this->builder, 'name'))->toBe('MyServer'); - expect(getBuilderProperty($this->builder, 'version'))->toBe('1.2.3'); + $serverInfo = getPrivateProperty($this->builder, 'serverInfo'); + expect($serverInfo)->toBeInstanceOf(Implementation::class) + ->and($serverInfo->name)->toBe('MyServer') + ->and($serverInfo->version)->toBe('1.2.3'); }); -it('sets capabilities', function () { - $capabilities = Capabilities::forServer(); // Use static factory +it('sets capabilities correctly', function () { + $capabilities = ServerCapabilities::make(toolsListChanged: true); $this->builder->withCapabilities($capabilities); - expect(getBuilderProperty($this->builder, 'capabilities'))->toBe($capabilities); + expect(getPrivateProperty($this->builder, 'capabilities'))->toBe($capabilities); +}); + +it('sets pagination limit correctly', function () { + $this->builder->withPaginationLimit(100); + expect(getPrivateProperty($this->builder, 'paginationLimit'))->toBe(100); }); -it('sets logger', function () { +it('sets logger correctly', function () { $logger = Mockery::mock(LoggerInterface::class); $this->builder->withLogger($logger); - expect(getBuilderProperty($this->builder, 'logger'))->toBe($logger); + expect(getPrivateProperty($this->builder, 'logger'))->toBe($logger); }); -it('sets cache and TTL', function () { +it('sets cache correctly', function () { $cache = Mockery::mock(CacheInterface::class); - $this->builder->withCache($cache, 1800); - expect(getBuilderProperty($this->builder, 'cache'))->toBe($cache); - expect(getBuilderProperty($this->builder, 'definitionCacheTtl'))->toBe(1800); + $this->builder->withCache($cache); + expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache); +}); + +it('sets session handler correctly', function () { + $handler = Mockery::mock(SessionHandlerInterface::class); + $this->builder->withSessionHandler($handler, 7200); + expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBe($handler); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(7200); +}); + +it('sets session driver to array correctly', function () { + $this->builder->withSession('array', 1800); + expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('array'); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(1800); +}); + +it('sets session driver to cache correctly', function () { + $this->builder->withSession('cache', 900); + expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('cache'); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900); }); -it('sets cache with default TTL', function () { +it('uses default TTL when not specified for session', function () { + $this->builder->withSession('array'); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(3600); +}); + +it('throws exception for invalid session driver', function () { + $this->builder->withSession('redis'); +})->throws(\InvalidArgumentException::class, "Unsupported session driver 'redis'. Only 'array' and 'cache' drivers are supported."); + +it('throws exception for cache session driver without cache during build', function () { + $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('cache') + ->build(); +})->throws(ConfigurationException::class, 'Cache session driver requires a cache instance'); + +it('creates ArraySessionHandler when array driver is specified', function () { + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('array', 1800) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + $handler = $handlerProp->getValue($sessionManager); + + expect($handler)->toBeInstanceOf(ArraySessionHandler::class); + expect($handler->ttl)->toBe(1800); +}); + +it('creates CacheSessionHandler when cache driver is specified', function () { $cache = Mockery::mock(CacheInterface::class); - $this->builder->withCache($cache); // No TTL provided - expect(getBuilderProperty($this->builder, 'cache'))->toBe($cache); - expect(getBuilderProperty($this->builder, 'definitionCacheTtl'))->toBe(3600); // Default + $cache->shouldReceive('get')->with('mcp_session_index', [])->andReturn([]); + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withCache($cache) + ->withSession('cache', 900) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + $handler = $handlerProp->getValue($sessionManager); + + expect($handler)->toBeInstanceOf(CacheSessionHandler::class); + expect($handler->cache)->toBe($cache); + expect($handler->ttl)->toBe(900); }); -it('sets container', function () { +it('prefers custom session handler over session driver', function () { + $customHandler = Mockery::mock(SessionHandlerInterface::class); + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('array') + ->withSessionHandler($customHandler, 1200) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + + expect($handlerProp->getValue($sessionManager))->toBe($customHandler); +}); + + +it('sets container correctly', function () { $container = Mockery::mock(ContainerInterface::class); $this->builder->withContainer($container); - expect(getBuilderProperty($this->builder, 'container'))->toBe($container); + expect(getPrivateProperty($this->builder, 'container'))->toBe($container); }); -it('sets loop', function () { +it('sets loop correctly', function () { $loop = Mockery::mock(LoopInterface::class); $this->builder->withLoop($loop); - expect(getBuilderProperty($this->builder, 'loop'))->toBe($loop); + expect(getPrivateProperty($this->builder, 'loop'))->toBe($loop); }); it('stores manual tool registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $name = 'my-tool'; - $desc = 'Tool desc'; - $this->builder->withTool($handler, $name, $desc); - - $manualTools = getBuilderProperty($this->builder, 'manualTools'); - expect($manualTools)->toBeArray()->toHaveCount(1); - expect($manualTools[0])->toBe(['handler' => $handler, 'name' => $name, 'description' => $desc]); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withTool($handler, 'my-tool', 'Tool desc'); + $manualTools = getPrivateProperty($this->builder, 'manualTools'); + expect($manualTools[0]['handler'])->toBe($handler) + ->and($manualTools[0]['name'])->toBe('my-tool') + ->and($manualTools[0]['description'])->toBe('Tool desc'); }); it('stores manual resource registration data', function () { - $handler = DummyInvokableClass::class; - $uri = 'test://resource'; - $name = 'inv-res'; - $this->builder->withResource($handler, $uri, $name); - - $manualResources = getBuilderProperty($this->builder, 'manualResources'); - expect($manualResources)->toBeArray()->toHaveCount(1); - expect($manualResources[0]['handler'])->toBe($handler); - expect($manualResources[0]['uri'])->toBe($uri); - expect($manualResources[0]['name'])->toBe($name); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withResource($handler, 'res://resource', 'Resource name'); + $manualResources = getPrivateProperty($this->builder, 'manualResources'); + expect($manualResources[0]['handler'])->toBe($handler) + ->and($manualResources[0]['uri'])->toBe('res://resource') + ->and($manualResources[0]['name'])->toBe('Resource name'); }); it('stores manual resource template registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $uriTemplate = 'test://tmpl/{id}'; - $this->builder->withResourceTemplate($handler, $uriTemplate); - - $manualTemplates = getBuilderProperty($this->builder, 'manualResourceTemplates'); - expect($manualTemplates)->toBeArray()->toHaveCount(1); - expect($manualTemplates[0]['handler'])->toBe($handler); - expect($manualTemplates[0]['uriTemplate'])->toBe($uriTemplate); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withResourceTemplate($handler, 'res://resource', 'Resource name'); + $manualResourceTemplates = getPrivateProperty($this->builder, 'manualResourceTemplates'); + expect($manualResourceTemplates[0]['handler'])->toBe($handler) + ->and($manualResourceTemplates[0]['uriTemplate'])->toBe('res://resource') + ->and($manualResourceTemplates[0]['name'])->toBe('Resource name'); }); it('stores manual prompt registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $name = 'my-prompt'; - $this->builder->withPrompt($handler, $name); - - $manualPrompts = getBuilderProperty($this->builder, 'manualPrompts'); - expect($manualPrompts)->toBeArray()->toHaveCount(1); - expect($manualPrompts[0]['handler'])->toBe($handler); - expect($manualPrompts[0]['name'])->toBe($name); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withPrompt($handler, 'my-prompt', 'Prompt desc'); + $manualPrompts = getPrivateProperty($this->builder, 'manualPrompts'); + expect($manualPrompts[0]['handler'])->toBe($handler) + ->and($manualPrompts[0]['name'])->toBe('my-prompt') + ->and($manualPrompts[0]['description'])->toBe('Prompt desc'); }); -it('throws exception if build called without server info', function () { - $this->builder - // ->withDiscoveryPaths($this->tempBasePath) // No longer needed - ->withTool([DummyHandlerClass::class, 'handle']) // Provide manual element - ->build(); +it('throws ConfigurationException if server info not provided', function () { + $this->builder->build(); })->throws(ConfigurationException::class, 'Server name and version must be provided'); -it('throws exception for empty server name or version', function ($name, $version) { - $this->builder - ->withServerInfo($name, $version) - ->withTool([DummyHandlerClass::class, 'handle']) // Provide manual element - ->build(); -})->throws(ConfigurationException::class, 'Server name and version must be provided') - ->with([ - ['', '1.0'], - ['Server', ''], - [' ', '1.0'], - ]); - -it('resolves default Logger correctly when building', function () { - $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) - ->build(); - expect($server->getConfiguration()->logger)->toBeInstanceOf(NullLogger::class); + +it('resolves default Logger, Loop, Container, SessionHandler if not provided', function () { + $server = $this->builder->withServerInfo('Test', '1.0')->build(); + $config = $server->getConfiguration(); + + expect($config->logger)->toBeInstanceOf(NullLogger::class); + expect($config->loop)->toBeInstanceOf(LoopInterface::class); + expect($config->container)->toBeInstanceOf(BasicContainer::class); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + expect($handlerProp->getValue($sessionManager))->toBeInstanceOf(ArraySessionHandler::class); }); -it('resolves default Loop correctly when building', function () { +it('builds Server with correct Configuration, Registry, Protocol, SessionManager', function () { + $logger = new NullLogger(); + $loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); + $cache = Mockery::mock(CacheInterface::class); + $container = Mockery::mock(ContainerInterface::class); + $sessionHandler = Mockery::mock(SessionHandlerInterface::class); + $capabilities = ServerCapabilities::make(promptsListChanged: true, resourcesListChanged: true); + + $loop->shouldReceive('addPeriodicTimer')->with(300, Mockery::type('callable'))->andReturn(Mockery::mock(TimerInterface::class)); + $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) + ->withServerInfo('FullBuild', '3.0') + ->withLogger($logger) + ->withLoop($loop) + ->withCache($cache) + ->withContainer($container) + ->withSessionHandler($sessionHandler) + ->withCapabilities($capabilities) + ->withPaginationLimit(75) ->build(); - expect($server->getConfiguration()->loop)->toBeInstanceOf(LoopInterface::class); + + expect($server)->toBeInstanceOf(Server::class); + + $config = $server->getConfiguration(); + expect($config->serverInfo->name)->toBe('FullBuild'); + expect($config->serverInfo->version)->toBe('3.0'); + expect($config->capabilities)->toBe($capabilities); + expect($config->logger)->toBe($logger); + expect($config->loop)->toBe($loop); + expect($config->cache)->toBe($cache); + expect($config->container)->toBe($container); + expect($config->paginationLimit)->toBe(75); + + expect($server->getRegistry())->toBeInstanceOf(Registry::class); + expect($server->getProtocol())->toBeInstanceOf(Protocol::class); + expect($server->getSessionManager())->toBeInstanceOf(SessionManager::class); + $smReflection = new ReflectionClass($server->getSessionManager()); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + expect($handlerProp->getValue($server->getSessionManager()))->toBe($sessionHandler); }); -it('resolves default Container correctly when building', function () { +it('registers manual tool successfully during build', function () { + $handler = [SB_DummyHandlerClass::class, 'handle']; + $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) + ->withServerInfo('ManualToolTest', '1.0') + ->withTool($handler, 'test-manual-tool', 'A test tool') ->build(); - expect($server->getConfiguration()->container)->toBeInstanceOf(BasicContainer::class); + + $registry = $server->getRegistry(); + $tool = $registry->getTool('test-manual-tool'); + + expect($tool)->toBeInstanceOf(RegisteredTool::class); + expect($tool->isManual)->toBeTrue(); + expect($tool->schema->name)->toBe('test-manual-tool'); + expect($tool->schema->description)->toBe('A test tool'); + expect($tool->schema->inputSchema)->toEqual(['type' => 'object', 'properties' => ['arg' => ['type' => 'string']], 'required' => ['arg']]); + expect($tool->handlerClass)->toBe(SB_DummyHandlerClass::class); + expect($tool->handlerMethod)->toBe('handle'); }); -it('uses provided dependencies over defaults when building', function () { - $myLoop = Mockery::mock(LoopInterface::class); - $myLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $myContainer = Mockery::mock(ContainerInterface::class); - $myCache = Mockery::mock(CacheInterface::class); - $myCaps = Capabilities::forServer(resourcesSubscribe: true); +it('infers tool name from invokable class if not provided', function () { + $handler = SB_DummyInvokableClass::class; $server = $this->builder - ->withServerInfo('CustomDeps', '1.0') - ->withLoop($myLoop) - ->withLogger($myLogger) - ->withContainer($myContainer) - ->withCache($myCache) - ->withCapabilities($myCaps) - ->withTool([DummyHandlerClass::class, 'handle']) // Add element + ->withServerInfo('Test', '1.0') + ->withTool($handler) ->build(); - $config = $server->getConfiguration(); - expect($config->loop)->toBe($myLoop); - expect($config->logger)->toBe($myLogger); - expect($config->container)->toBe($myContainer); - expect($config->cache)->toBe($myCache); - expect($config->capabilities)->toBe($myCaps); + $tool = $server->getRegistry()->getTool('SB_DummyInvokableClass'); + expect($tool)->not->toBeNull(); + expect($tool->schema->name)->toBe('SB_DummyInvokableClass'); }); -it('successfully creates Server with defaults', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('infers prompt arguments and completion providers for manual prompt', function () { + $handler = [SB_DummyHandlerClass::class, 'handlerWithCompletion']; $server = $this->builder - ->withServerInfo('BuiltServer', '1.0') - ->withContainer($container) - ->withTool([DummyHandlerClass::class, 'handle'], 'manualTool') + ->withServerInfo('Test', '1.0') + ->withPrompt($handler, 'myPrompt') ->build(); - expect($server)->toBeInstanceOf(Server::class); - $config = $server->getConfiguration(); - expect($config->serverName)->toBe('BuiltServer'); - expect($server->getRegistry()->findTool('manualTool'))->not->toBeNull(); - expect($config->logger)->toBeInstanceOf(NullLogger::class); - expect($config->loop)->toBeInstanceOf(LoopInterface::class); - expect($config->container)->toBe($container); - expect($config->capabilities)->toBeInstanceOf(Capabilities::class); + $prompt = $server->getRegistry()->getPrompt('myPrompt'); + expect($prompt)->toBeInstanceOf(RegisteredPrompt::class); + expect($prompt->schema->arguments)->toHaveCount(2); + expect($prompt->schema->arguments[0]->name)->toBe('name'); + expect($prompt->schema->arguments[1]->name)->toBe('uriParam'); + expect($prompt->getCompletionProvider('uriParam'))->toBe(SB_DummyCompletionProvider::class); }); -it('successfully creates Server with custom dependencies', function () { - $myLoop = Mockery::mock(LoopInterface::class); - $myLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $myContainer = Mockery::mock(ContainerInterface::class); - $myCache = Mockery::mock(CacheInterface::class); - $myCaps = Capabilities::forServer(resourcesSubscribe: true); +// it('throws DefinitionException if HandlerResolver fails for a manual element', function () { +// $handler = ['NonExistentClass', 'method']; + +// $server = $this->builder +// ->withServerInfo('Test', '1.0') +// ->withTool($handler, 'badTool') +// ->build(); +// })->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); + +it('builds successfully with minimal valid config', function () { $server = $this->builder - ->withServerInfo('CustomServer', '2.0') - ->withLoop($myLoop)->withLogger($myLogger)->withContainer($myContainer) - ->withCache($myCache)->withCapabilities($myCaps) - ->withPrompt(DummyInvokableClass::class) // Add one element + ->withServerInfo('TS-Compatible', '0.1') ->build(); - expect($server)->toBeInstanceOf(Server::class); - $config = $server->getConfiguration(); - expect($config->serverName)->toBe('CustomServer'); - expect($config->logger)->toBe($myLogger); - expect($config->loop)->toBe($myLoop); - expect($config->container)->toBe($myContainer); - expect($config->cache)->toBe($myCache); - expect($config->capabilities)->toBe($myCaps); - expect($server->getRegistry()->allPrompts()->count())->toBe(1); }); -it('throws DefinitionException if manual tool registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('can be built multiple times with different configurations', function () { + $builder = new ServerBuilder(); - $this->builder - ->withServerInfo('FailRegServer', '1.0') - ->withContainer($container) - ->withTool([DummyHandlerClass::class, 'nonExistentMethod'], 'badTool') + $server1 = $builder + ->withServerInfo('ServerOne', '1.0') + ->withTool([SB_DummyHandlerClass::class, 'handle'], 'toolOne') ->build(); -})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); - -it('throws DefinitionException if manual resource registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); - $this->builder - ->withServerInfo('FailRegServer', '1.0') - ->withContainer($container) - ->withResource([DummyHandlerClass::class, 'handle'], 'invalid-uri-no-scheme') // Invalid URI + $server2 = $builder + ->withServerInfo('ServerTwo', '2.0') + ->withTool([SB_DummyHandlerClass::class, 'noArgsHandler'], 'toolTwo') ->build(); -})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); + + expect($server1->getConfiguration()->serverInfo->name)->toBe('ServerOne'); + $registry1 = $server1->getRegistry(); + expect($registry1->getTool('toolOne'))->not->toBeNull(); + expect($registry1->getTool('toolTwo'))->toBeNull(); + + expect($server2->getConfiguration()->serverInfo->name)->toBe('ServerTwo'); + $registry2 = $server2->getRegistry(); + expect($registry2->getTool('toolOne'))->not->toBeNull(); + expect($registry2->getTool('toolTwo'))->not->toBeNull(); + + $builder3 = new ServerBuilder(); + $server3 = $builder3->withServerInfo('ServerThree', '3.0')->build(); + expect($server3->getRegistry()->hasElements())->toBeFalse(); +}); diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 4851e06..7e4c446 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -10,57 +10,73 @@ use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\DiscoveryException; -use PhpMcp\Server\Model\Capabilities; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Utils\Discoverer; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; beforeEach(function () { - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->loop = Mockery::mock(LoopInterface::class); - - $cache = Mockery::mock(CacheInterface::class); - $container = Mockery::mock(ContainerInterface::class); - $capabilities = Capabilities::forServer(); + /** @var MockInterface&LoopInterface $loop */ + $this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); + /** @var MockInterface&CacheInterface $cache */ + $this->cache = Mockery::mock(CacheInterface::class); + /** @var MockInterface&ContainerInterface $container */ + $this->container = Mockery::mock(ContainerInterface::class); $this->configuration = new Configuration( - 'TestServerInstance', - '1.0', - $capabilities, - $this->logger, - $this->loop, - $cache, - $container + serverInfo: Implementation::make('TestServerInstance', '1.0'), + capabilities: ServerCapabilities::make(), + logger: $this->logger, + loop: $this->loop, + cache: $this->cache, + container: $this->container ); - + /** @var MockInterface&Registry $registry */ $this->registry = Mockery::mock(Registry::class); - $this->clientStateManager = Mockery::mock(ClientStateManager::class); + /** @var MockInterface&Protocol $protocol */ $this->protocol = Mockery::mock(Protocol::class); + /** @var MockInterface&Discoverer $discoverer */ + $this->discoverer = Mockery::mock(Discoverer::class); - $this->server = new Server($this->configuration, $this->registry, $this->protocol, $this->clientStateManager); + $this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop); + + $this->server = new Server( + $this->configuration, + $this->registry, + $this->protocol, + $this->sessionManager + ); $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault(); - $this->registry->allows('discoveryRanOrCached')->withNoArgs()->andReturn(false)->byDefault(); - $this->registry->allows('clearDiscoveredElements')->withAnyArgs()->andReturnNull()->byDefault(); - $this->registry->allows('saveDiscoveredElementsToCache')->withAnyArgs()->andReturn(true)->byDefault(); - $this->registry->allows('loadDiscoveredElementsFromCache')->withAnyArgs()->andReturnNull()->byDefault(); - $this->registry->allows('allTools->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allResources->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allResourceTemplates->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allPrompts->count')->withNoArgs()->andReturn(0)->byDefault(); + $this->registry->allows('clear')->withAnyArgs()->byDefault(); + $this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault(); +}); + +afterEach(function () { + $this->sessionManager->stopGcTimer(); }); it('provides getters for core components', function () { expect($this->server->getConfiguration())->toBe($this->configuration); expect($this->server->getRegistry())->toBe($this->registry); expect($this->server->getProtocol())->toBe($this->protocol); + expect($this->server->getSessionManager())->toBe($this->sessionManager); +}); + +it('provides a static make method returning ServerBuilder', function () { + expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class); }); it('skips discovery if already run and not forced', function () { @@ -69,73 +85,95 @@ $prop->setAccessible(true); $prop->setValue($this->server, true); - $this->registry->shouldNotReceive('clearDiscoveredElements'); - $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); - - $this->server->discover(sys_get_temp_dir()); + $this->registry->shouldNotReceive('clear'); + $this->discoverer->shouldNotReceive('discover'); + $this->registry->shouldNotReceive('save'); + $this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer); $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.'); }); -it('clears discovered elements before scanning', function () { - $basePath = sys_get_temp_dir(); - - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); - - $this->server->discover($basePath); - +it('forces discovery even if already run, calling injected discoverer', function () { $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); + $prop->setValue($this->server, true); + + $basePath = realpath(sys_get_temp_dir()); + $scanDirs = ['.', 'src']; + + + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover') + ->with($basePath, $scanDirs, Mockery::type('array')) + ->once(); + $this->registry->shouldReceive('save')->once()->andReturn(true); + + $this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer); + expect($prop->getValue($this->server))->toBeTrue(); }); -it('saves to cache after discovery when requested', function () { - // Arrange - $basePath = sys_get_temp_dir(); +it('calls registry clear and discoverer, then saves to cache by default', function () { + $basePath = realpath(sys_get_temp_dir()); + $scanDirs = ['app', 'lib']; + $userExcludeDirs = ['specific_exclude']; + $finalExcludeDirs = array_unique(array_merge( + ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'], + $userExcludeDirs + )); - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); - // Act - $this->server->discover($basePath, saveToCache: true); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover') + ->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) { + expect($arg)->toBeArray(); + expect($arg)->toEqualCanonicalizing($finalExcludeDirs); + return true; + })) + ->once(); + $this->registry->shouldReceive('save')->once()->andReturn(true); + + $this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer); + + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + expect($prop->getValue($this->server))->toBeTrue(); }); -it('does NOT save to cache after discovery when requested', function () { - // Arrange - $basePath = sys_get_temp_dir(); +it('does not save to cache if saveToCache is false', function () { + $basePath = realpath(sys_get_temp_dir()); - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(false); // saveToCache=false -> deleteCacheFile=false - $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); // Expect NOT to save + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once(); + $this->registry->shouldNotReceive('save'); - // Act - $this->server->discover($basePath, saveToCache: false); + $this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer); }); -it('throws InvalidArgumentException for bad base path', function () { - $this->server->discover('/non/existent/path/for/sure'); -})->throws(\InvalidArgumentException::class); +it('throws InvalidArgumentException for bad base path in discover', function () { + $this->discoverer->shouldNotReceive('discover'); + $this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer); +})->throws(\InvalidArgumentException::class, 'Invalid discovery base path'); -it('throws DiscoveryException if discoverer fails', function () { - $basePath = sys_get_temp_dir(); - $exception = new \RuntimeException('Filesystem error'); +it('throws DiscoveryException if Discoverer fails during discovery', function () { + $basePath = realpath(sys_get_temp_dir()); - $this->registry->shouldReceive('clearDiscoveredElements')->once(); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andThrow($exception); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error')); + $this->registry->shouldNotReceive('save'); - $this->server->discover($basePath); + $this->server->discover($basePath, discoverer: $this->discoverer); })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error'); -it('resets discoveryRan flag on failure', function () { - $basePath = sys_get_temp_dir(); - $exception = new \RuntimeException('Filesystem error'); - - $this->registry->shouldReceive('clearDiscoveredElements')->once(); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andThrow($exception); +it('resets discoveryRan flag on Discoverer failure', function () { + $basePath = realpath(sys_get_temp_dir()); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure')); try { - $this->server->discover($basePath); + $this->server->discover($basePath, discoverer: $this->discoverer); } catch (DiscoveryException $e) { // Expected } @@ -146,30 +184,19 @@ expect($prop->getValue($this->server))->toBeFalse(); }); -it('throws exception if already listening', function () { - $transport = Mockery::mock(ServerTransportInterface::class); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs()->byDefault(); - $transport->shouldReceive('listen')->once(); // Expect listen on first call - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); // Simulate loop run for first call - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); +// --- Listening Tests --- +it('throws LogicException if listen is called when already listening', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); - $this->server->listen($transport); + $this->server->listen($transport, false); + $this->server->listen($transport, false); +})->throws(LogicException::class, 'Server is already listening'); - $reflector = new \ReflectionClass($this->server); - $prop = $reflector->getProperty('isListening'); - $prop->setAccessible(true); - $prop->setValue($this->server, true); - - // Act & Assert: Second call throws - expect(fn() => $this->server->listen($transport)) - ->toThrow(LogicException::class, 'Server is already listening'); -}); - -it('warns if no elements and discovery not run when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); +it('warns if no elements and discovery not run when listen is called', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->registry->shouldReceive('hasElements')->andReturn(false); @@ -177,64 +204,72 @@ ->once() ->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/')); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); - - $this->server->listen($transport); + $this->server->listen($transport, false); }); -it('warns if no elements found AFTER discovery when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); - - $this->registry->shouldReceive('hasElements')->andReturn(false); - $reflector = new \ReflectionClass($this->server); - $prop = $reflector->getProperty('discoveryRan'); - $prop->setAccessible(true); - $prop->setValue($this->server, true); +it('injects logger and loop into aware transports during listen', function () { + $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class); + $transport->shouldReceive('setLogger')->with($this->logger)->once(); + $transport->shouldReceive('setLoop')->with($this->loop)->once(); + $transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs(); + $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs(); - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Starting listener, but no MCP elements were found after discovery/')); + $this->server->listen($transport); +}); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); +it('binds protocol, starts transport listener, and runs loop by default', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); + $this->loop->shouldReceive('run')->once(); + $this->protocol->shouldReceive('unbindTransport')->once(); $this->server->listen($transport); + expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); -it('does not warn if elements are present when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); +it('does not run loop if runLoop is false in listen', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); - $this->registry->shouldReceive('hasElements')->andReturn(true); + $this->loop->shouldNotReceive('run'); - $this->logger->shouldNotReceive('warning'); + $this->server->listen($transport, runLoop: false); + expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue(); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->protocol->shouldReceive('unbindTransport'); + $transport->shouldReceive('removeAllListeners'); + $transport->shouldReceive('close'); + $this->server->endListen($transport); +}); - $this->server->listen($transport); +it('calls endListen if transport listen throws immediately', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use")); + $this->protocol->shouldReceive('bindTransport')->once(); + $this->protocol->shouldReceive('unbindTransport')->once(); + + $this->loop->shouldNotReceive('run'); + + try { + $this->server->listen($transport); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toBe("Port in use"); + } + expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); -it('injects logger and loop into aware transports when listening', function () { - $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class); - $transport->shouldReceive('setLogger')->with($this->logger)->once(); - $transport->shouldReceive('setLoop')->with($this->loop)->once(); - $transport->shouldReceive('on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); +it('endListen unbinds protocol and closes transport if listening', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('isListening'); + $prop->setAccessible(true); + $prop->setValue($this->server, true); - $this->server->listen($transport); + $this->protocol->shouldReceive('unbindTransport')->once(); + $transport->shouldReceive('removeAllListeners')->with('close')->once(); + $transport->shouldReceive('close')->once(); + + $this->server->endListen($transport); + expect($prop->getValue($this->server))->toBeFalse(); }); diff --git a/tests/Unit/Session/ArraySessionHandlerTest.php b/tests/Unit/Session/ArraySessionHandlerTest.php new file mode 100644 index 0000000..85d0a5e --- /dev/null +++ b/tests/Unit/Session/ArraySessionHandlerTest.php @@ -0,0 +1,216 @@ +fixedClock = new FixedClock(); + $this->handler = new ArraySessionHandler(DEFAULT_TTL_ARRAY, $this->fixedClock); +}); + +it('implements SessionHandlerInterface', function () { + expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); +}); + +it('constructs with a default TTL and SystemClock if no clock provided', function () { + $handler = new ArraySessionHandler(); + expect($handler->ttl)->toBe(DEFAULT_TTL_ARRAY); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBeInstanceOf(SystemClock::class); +}); + +it('constructs with a custom TTL and injected clock', function () { + $customTtl = 1800; + $clock = new FixedClock(); + $handler = new ArraySessionHandler($customTtl, $clock); + expect($handler->ttl)->toBe($customTtl); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBe($clock); +}); + +it('writes session data and reads it back correctly', function () { + $writeResult = $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + expect($writeResult)->toBeTrue(); + + $readData = $this->handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_1); +}); + +it('returns false when reading a non-existent session', function () { + $readData = $this->handler->read('non-existent-session-id'); + expect($readData)->toBeFalse(); +}); + +it('overwrites existing session data on subsequent write', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + $updatedData = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":3}],"total":175.25},"theme":"light"}'; + $this->handler->write(SESSION_ID_ARRAY_1, $updatedData); + + $readData = $this->handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe($updatedData); +}); + +it('returns false and removes data when reading an expired session due to handler TTL', function () { + $ttl = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($ttl, $fixedClock); + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($ttl + 1); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBeFalse(); + + $reflection = new \ReflectionClass($handler); + $storeProp = $reflection->getProperty('store'); + $storeProp->setAccessible(true); + $internalStore = $storeProp->getValue($handler); + expect($internalStore)->not->toHaveKey(SESSION_ID_ARRAY_1); +}); + +it('does not return data if read exactly at TTL expiration time', function () { + $shortTtl = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($shortTtl, $fixedClock); + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($shortTtl); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_1); + + $fixedClock->addSecond(); + + $readDataExpired = $handler->read(SESSION_ID_ARRAY_1); + expect($readDataExpired)->toBeFalse(); +}); + + +it('updates timestamp on write, effectively extending session life', function () { + $veryShortTtl = 5; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($veryShortTtl, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(3); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_2); + + $fixedClock->addSeconds(3); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_2); +}); + +it('destroys an existing session and it cannot be read', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + + $destroyResult = $this->handler->destroy(SESSION_ID_ARRAY_1); + expect($destroyResult)->toBeTrue(); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + + $reflection = new \ReflectionClass($this->handler); + $storeProp = $reflection->getProperty('store'); + $storeProp->setAccessible(true); + expect($storeProp->getValue($this->handler))->not->toHaveKey(SESSION_ID_ARRAY_1); +}); + +it('destroy returns true and does nothing for a non-existent session', function () { + $destroyResult = $this->handler->destroy('non-existent-id'); + expect($destroyResult)->toBeTrue(); +}); + +it('garbage collects only sessions older than maxLifetime', function () { + $gcMaxLifetime = 100; + $handlerTtl = 300; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($handlerTtl, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(50); + $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $fixedClock->addSeconds(80); + + $deletedSessions = $handler->gc($gcMaxLifetime); + + expect($deletedSessions)->toBeArray()->toEqual([SESSION_ID_ARRAY_1]); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); +}); + +it('garbage collection respects maxLifetime precisely', function () { + $maxLifetime = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler(300, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($maxLifetime); + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toBeEmpty(); + expect($handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + + $fixedClock->addSecond(); + $deleted2 = $handler->gc($maxLifetime); + expect($deleted2)->toEqual([SESSION_ID_ARRAY_1]); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); +}); + +it('garbage collection returns empty array if no sessions meet criteria', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + $this->handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $this->fixedClock->addSeconds(DEFAULT_TTL_ARRAY / 2); + + $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); + expect($deletedSessions)->toBeArray()->toBeEmpty(); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + expect($this->handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); +}); + +it('garbage collection correctly handles an empty store', function () { + $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); + expect($deletedSessions)->toBeArray()->toBeEmpty(); +}); + +it('garbage collection removes multiple expired sessions', function () { + $maxLifetime = 30; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler(300, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(20); + $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $fixedClock->addSeconds(20); + $handler->write(SESSION_ID_ARRAY_3, SESSION_DATA_3); + + $fixedClock->addSeconds(20); + + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toHaveCount(2)->toContain(SESSION_ID_ARRAY_1)->toContain(SESSION_ID_ARRAY_2); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_2))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_3))->toBe(SESSION_DATA_3); +}); diff --git a/tests/Unit/Session/CacheSessionHandlerTest.php b/tests/Unit/Session/CacheSessionHandlerTest.php new file mode 100644 index 0000000..e5a9507 --- /dev/null +++ b/tests/Unit/Session/CacheSessionHandlerTest.php @@ -0,0 +1,244 @@ +fixedClock = new FixedClock(); + /** @var MockInterface&CacheInterface $cache */ + $this->cache = Mockery::mock(CacheInterface::class); + + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault(); + + $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); +}); + +it('implements SessionHandlerInterface', function () { + expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); +}); + +it('constructs with default TTL and SystemClock if no clock provided', function () { + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $handler = new CacheSessionHandler($cacheMock); + + expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class); +}); + +it('constructs with a custom TTL and injected clock', function () { + $customTtl = 7200; + $clock = new FixedClock(); + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock); + expect($handler->ttl)->toBe($customTtl); + + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBe($clock); +}); + +it('loads session index from cache on construction', function () { + $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp]; + + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex); + + new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock); +}); + +it('reads session data from cache', function () { + $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex); + $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1); + + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + $readData = $handler->read(SESSION_ID_CACHE_1); + expect($readData)->toBe(SESSION_DATA_CACHE_1); +}); + +it('returns false when reading non-existent session (cache get returns default)', function () { + $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false); + $readData = $this->handler->read('non-existent-id'); + expect($readData)->toBeFalse(); +}); + +it('writes session data to cache with correct key and TTL, and updates session index', function () { + $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00 + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('set') + ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(true); + + $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); + expect($writeResult)->toBeTrue(); +}); + +it('updates timestamp in session index for existing session on write', function () { + $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00 + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->fixedClock->addSeconds(90); + $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp(); + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('set') + ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(true); + + $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); +}); + +it('returns false if cache set for session data fails', function () { + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); + $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(false); + + $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); + expect($writeResult)->toBeFalse(); +}); + +it('destroys session by removing from cache and updating index', function () { + $initialTimestamp = $this->fixedClock->now()->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); + + $handler->destroy(SESSION_ID_CACHE_1); +}); + +it('destroy returns true if session ID not in index (cache delete still called)', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty + $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data + + $destroyResult = $handler->destroy('non-existent-id'); + expect($destroyResult)->toBeTrue(); +}); + +it('destroy returns false if cache delete for session data fails', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails + + $destroyResult = $handler->destroy(SESSION_ID_CACHE_1); + expect($destroyResult)->toBeFalse(); +}); + +it('garbage collects only sessions older than maxLifetime from cache and index', function () { + $maxLifetime = 120; + + $initialIndex = [ + SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(), + SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(), + SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(), + ]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true); + $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1); + + $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]]; + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); + + $deletedSessionIds = $handler->gc($maxLifetime); + + expect($deletedSessionIds)->toBeArray()->toHaveCount(2) + ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2) + ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3); +}); + +it('garbage collection respects maxLifetime precisely for cache handler', function () { + $maxLifetime = 60; + + $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldNotReceive('delete'); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true); + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toBeEmpty(); + + $this->fixedClock->addSeconds(1); + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); + $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime); + expect($deleted2)->toEqual([SESSION_ID_CACHE_1]); +}); + + +it('garbage collection handles an empty session index', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); + $this->cache->shouldNotReceive('delete'); + + $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE); + expect($deletedSessions)->toBeArray()->toBeEmpty(); +}); + +it('garbage collection continues updating index even if a cache delete fails', function () { + $maxLifetime = 60; + + $initialIndex = [ + 'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(), + 'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(), + 'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(), + ]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false); + + $expectedFinalIndex = ['survivor' => $initialIndex['survivor']]; + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); + + $deletedSessionIds = $handler->gc($maxLifetime); + expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails'); +}); diff --git a/tests/Unit/Session/SessionManagerTest.php b/tests/Unit/Session/SessionManagerTest.php new file mode 100644 index 0000000..29f6257 --- /dev/null +++ b/tests/Unit/Session/SessionManagerTest.php @@ -0,0 +1,210 @@ +sessionHandler = Mockery::mock(SessionHandlerInterface::class); + /** @var MockInterface&LoggerInterface $logger */ + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $this->loop = Loop::get(); + + $this->sessionManager = new SessionManager( + $this->sessionHandler, + $this->logger, + $this->loop, + DEFAULT_TTL_MGR + ); + + $this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault(); + $this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault(); + $this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault(); + $this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault(); +}); + +it('creates a new session with default hydrated values and saves it', function () { + $this->sessionHandler->shouldReceive('write') + ->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) { + $data = json_decode($dataJson, true); + expect($data['initialized'])->toBeFalse(); + expect($data['client_info'])->toBeNull(); + expect($data['protocol_version'])->toBeNull(); + expect($data['subscriptions'])->toEqual([]); + expect($data['message_queue'])->toEqual([]); + expect($data['log_level'])->toBeNull(); + return true; + }))->once()->andReturn(true); + + $sessionCreatedEmitted = false; + $emittedSessionId = null; + $emittedSessionObj = null; + $this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) { + $sessionCreatedEmitted = true; + $emittedSessionId = $id; + $emittedSessionObj = $session; + }); + + $session = $this->sessionManager->createSession(SESSION_ID_MGR_1); + + expect($session)->toBeInstanceOf(SessionInterface::class); + expect($session->getId())->toBe(SESSION_ID_MGR_1); + expect($session->get('initialized'))->toBeFalse(); + $this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionCreatedEmitted)->toBeTrue(); + expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); + expect($emittedSessionObj)->toBe($session); +}); + +it('gets an existing session if handler read returns data', function () { + $existingData = ['user_id' => 123, 'initialized' => true]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData)); + + $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); + expect($session)->toBeInstanceOf(SessionInterface::class); + expect($session->getId())->toBe(SESSION_ID_MGR_1); + expect($session->get('user_id'))->toBe(123); +}); + +it('returns null from getSession if session does not exist (handler read returns false)', function () { + $this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false); + $session = $this->sessionManager->getSession('non-existent'); + expect($session)->toBeNull(); +}); + +it('returns null from getSession if session data is empty after load', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode([])); + $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); + expect($session)->toBeNull(); +}); + + +it('deletes a session successfully and emits event', function () { + $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true); + + $sessionDeletedEmitted = false; + $emittedSessionId = null; + $this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) { + $sessionDeletedEmitted = true; + $emittedSessionId = $id; + }); + + $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); + + expect($success)->toBeTrue(); + $this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionDeletedEmitted)->toBeTrue(); + expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); +}); + +it('logs warning and does not emit event if deleteSession fails', function () { + $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false); + $sessionDeletedEmitted = false; + $this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) { + $sessionDeletedEmitted = true; + }); + + $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); + + expect($success)->toBeFalse(); + $this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionDeletedEmitted)->toBeFalse(); +}); + +it('queues message for existing session', function () { + $sessionData = ['message_queue' => []]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); + $message = '{"id":1}'; + $updatedSessionData = ['message_queue' => [$message]]; + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, json_encode($updatedSessionData))->once()->andReturn(true); + + $this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message); +}); + +it('does nothing on queueMessage if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + $this->sessionHandler->shouldNotReceive('write'); + $this->sessionManager->queueMessage('no-such-session', '{"id":1}'); +}); + +it('dequeues messages from existing session', function () { + $messages = ['{"id":1}', '{"id":2}']; + $sessionData = ['message_queue' => $messages]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, json_encode(['message_queue' => []]))->once()->andReturn(true); + + $dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1); + expect($dequeued)->toEqual($messages); +}); + +it('returns empty array from dequeueMessages if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]); +}); + +it('checks hasQueuedMessages for existing session', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']])); + expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue(); + + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []])); + expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse(); +}); + +it('returns false from hasQueuedMessages if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse(); +}); + +it('can stop GC timer on stopGcTimer ', function () { + $loop = Mockery::mock(LoopInterface::class); + $loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class)); + $loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once(); + + $manager = new SessionManager($this->sessionHandler, $this->logger, $loop); + $manager->startGcTimer(); + $manager->stopGcTimer(); +}); + +it('GC timer callback deletes expired sessions', function () { + $clock = new FixedClock(); + + $sessionHandler = new ArraySessionHandler(60, $clock); + $sessionHandler->write('sess_expired', 'data'); + + $clock->addSeconds(100); + + $manager = new SessionManager( + $sessionHandler, + $this->logger, + ttl: 30, + gcInterval: 0.01 + ); + + $session = $manager->getSession('sess_expired'); + expect($session)->toBeNull(); +}); + + +it('does not start GC timer if already started', function () { + $this->loop = Mockery::mock(LoopInterface::class); + $this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class)); + + $manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop); + $manager->startGcTimer(); +}); diff --git a/tests/Unit/Session/SessionTest.php b/tests/Unit/Session/SessionTest.php new file mode 100644 index 0000000..947f059 --- /dev/null +++ b/tests/Unit/Session/SessionTest.php @@ -0,0 +1,237 @@ +sessionHandler = Mockery::mock(SessionHandlerInterface::class); + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault(); +}); + +it('implements SessionInterface', function () { + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session)->toBeInstanceOf(SessionInterface::class); +}); + +// --- Constructor and ID Generation --- +it('uses provided ID if given', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->getId())->toBe(SESSION_ID_SESS); +}); + +it('generates an ID if none is provided', function () { + $this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false); + $session = new Session($this->sessionHandler); + expect($session->getId())->toBeString()->toHaveLength(32); +}); + +it('loads data from handler on construction if session exists', function () { + $initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData)); + + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toEqual($initialData); + expect($session->get('foo'))->toBe('bar'); +}); + +it('initializes with empty data if handler read returns false', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toBeEmpty(); +}); + +it('initializes with empty data if handler read returns invalid JSON', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json'); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toBeEmpty(); +}); + +it('saves current data to handler', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('name', 'Alice'); + $session->set('level', 10); + + $expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]); + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true); + + $session->save(); +}); + +it('sets and gets a top-level attribute', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('name', 'Bob'); + expect($session->get('name'))->toBe('Bob'); + expect($session->has('name'))->toBeTrue(); +}); + +it('gets default value if attribute does not exist', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->get('nonexistent', 'default_val'))->toBe('default_val'); + expect($session->has('nonexistent'))->toBeFalse(); +}); + +it('sets and gets nested attributes using dot notation', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('user.profile.email', 'test@example.com'); + $session->set('user.profile.active', true); + $session->set('user.roles', ['admin', 'editor']); + + expect($session->get('user.profile'))->toEqual(['email' => 'test@example.com', 'active' => true]); + expect($session->get('user.roles'))->toEqual(['admin', 'editor']); + expect($session->has('user.profile.email'))->toBeTrue(); + expect($session->has('user.other_profile.settings'))->toBeFalse(); +}); + +it('set does not overwrite if overwrite is false and key exists', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('counter', 10); + $session->set('counter', 20, false); + expect($session->get('counter'))->toBe(10); + + $session->set('user.id', 1); + $session->set('user.id', 2, false); + expect($session->get('user.id'))->toBe(1); +}); + +it('set overwrites if overwrite is true (default)', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('counter', 10); + $session->set('counter', 20); + expect($session->get('counter'))->toBe(20); +}); + + +it('forgets a top-level attribute', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->forget('age'); + expect($session->has('age'))->toBeFalse(); + expect($session->has('name'))->toBeTrue(); + expect($session->all())->toEqual(['name' => 'Alice']); +}); + +it('forgets a nested attribute using dot notation', function () { + $initialData = ['user' => ['profile' => ['email' => 'test@example.com', 'status' => 'active'], 'id' => 1]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + + $session->forget('user.profile.status'); + expect($session->has('user.profile.status'))->toBeFalse(); + expect($session->has('user.profile.email'))->toBeTrue(); + expect($session->get('user.profile'))->toEqual(['email' => 'test@example.com']); + + $session->forget('user.profile'); + expect($session->has('user.profile'))->toBeFalse(); + expect($session->get('user'))->toEqual(['id' => 1]); +}); + +it('forget does nothing if key does not exist', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test'])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->forget('nonexistent'); + $session->forget('another_nonexistent'); + expect($session->all())->toEqual(['name' => 'Test']); +}); + +it('pulls an attribute (gets and forgets)', function () { + $initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + + $pulledItem = $session->pull('item', 'default'); + expect($pulledItem)->toBe('important'); + expect($session->has('item'))->toBeFalse(); + + $pulledToken = $session->pull('user.token'); + expect($pulledToken)->toBe('abc123xyz'); + expect($session->has('user.token'))->toBeFalse(); + expect($session->has('user'))->toBeTrue(); + + $pulledNonExistent = $session->pull('nonexistent', 'fallback'); + expect($pulledNonExistent)->toBe('fallback'); +}); + +it('clears all session data', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->clear(); + expect($session->all())->toBeEmpty(); +}); + +it('returns all data with all()', function () { + $data = ['a' => 1, 'b' => ['c' => 3]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toEqual($data); +}); + +it('hydrates session data, merging with defaults and removing id', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $newAttributes = [ + 'client_info' => ['name' => 'TestClient', 'version' => '1.1'], + 'protocol_version' => '2024-custom', + 'user_custom_key' => 'my_value', + 'id' => 'should_be_ignored_on_hydrate' + ]; + $session->hydrate($newAttributes); + + $allData = $session->all(); + expect($allData['initialized'])->toBeFalse(); + expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']); + expect($allData['protocol_version'])->toBe('2024-custom'); + expect($allData['message_queue'])->toEqual([]); + expect($allData['log_level'])->toBeNull(); + expect($allData['user_custom_key'])->toBe('my_value'); + expect($allData)->not->toHaveKey('id'); +}); + +it('queues messages correctly', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->hasQueuedMessages())->toBeFalse(); + + $msg1 = '{"jsonrpc":"2.0","method":"n1"}'; + $msg2 = '{"jsonrpc":"2.0","method":"n2"}'; + $session->queueMessage($msg1); + $session->queueMessage($msg2); + + expect($session->hasQueuedMessages())->toBeTrue(); + expect($session->get('message_queue'))->toEqual([$msg1, $msg2]); +}); + +it('dequeues messages and clears queue', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $msg1 = '{"id":1}'; + $msg2 = '{"id":2}'; + $session->queueMessage($msg1); + $session->queueMessage($msg2); + + $dequeued = $session->dequeueMessages(); + expect($dequeued)->toEqual([$msg1, $msg2]); + expect($session->hasQueuedMessages())->toBeFalse(); + expect($session->get('message_queue', 'not_found'))->toEqual([]); + + expect($session->dequeueMessages())->toEqual([]); +}); + +it('jsonSerializes to all session data', function () { + $data = ['serialize' => 'me', 'nested' => ['ok' => true]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect(json_encode($session))->toBe(json_encode($data)); +}); diff --git a/tests/Unit/State/ClientStateManagerTest.php b/tests/Unit/State/ClientStateManagerTest.php deleted file mode 100644 index cb6c72c..0000000 --- a/tests/Unit/State/ClientStateManagerTest.php +++ /dev/null @@ -1,437 +0,0 @@ -cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&LoggerInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - // Instance WITH mocked cache for most tests - $this->stateManagerWithCache = new ClientStateManager( - $this->logger, - $this->cache, - CLIENT_DATA_PREFIX_CSM, - CACHE_TTL_CSM - ); - - // Instance that will use its internal default ArrayCache - $this->stateManagerWithDefaultCache = new ClientStateManager( - $this->logger, - null, - CLIENT_DATA_PREFIX_CSM, - CACHE_TTL_CSM - ); -}); - -afterEach(function () { - Mockery::close(); -}); - -function getClientStateKey(string $clientId): string -{ - return CLIENT_DATA_PREFIX_CSM . $clientId; -} -function getResourceSubscribersKey(string $uri): string -{ - return GLOBAL_RES_SUBS_PREFIX_CSM . sha1($uri); -} -function getActiveClientsKey(): string -{ - return CLIENT_DATA_PREFIX_CSM . ClientStateManager::GLOBAL_ACTIVE_CLIENTS_KEY; -} - -it('uses provided cache or defaults to ArrayCache', function () { - // Verify with provided cache - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $cacheProp = $reflector->getProperty('cache'); - $cacheProp->setAccessible(true); - expect($cacheProp->getValue($this->stateManagerWithCache))->toBe($this->cache); - - // Verify with default ArrayCache - $reflectorNoCache = new \ReflectionClass($this->stateManagerWithDefaultCache); - $cachePropNoCache = $reflectorNoCache->getProperty('cache'); - $cachePropNoCache->setAccessible(true); - expect($cachePropNoCache->getValue($this->stateManagerWithDefaultCache))->toBeInstanceOf(ArrayCache::class); -}); - -it('returns existing state object from cache', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $mockedClientState = new ClientState(TEST_CLIENT_ID_CSM); - $mockedClientState->isInitialized = true; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($mockedClientState); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM); - - expect($state)->toBe($mockedClientState); -}); - -it('creates new state if not found and createIfNotFound is true', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Cache miss - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, true); // createIfNotFound = true - - expect($state)->toBeInstanceOf(ClientState::class); - expect($state->isInitialized)->toBeFalse(); // New state default -}); - -it('returns null if not found and createIfNotFound is false', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, false); // createIfNotFound = false - - expect($state)->toBeNull(); -}); - -it('deletes invalid data from cache', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn('not a ClientState object'); - $this->cache->shouldReceive('delete')->once()->with($clientStateKey)->andReturn(true); - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Invalid data type found in cache for client state/'), Mockery::any()); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, true); // Try to create - - expect($state)->toBeInstanceOf(ClientState::class); // Should create a new one -}); - -it('saves state in cache and updates timestamp', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $clientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialTimestamp = $clientState->lastActivityTimestamp; - - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $state) use ($initialTimestamp) { - return $state->lastActivityTimestamp >= $initialTimestamp; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('saveClientState'); - $method->setAccessible(true); - $success = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, $clientState); - - expect($success)->toBeTrue(); - expect($clientState->lastActivityTimestamp)->toBeGreaterThanOrEqual($initialTimestamp); // Timestamp updated -}); - -// --- Initialization --- -test('gets client state and checks if initialized', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->isInitialized = true; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - expect($this->stateManagerWithCache->isInitialized(TEST_CLIENT_ID_CSM))->toBeTrue(); - - $stateNotInit = new ClientState(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->with(getClientStateKey('client2'))->andReturn($stateNotInit); - expect($this->stateManagerWithCache->isInitialized('client2'))->toBeFalse(); -}); - -it('updates client state and global active list when client is initialized', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $activeClientsKey = getActiveClientsKey(); - - // getClientState (createIfNotFound=true) - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Simulate not found - // saveClientState - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => $s->isInitialized === true), CACHE_TTL_CSM) - ->andReturn(true); - // updateGlobalActiveClientTimestamp - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::hasKey(TEST_CLIENT_ID_CSM), CACHE_TTL_CSM)->andReturn(true); - $this->logger->shouldReceive('info')->with('ClientStateManager: Client marked initialized.', Mockery::any()); - - $this->stateManagerWithCache->markInitialized(TEST_CLIENT_ID_CSM); -}); - -// --- Client Info --- -it('updates client state when client info is stored', function () { - $clientInfo = ['name' => 'X', 'v' => '2']; - $proto = 'P1'; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Create new - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $s) use ($clientInfo, $proto) { - return $s->clientInfo === $clientInfo && $s->protocolVersion === $proto; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->storeClientInfo($clientInfo, $proto, TEST_CLIENT_ID_CSM); -}); - -// getClientInfo and getProtocolVersion now use null-safe operator, tests simplify -it('retrieves client info from ClientState', function () { - $clientInfo = ['name' => 'Y']; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->clientInfo = $clientInfo; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - expect($this->stateManagerWithCache->getClientInfo(TEST_CLIENT_ID_CSM))->toBe($clientInfo); - - $this->cache->shouldReceive('get')->with(getClientStateKey('none'))->andReturn(null); - expect($this->stateManagerWithCache->getClientInfo('none'))->toBeNull(); -}); - -// --- Subscriptions --- -it('updates client state and global resource list when a resource subscription is added', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - - // getClientState (create) - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); - // saveClientState - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => isset($s->subscriptions[TEST_URI_CSM_1])), CACHE_TTL_CSM) - ->andReturn(true); - // Global resource sub update - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($resSubKey, [TEST_CLIENT_ID_CSM => true], CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->addResourceSubscription(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1); -}); - -it('updates client state and global resource list when a resource subscription is removed', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - - $initialClientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $initialClientState->addSubscription(TEST_URI_CSM_2); - - // getClientState - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); - // saveClientState (after removing TEST_URI_CSM_1 from client's list) - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => ! isset($s->subscriptions[TEST_URI_CSM_1]) && isset($s->subscriptions[TEST_URI_CSM_2])), CACHE_TTL_CSM) - ->andReturn(true); - // Global resource sub update - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'other' => true]); - $this->cache->shouldReceive('set')->once()->with($resSubKey, ['other' => true], CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->removeResourceSubscription(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1); -}); - -it('clears from ClientState and all global lists when all resource subscriptions are removed', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey1 = getResourceSubscribersKey(TEST_URI_CSM_1); - $resSubKey2 = getResourceSubscribersKey(TEST_URI_CSM_2); - - $initialClientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $initialClientState->addSubscription(TEST_URI_CSM_2); - - // Get client state - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); - // Save client state with empty subscriptions - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => empty($s->subscriptions)), CACHE_TTL_CSM) - ->andReturn(true); - - // Interaction with global resource sub list for URI 1 - $this->cache->shouldReceive('get')->once()->with($resSubKey1, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'other' => true]); - $this->cache->shouldReceive('set')->once()->with($resSubKey1, ['other' => true], CACHE_TTL_CSM)->andReturn(true); - // Interaction with global resource sub list for URI 2 - $this->cache->shouldReceive('get')->once()->with($resSubKey2, [])->andReturn([TEST_CLIENT_ID_CSM => true]); - $this->cache->shouldReceive('delete')->once()->with($resSubKey2)->andReturn(true); // Becomes empty - - $this->stateManagerWithCache->removeAllResourceSubscriptions(TEST_CLIENT_ID_CSM); -}); - -it('can retrieve global resource list', function () { - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'c2' => true]); - expect($this->stateManagerWithCache->getResourceSubscribers(TEST_URI_CSM_1))->toEqualCanonicalizing([TEST_CLIENT_ID_CSM, 'c2']); -}); - -it('can check if a client is subscribed to a resource', function () { - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true]); - - expect($this->stateManagerWithCache->isSubscribedToResource(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1))->toBeTrue(); - expect($this->stateManagerWithCache->isSubscribedToResource('other_client', TEST_URI_CSM_1))->toBeFalse(); -}); - -// --- Message Queue --- -it('can add a message to the client state queue', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $notification = json_encode((new Notification('2.0', 'event'))->toArray()); - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $s) use ($notification) { - return count($s->messageQueue) === 1 && $s->messageQueue[0] == $notification; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->queueMessage(TEST_CLIENT_ID_CSM, $notification); -}); - -it('consumes from ClientState queue and saves', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $messagesData = [json_encode(['method' => 'm1']), json_encode(['method' => 'm2'])]; - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - $initialState->messageQueue = $messagesData; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() // Expect save after consuming - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => empty($s->messageQueue)), CACHE_TTL_CSM) - ->andReturn(true); - - $retrieved = $this->stateManagerWithCache->getQueuedMessages(TEST_CLIENT_ID_CSM); - expect($retrieved)->toEqual($messagesData); -}); - -// --- Log Level Management --- -it('updates client state when log level is set', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $level = 'debug'; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Create new - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => $s->requestedLogLevel === $level), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->setClientRequestedLogLevel(TEST_CLIENT_ID_CSM, $level); -}); - -it('can retrieve client requested log level', function () { - $level = 'info'; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->requestedLogLevel = $level; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - - expect($this->stateManagerWithCache->getClientRequestedLogLevel(TEST_CLIENT_ID_CSM))->toBe($level); - - $this->cache->shouldReceive('get')->with(getClientStateKey('none_set'))->andReturn(new ClientState('none_set')); - expect($this->stateManagerWithCache->getClientRequestedLogLevel('none_set'))->toBeNull(); -}); - -// --- Client Management --- -it('performs all cleanup steps', function ($removeFromActive) { - $clientId = 'client-mgr-cleanup'; - $clientStateKey = getClientStateKey($clientId); - $activeClientsKey = getActiveClientsKey(); - - $initialClientState = new ClientState($clientId); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); // For removeAllResourceSubscriptions - $this->cache->shouldReceive('set')->once()->with($clientStateKey, Mockery::on(fn(ClientState $s) => empty($s->subscriptions)), CACHE_TTL_CSM); // For removeAll... - $resSubKey1 = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($resSubKey1, [])->andReturn([$clientId => true]); - $this->cache->shouldReceive('delete')->once()->with($resSubKey1); // Becomes empty - - $this->cache->shouldReceive('delete')->once()->with($clientStateKey)->andReturn(true); - - if ($removeFromActive) { - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([$clientId => time(), 'other' => time()]); - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::on(fn($arr) => ! isset($arr[$clientId])), CACHE_TTL_CSM)->andReturn(true); - } else { - $this->cache->shouldNotReceive('get')->with($activeClientsKey, []); // Should not touch active list - } - - $this->stateManagerWithCache->cleanupClient($clientId, $removeFromActive); -})->with([ - 'Remove From Active List' => [true], - 'Keep In Active List (manual)' => [false], -]); - -it('updates client state and global list when client activity is updated', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $activeClientsKey = getActiveClientsKey(); - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - $initialActivityTime = $initialState->lastActivityTimestamp; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() // Save ClientState - ->with($clientStateKey, Mockery::on(fn(ClientState $s) => $s->lastActivityTimestamp >= $initialActivityTime), CACHE_TTL_CSM) - ->andReturn(true); - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([]); // Update global - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::on(fn($arr) => $arr[TEST_CLIENT_ID_CSM] >= $initialActivityTime), CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->updateClientActivity(TEST_CLIENT_ID_CSM); -}); - -it('filters and cleans up inactive clients when getting active clients', function () { - $activeKey = getActiveClientsKey(); - $active1 = 'active1'; - $inactive1 = 'inactive1'; - $invalid1 = 'invalid_ts_client'; - $now = time(); - $activeData = [$active1 => $now - 10, $inactive1 => $now - 400, $invalid1 => 'not-a-timestamp']; - $expectedFinalActiveInCache = [$active1 => $activeData[$active1]]; // Only active1 remains - - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeData); - $this->cache->shouldReceive('set')->once()->with($activeKey, $expectedFinalActiveInCache, CACHE_TTL_CSM)->andReturn(true); - - $inactiveClientState = new ClientState($inactive1); - $this->cache->shouldReceive('get')->once()->with(getClientStateKey($inactive1))->andReturn($inactiveClientState); - $this->cache->shouldReceive('delete')->once()->with(getClientStateKey($inactive1)); - - $invalidClientState = new ClientState($invalid1); - $this->cache->shouldReceive('get')->once()->with(getClientStateKey($invalid1))->andReturn($invalidClientState); - $this->cache->shouldReceive('delete')->once()->with(getClientStateKey($invalid1)); - - $result = $this->stateManagerWithCache->getActiveClients(300); - expect($result)->toEqual([$active1]); -}); - -it('can get last activity time', function () { - $activeKey = getActiveClientsKey(); - $now = time(); - $cacheData = [TEST_CLIENT_ID_CSM => $now - 50, 'other' => $now - 100]; - $this->cache->shouldReceive('get')->with($activeKey, [])->times(3)->andReturn($cacheData); - - expect($this->stateManagerWithCache->getLastActivityTime(TEST_CLIENT_ID_CSM))->toBe($now - 50); - expect($this->stateManagerWithCache->getLastActivityTime('other'))->toBe($now - 100); - expect($this->stateManagerWithCache->getLastActivityTime('nonexistent'))->toBeNull(); -}); - -it('gracefully handles cache exception', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey) - ->andThrow(new class() extends \Exception implements CacheInvalidArgumentException {}); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/Error fetching client state from cache/'), Mockery::any()); - - expect($this->stateManagerWithCache->getClientInfo(TEST_CLIENT_ID_CSM))->toBeNull(); -}); diff --git a/tests/Unit/State/ClientStateTest.php b/tests/Unit/State/ClientStateTest.php deleted file mode 100644 index 790936d..0000000 --- a/tests/Unit/State/ClientStateTest.php +++ /dev/null @@ -1,133 +0,0 @@ -lastActivityTimestamp)->toBeGreaterThanOrEqual($startTime); - expect($state->lastActivityTimestamp)->toBeLessThanOrEqual($endTime); -}); - -it('has correct default property values', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - - expect($state->isInitialized)->toBeFalse(); - expect($state->clientInfo)->toBeNull(); - expect($state->protocolVersion)->toBeNull(); - expect($state->subscriptions)->toBe([]); - expect($state->messageQueue)->toBe([]); - expect($state->requestedLogLevel)->toBeNull(); -}); - -it('can add resource subscriptions for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $uri1 = 'file:///doc1.txt'; - $uri2 = 'config://app/settings'; - - $state->addSubscription($uri1); - expect($state->subscriptions)->toHaveKey($uri1); - expect($state->subscriptions[$uri1])->toBeTrue(); - expect($state->subscriptions)->toHaveCount(1); - - $state->addSubscription($uri2); - expect($state->subscriptions)->toHaveKey($uri2); - expect($state->subscriptions[$uri2])->toBeTrue(); - expect($state->subscriptions)->toHaveCount(2); - - // Adding the same URI again should not change the count - $state->addSubscription($uri1); - expect($state->subscriptions)->toHaveCount(2); -}); - -it('can remove a resource subscription for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $uri1 = 'file:///doc1.txt'; - $uri2 = 'config://app/settings'; - - $state->addSubscription($uri1); - $state->addSubscription($uri2); - expect($state->subscriptions)->toHaveCount(2); - - $state->removeSubscription($uri1); - expect($state->subscriptions)->not->toHaveKey($uri1); - expect($state->subscriptions)->toHaveKey($uri2); - expect($state->subscriptions)->toHaveCount(1); - - // Removing a non-existent URI should not cause an error or change count - $state->removeSubscription('nonexistent://uri'); - expect($state->subscriptions)->toHaveCount(1); - - $state->removeSubscription($uri2); - expect($state->subscriptions)->toBeEmpty(); -}); - -it('can clear all resource subscriptions for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $state->addSubscription('file:///doc1.txt'); - $state->addSubscription('config://app/settings'); - expect($state->subscriptions)->not->toBeEmpty(); - - $state->clearSubscriptions(); - expect($state->subscriptions)->toBeEmpty(); -}); - -// --- Message Queue Management --- - -it('can add a message to the queue', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $message1 = json_encode(['jsonrpc' => '2.0', 'method' => 'notify1']); - $message2 = json_encode(['jsonrpc' => '2.0', 'id' => 1, 'result' => []]); - - $state->addMessageToQueue($message1); - expect($state->messageQueue)->toHaveCount(1); - expect($state->messageQueue[0])->toBe($message1); - - $state->addMessageToQueue($message2); - expect($state->messageQueue)->toHaveCount(2); - expect($state->messageQueue[1])->toBe($message2); -}); - -it('can consume all messages from the queue', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $message1 = json_encode(['method' => 'msg1']); - $message2 = json_encode(['method' => 'msg2']); - - $state->addMessageToQueue($message1); - $state->addMessageToQueue($message2); - expect($state->messageQueue)->toHaveCount(2); - - $consumedMessages = $state->consumeMessageQueue(); - expect($consumedMessages)->toBeArray()->toHaveCount(2); - expect($consumedMessages[0])->toBe($message1); - expect($consumedMessages[1])->toBe($message2); - - // Verify the queue is now empty - expect($state->messageQueue)->toBeEmpty(); - expect($state->consumeMessageQueue())->toBeEmpty(); // Consuming an empty queue -}); - -test('public properties can be set and retain values', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - - $state->isInitialized = true; - expect($state->isInitialized)->toBeTrue(); - - $clientInfoData = ['name' => 'Test Client', 'version' => '0.9']; - $state->clientInfo = $clientInfoData; - expect($state->clientInfo)->toBe($clientInfoData); - - $protoVersion = '2024-11-05-test'; - $state->protocolVersion = $protoVersion; - expect($state->protocolVersion)->toBe($protoVersion); - - $logLevel = 'debug'; - $state->requestedLogLevel = $logLevel; - expect($state->requestedLogLevel)->toBe($logLevel); -}); diff --git a/tests/Unit/Support/ArgumentPreparerTest.php b/tests/Unit/Support/ArgumentPreparerTest.php deleted file mode 100644 index cbaa38f..0000000 --- a/tests/Unit/Support/ArgumentPreparerTest.php +++ /dev/null @@ -1,210 +0,0 @@ -loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->preparer = new ArgumentPreparer($this->loggerMock); - $this->stubInstance = new SchemaGeneratorTestStub(); // Instance to reflect on -}); - -// --- Helper --- -function reflectMethod(string $methodName): ReflectionMethod -{ - return new ReflectionMethod(SchemaGeneratorTestStub::class, $methodName); -} - -// --- Basic Argument Matching Tests --- - -test('prepares empty array for method with no parameters', function () { - $method = reflectMethod('noParams'); - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'noParams', []); - expect($args)->toBe([]); -}); - -test('prepares arguments in correct order for simple required types', function () { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => 'hello', - 'p2' => 123, - 'p3' => true, - 'p4' => 4.56, - 'p5' => ['a', 'b'], - 'p6' => new stdClass(), - ]; - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - expect($args)->toBe(['hello', 123, true, 4.56, ['a', 'b'], $input['p6']]); -}); - -test('uses default values when input not provided', function () { - $method = reflectMethod('simpleOptionalDefaults'); - $input = ['p1' => 'provided']; // Only provide p1 - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleOptionalDefaults', $input); - expect($args)->toEqual(['provided', 123, true, 1.23, ['a', 'b'], null]); -}); - -test('handles nullable types without explicit default (passes null)', function () { - $method = reflectMethod('nullableWithoutDefault'); - $input = []; // Provide no input - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'nullableWithoutDefault', $input); - // All params allow null and have no default, so they should receive null - expect($args)->toEqual([null, null, null]); -}); - -test('handles nullable types with explicit null default', function () { - $method = reflectMethod('nullableWithNullDefault'); - $input = []; // Provide no input - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'nullableWithNullDefault', $input); - // Both have explicit null defaults - expect($args)->toEqual([null, null]); -}); - -// --- Type Casting Tests --- - -test('casts valid input values to expected types', function (string $paramName, mixed $inputVal, mixed $expectedVal) { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => '', 'p2' => 0, 'p3' => false, 'p4' => 0.0, 'p5' => [], 'p6' => new stdClass(), // Base values - $paramName => $inputVal, // Use $paramName - ]; - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - - // Find the parameter by name to get its position - $argPosition = -1; - foreach ($method->getParameters() as $p) { - if ($p->getName() === $paramName) { - $argPosition = $p->getPosition(); - break; - } - } - expect($argPosition)->not->toBe(-1, "Parameter {$paramName} not found in method."); // Assert parameter was found - - expect($args[$argPosition])->toEqual($expectedVal); - -})->with([ - ['p1', 123, '123'], // int to string - ['p2', '456', 456], // numeric string to int - ['p2', '-10', -10], // negative numeric string to int - ['p2', 99.0, 99], // float (whole) to int - ['p3', 1, true], // 1 to bool true - ['p3', 'true', true], // 'true' to bool true - ['p3', 0, false], // 0 to bool false - ['p3', 'false', false], // 'false' to bool false - ['p4', '7.89', 7.89], // numeric string to float - ['p4', 10, 10.0], // int to float - ['p5', [1, 2], [1, 2]], // array passes through - ['p6', (object) ['a' => 1], (object) ['a' => 1]], // object passes through -]); - -test('throws McpException for invalid type casting', function (string $paramName, mixed $invalidInput, string $expectedType) { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => '', 'p2' => 0, 'p3' => false, 'p4' => 0.0, 'p5' => [], 'p6' => new stdClass(), // Base values - $paramName => $invalidInput, // Use $paramName - ]; - - $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - -})->throws(McpException::class) - ->with([ - ['p2', 'abc', 'int'], // non-numeric string to int - ['p2', 12.3, 'int'], // non-whole float to int - ['p2', true, 'int'], // bool to int - ['p3', 'yes', 'bool'], // 'yes' to bool - ['p3', 2, 'bool'], // 2 to bool - ['p4', 'xyz', 'float'], // non-numeric string to float - ['p4', false, 'float'], // bool to float - ['p5', 'not_array', 'array'], // string to array - ['p5', 123, 'array'], // int to array - ]); - -test('throws McpException when required argument is missing', function () { - $method = reflectMethod('simpleRequired'); - $input = ['p1' => 'hello']; // Missing p2, p3, etc. - - // Expect logger to be called because this is an invariant violation - $this->loggerMock->shouldReceive('error')->once()->with(Mockery::pattern('/Invariant violation: Missing required argument `p2`/'), Mockery::any()); - - $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - -})->throws(McpException::class, 'Missing required argument `p2`'); // Throws on the first missing one - -// --- Edge Cases --- - -test('handles untyped parameter (passes value through)', function () { - $method = reflectMethod('docBlockOnly'); - $input = ['p1' => 'from_doc', 'p2' => 12345]; // p2 has no type hint - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'docBlockOnly', $input); - expect($args)->toEqual(['from_doc', 12345]); -}); - -// --- Enum Casting Tests (Requires PHP 8.1+) --- - -test('casts valid input values to backed enums', function (string $paramName, mixed $inputVal, mixed $expectedEnumInstance) { - $method = reflectMethod('enumTypes'); // Method with enum parameters - $input = [ - // Provide valid base values for other required params (p1, p2, p3) - 'p1' => 'A', - 'p2' => 1, - 'p3' => 'Yes', // Assuming unit enums aren't handled by casting yet - // Override the param being tested - $paramName => $inputVal, - ]; - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'enumTypes', $input); - - $argPosition = -1; - foreach ($method->getParameters() as $p) { - if ($p->getName() === $paramName) { - $argPosition = $p->getPosition(); - break; - } - } - expect($argPosition)->not->toBe(-1); - - expect($args[$argPosition])->toEqual($expectedEnumInstance); // Use toEqual for enums - -})->with([ - ['p1', 'A', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionA], - ['p1', 'B', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionB], - ['p2', 1, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::First], - ['p2', 2, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::Second], - // p4 is nullable enum - test passing valid value - ['p4', 'A', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionA], - // p5 is optional with default - test passing valid value - ['p5', 2, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::Second], -]); - -test('throws McpException for invalid enum values', function (string $paramName, mixed $invalidValue) { - $method = reflectMethod('enumTypes'); - $input = [ - 'p1' => 'A', 'p2' => 1, 'p3' => 'Yes', // Valid base values - $paramName => $invalidValue, // Override with invalid value - ]; - - $this->preparer->prepareMethodArguments($this->stubInstance, 'enumTypes', $input); - -})->throws(McpException::class) // Expect the wrapped exception - ->with([ - ['p1', 'C'], // Invalid string for BackedStringEnum - ['p2', 3], // Invalid int for BackedIntEnum - ['p1', null], // Null for non-nullable enum - ]); - -// ReflectionParameter::isVariadic() exists, but ArgumentPreparer doesn't use it currently. -// For now, variadics aren't handled by the preparer. diff --git a/tests/Unit/Support/AttributeFinderTest.php b/tests/Unit/Support/AttributeFinderTest.php deleted file mode 100644 index a64e753..0000000 --- a/tests/Unit/Support/AttributeFinderTest.php +++ /dev/null @@ -1,166 +0,0 @@ -finder = new AttributeFinder(); -}); - -// --- Class Attribute Tests --- - -test('getFirstClassAttribute finds first matching attribute', function () { - $reflectionClass = new ReflectionClass(AttributeTestStub::class); - $attributeRefl = $this->finder->getFirstClassAttribute($reflectionClass, TestAttributeOne::class); - - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $attributeInstance = $attributeRefl->newInstance(); - expect($attributeInstance)->toBeInstanceOf(TestAttributeOne::class); - expect($attributeInstance->value)->toBe('class-level'); -}); - -test('getFirstClassAttribute returns null if attribute not found', function () { - $reflectionClass = new ReflectionClass(PlainPhpClass::class); // Class with no attributes - $attributeRefl = $this->finder->getFirstClassAttribute($reflectionClass, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -test('getClassAttributes finds all attributes of a type', function () { - $reflectionClass = new ReflectionClass(AttributeTestStub::class); - $attributes = $this->finder->getClassAttributes($reflectionClass, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributes[0]->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('class-level'); - - $attributesTwo = $this->finder->getClassAttributes($reflectionClass, TestClassOnlyAttribute::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instanceTwo = $attributesTwo[0]->newInstance(); - expect($instanceTwo)->toBeInstanceOf(TestClassOnlyAttribute::class); -}); - -// --- Method Attribute Tests --- - -test('getMethodAttributes finds all attributes of a type', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodTwo'); - $attributes = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('method-two'); - - $attributesTwo = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance2 = $attributesTwo[0]->newInstance(); - expect($instance2)->toBeInstanceOf(TestAttributeTwo::class); - expect($instance2->number)->toBe(2); -}); - -// REMOVED: test 'getMethodAttributes finds all attributes if no type specified' - -test('getMethodAttributes returns empty array if none found', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodThree'); - $attributes = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeOne::class); - expect($attributes)->toBeArray()->toBeEmpty(); -}); - -test('getFirstMethodAttribute finds first matching attribute', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodTwo'); - $attributeRefl = $this->finder->getFirstMethodAttribute($reflectionMethod, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('method-two'); -}); - -test('getFirstMethodAttribute returns null if attribute not found', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodThree'); - $attributeRefl = $this->finder->getFirstMethodAttribute($reflectionMethod, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -// --- Parameter Attribute Tests --- - -test('getParameterAttributes finds all attributes of a type', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodOne'], 'param1'); - $attributes = $this->finder->getParameterAttributes($reflectionParam, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('param-one'); - - $attributesTwo = $this->finder->getParameterAttributes($reflectionParam, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance2 = $attributesTwo[0]->newInstance(); - expect($instance2)->toBeInstanceOf(TestAttributeTwo::class); - expect($instance2->number)->toBe(1); -}); - -// REMOVED: test 'getParameterAttributes finds all attributes if no type specified' - -test('getParameterAttributes returns empty array if none found', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodThree'], 'unattributedParam'); - $attributes = $this->finder->getParameterAttributes($reflectionParam, TestAttributeOne::class); - expect($attributes)->toBeArray()->toBeEmpty(); -}); - -test('getFirstParameterAttribute finds first matching attribute', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodOne'], 'param1'); - $attributeRefl = $this->finder->getFirstParameterAttribute($reflectionParam, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('param-one'); -}); - -test('getFirstParameterAttribute returns null if attribute not found', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodThree'], 'unattributedParam'); - $attributeRefl = $this->finder->getFirstParameterAttribute($reflectionParam, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -// --- Property Attribute Tests --- - -test('getPropertyAttributes finds attribute', function () { - $reflectionProp = new ReflectionProperty(AttributeTestStub::class, 'propertyOne'); - $attributes = $this->finder->getPropertyAttributes($reflectionProp, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('prop-level'); - - $attributesTwo = $this->finder->getPropertyAttributes($reflectionProp, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toBeEmpty(); // TestAttributeTwo not on property -}); - -test('getFirstPropertyAttribute finds attribute', function () { - $reflectionProp = new ReflectionProperty(AttributeTestStub::class, 'propertyOne'); - $attributeRefl = $this->finder->getFirstPropertyAttribute($reflectionProp, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('prop-level'); - - $nullRefl = $this->finder->getFirstPropertyAttribute($reflectionProp, TestAttributeTwo::class); - expect($nullRefl)->toBeNull(); -}); diff --git a/tests/Unit/Support/DiscovererTest.php b/tests/Unit/Support/DiscovererTest.php deleted file mode 100644 index 09a1f50..0000000 --- a/tests/Unit/Support/DiscovererTest.php +++ /dev/null @@ -1,216 +0,0 @@ -registry = Mockery::mock(Registry::class); - /** @var LoggerInterface&MockInterface $logger */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $attributeFinder = new AttributeFinder(); - $docBlockParser = new DocBlockParser($this->logger); - $schemaGenerator = new SchemaGenerator($docBlockParser, $attributeFinder); - - $this->discoverer = new Discoverer( - $this->registry, - $this->logger, - $docBlockParser, - $schemaGenerator, - $attributeFinder, - ); -}); - -afterEach(function () { - cleanupTempDir(); -}); - -test('discovers all element types in a single file', function () { - // Arrange - $filePath = createDiscoveryTestFile('AllElementsStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ToolDefinition && $arg->getName() === 'discovered-tool'; - })); - $this->registry->shouldReceive('registerResource')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceDefinition && $arg->getUri() === 'discovered://resource'; - })); - $this->registry->shouldReceive('registerPrompt')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof PromptDefinition && $arg->getName() === 'discovered-prompt'; - })); - $this->registry->shouldReceive('registerResourceTemplate')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceTemplateDefinition && $arg->getUriTemplate() === 'discovered://template/{id}'; - })); - - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('discovers elements across multiple files', function () { - // Arrange - $file1Path = createDiscoveryTestFile('ToolOnlyStub'); - $file2Path = createDiscoveryTestFile('ResourceOnlyStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn ($arg) => $arg->getName() === 'tool-from-file1')); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Ensure no errors during processing of these files - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles directory with no MCP elements', function () { - // Arrange - createDiscoveryTestFile('PlainPhpClass'); - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles non-existent directory gracefully', function () { - // Arrange - $nonExistentDir = TEST_DISCOVERY_DIR.'/nonexistent'; - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Assert logging - $this->logger->shouldReceive('warning')->with('No valid discovery directories found to scan.', Mockery::any())->twice(); - - // Act - $this->discoverer->discover($nonExistentDir, ['.']); // Base path doesn't exist - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['nonexistent_subdir']); -}); - -test('skips non-instantiable classes and non-public/static/constructor methods', function (string $stubName, int $expectedRegistrations) { - // Arrange - $filePath = createDiscoveryTestFile($stubName); - - if ($expectedRegistrations === 0) { - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - } else { - // Example if one tool is expected (adjust if other types can be expected) - $this->registry->shouldReceive('registerTool')->times($expectedRegistrations); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - } - - // Ensure no processing errors for this file - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); - -})->with([ - 'Abstract class' => ['AbstractStub', 0], - 'Interface' => ['InterfaceStub', 0], - 'Trait' => ['TraitStub', 0], - 'Enum' => ['EnumStub', 0], - 'Static method' => ['StaticMethodStub', 0], - 'Protected method' => ['ProtectedMethodStub', 0], - 'Private method' => ['PrivateMethodStub', 0], - 'Constructor' => ['ConstructorStub', 0], - 'Inherited method' => ['ChildInheriting', 0], // Child has no *declared* methods with attributes - 'Class using Trait' => ['ClassUsingTrait', 1], // Expect the trait method to be found - // Need to also test scanning the parent/trait files directly if needed -]); - -test('handles definition creation error and continues', function () { - // Arrange - $filePath = createDiscoveryTestFile('MixedValidityStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->getName() === 'valid-tool')) - ->once(); - $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->getName() === 'another-valid-tool')) - ->once(); - $this->registry->shouldNotReceive('registerResource'); - - // Ensure no *other* unexpected errors related to this class/methods - $this->logger->shouldNotReceive('error') - ->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles file read error gracefully', function () { - // Arrange - $invalidFile = TEST_DISCOVERY_DIR.'/invalid.php'; - touch($invalidFile); // Create the file - chmod($invalidFile, 0000); // Make it unreadable - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); - - // Cleanup permissions - chmod($invalidFile, 0644); -}); - -test('discovers attributes placed directly on invokable classes', function (string $stubName, string $registryMethod, string $expectedNameOrUri) { - // Arrange - createDiscoveryTestFile($stubName); - - // Assert registry interactions - $this->registry->shouldReceive($registryMethod) - ->once() - ->with(Mockery::on(function ($arg) use ($expectedNameOrUri, $stubName) { - // Check if it's the correct definition type and name/uri - return ($arg instanceof ToolDefinition && $arg->getName() === $expectedNameOrUri) - || ($arg instanceof ResourceDefinition && $arg->getUri() === $expectedNameOrUri) - || ($arg instanceof PromptDefinition && $arg->getName() === $expectedNameOrUri) - || ($arg instanceof ResourceTemplateDefinition && $arg->getUriTemplate() === $expectedNameOrUri) - // Verify the definition points to the __invoke method - && $arg->getMethodName() === '__invoke' - && str_ends_with($arg->getClassName(), $stubName); - })); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -})->with([ - 'Invokable Tool' => ['InvokableToolStub', 'registerTool', 'invokable-tool'], - 'Invokable Resource' => ['InvokableResourceStub', 'registerResource', 'invokable://resource'], - 'Invokable Prompt' => ['InvokablePromptStub', 'registerPrompt', 'invokable-prompt'], - 'Invokable Template' => ['InvokableTemplateStub', 'registerResourceTemplate', 'invokable://template/{id}'], -]); diff --git a/tests/Unit/Support/RequestProcessorTest.php b/tests/Unit/Support/RequestProcessorTest.php deleted file mode 100644 index 792d13b..0000000 --- a/tests/Unit/Support/RequestProcessorTest.php +++ /dev/null @@ -1,260 +0,0 @@ -expect($response)->toBeInstanceOf(Response::class); - test()->expect($response->id)->toBe($id); - test()->expect($response->result)->toBeNull(); - test()->expect($response->error)->toBeInstanceOf(JsonRpcError::class); - test()->expect($response->error->code)->toBe($expectedCode); -} - -beforeEach(function () { - $this->containerMock = Mockery::mock(ContainerInterface::class); - $this->registryMock = Mockery::mock(Registry::class); - $this->clientStateManagerMock = Mockery::mock(ClientStateManager::class); - /** @var LoggerInterface&MockInterface $loggerMock */ - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->schemaValidatorMock = Mockery::mock(SchemaValidator::class); - $this->argumentPreparerMock = Mockery::mock(ArgumentPreparer::class); - $this->cacheMock = Mockery::mock(CacheInterface::class); - - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: Capabilities::forServer(), - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock, - definitionCacheTtl: 3600 - ); - - $this->registryMock->allows('allTools')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allResources')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allResourceTemplates')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allPrompts')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(false)->byDefault(); - - $this->processor = new RequestProcessor( - $this->configuration, - $this->registryMock, - $this->clientStateManagerMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock - ); -}); - -it('can be instantiated', function () { - expect($this->processor)->toBeInstanceOf(RequestProcessor::class); -}); - -it('can handle an initialize request', function () { - $clientInfo = ['name' => 'TestClientProc', 'version' => '1.3.0']; - $request = createRequest('initialize', [ - 'protocolVersion' => SUPPORTED_VERSION_PROC, - 'clientInfo' => $clientInfo, - ]); - - $this->clientStateManagerMock->shouldReceive('storeClientInfo')->once()->with($clientInfo, SUPPORTED_VERSION_PROC, CLIENT_ID_PROC); - - // Mock registry counts to enable capabilities in response - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject(['dummyTool' => new stdClass()])); - $this->registryMock->allows('allResources')->andReturn(new \ArrayObject(['dummyRes' => new stdClass()])); - $this->registryMock->allows('allPrompts')->andReturn(new \ArrayObject(['dummyPrompt' => new stdClass()])); - - // Override default capabilities in the configuration passed to processor for this test - $capabilities = Capabilities::forServer( - toolsEnabled: true, - toolsListChanged: true, - resourcesEnabled: true, - resourcesSubscribe: true, - resourcesListChanged: false, - promptsEnabled: true, - promptsListChanged: true, - loggingEnabled: true, - instructions: 'Test Instructions' - ); - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: $capabilities, - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock - ); - $this->processor = new RequestProcessor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->schemaValidatorMock, $this->argumentPreparerMock); - - /** @var Response $response */ - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expect($response)->toBeInstanceOf(Response::class); - expect($response->id)->toBe($request->id); - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(InitializeResult::class); - expect($response->result->serverInfo['name'])->toBe(SERVER_NAME_PROC); - expect($response->result->serverInfo['version'])->toBe(SERVER_VERSION_PROC); - expect($response->result->protocolVersion)->toBe(SUPPORTED_VERSION_PROC); - expect($response->result->capabilities)->toHaveKeys(['tools', 'resources', 'prompts', 'logging']); - expect($response->result->capabilities['tools'])->toEqual(['listChanged' => true]); - expect($response->result->capabilities['resources'])->toEqual(['subscribe' => true]); - expect($response->result->capabilities['prompts'])->toEqual(['listChanged' => true]); - expect($response->result->capabilities['logging'])->toBeInstanceOf(stdClass::class); - expect($response->result->instructions)->toBe('Test Instructions'); -}); - -it('marks client as initialized when receiving an initialized notification', function () { - $notification = createNotification('notifications/initialized'); - $this->clientStateManagerMock->shouldReceive('markInitialized')->once()->with(CLIENT_ID_PROC); - $response = $this->processor->process($notification, CLIENT_ID_PROC); - expect($response)->toBeNull(); -}); - -it('fails if client not initialized for non-initialize methods', function (string $method) { - $request = createRequest($method); - $response = $this->processor->process($request, CLIENT_ID_PROC); - expectMcpErrorResponse($response, McpServerException::CODE_INVALID_REQUEST); - expect($response->error->message)->toContain('Client not initialized'); -})->with([ - 'tools/list', - 'tools/call', - 'resources/list', // etc. -]); - -it('fails if capability is disabled', function (string $method, array $params, array $enabledCaps) { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - - $capabilities = Capabilities::forServer(...$enabledCaps); - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: $capabilities, - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock - ); - $this->processor = new RequestProcessor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->schemaValidatorMock, $this->argumentPreparerMock); - - $request = createRequest($method, $params); - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expectMcpErrorResponse($response, McpServerException::CODE_METHOD_NOT_FOUND); - expect($response->error->message)->toContain('capability'); - expect($response->error->message)->toContain('is not enabled'); -})->with([ - 'tools/call' => ['tools/call', [], ['toolsEnabled' => false]], - 'resources/read' => ['resources/read', [], ['resourcesEnabled' => false]], - 'resources/subscribe' => ['resources/subscribe', ['uri' => 'https://example.com/resource'], ['resourcesSubscribe' => false]], - 'resources/templates/list' => ['resources/templates/list', [], ['resourcesEnabled' => false]], - 'prompts/list' => ['prompts/list', [], ['promptsEnabled' => false]], - 'prompts/get' => ['prompts/get', [], ['promptsEnabled' => false]], - 'logging/setLevel' => ['logging/setLevel', [], ['loggingEnabled' => false]], -]); - -it('pings successfully for initialized client', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $request = createRequest('ping'); - $response = $this->processor->process($request, CLIENT_ID_PROC); - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(EmptyResult::class); -}); - -it('can list tools using hardcoded limit', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $tool1 = new ToolDefinition('Class', 'm1', 'tool1', 'd1', []); - $tool2 = new ToolDefinition('Class', 'm2', 'tool2', 'd2', []); - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); - - $request = createRequest('tools/list'); - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListToolsResult::class); - expect($response->result->tools)->toHaveCount(2); // Assumes limit >= 2 -}); - -it('can call a tool using the container to get handler', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $toolName = 'myTool'; - $handlerClass = 'App\\Handlers\\MyToolHandler'; - $handlerMethod = 'execute'; - $rawArgs = ['p' => 'v']; - $toolResult = 'Success'; - $definition = Mockery::mock(ToolDefinition::class); - $handlerInstance = Mockery::mock($handlerClass); - - $definition->allows('getClassName')->andReturn($handlerClass); - $definition->allows('getMethodName')->andReturn($handlerMethod); - $definition->allows('getInputSchema')->andReturn([]); - - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); - $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->andReturn([]); - // *** Assert container is used *** - $this->containerMock->shouldReceive('get')->once()->with($handlerClass)->andReturn($handlerInstance); - // ******************************* - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments')->once()->andReturn(['v']); - $handlerInstance->shouldReceive($handlerMethod)->once()->with('v')->andReturn($toolResult); - - // Spy/mock formatToolResult - /** @var RequestProcessor&MockInterface $processorSpy */ - $processorSpy = Mockery::mock(RequestProcessor::class . '[formatToolResult]', [ - $this->configuration, - $this->registryMock, - $this->clientStateManagerMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial()->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatToolResult')->once()->andReturn([new TextContent('Success')]); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => $rawArgs]); - $response = $processorSpy->process($request, CLIENT_ID_PROC); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(CallToolResult::class); -}); diff --git a/tests/Unit/Support/SchemaGeneratorTest.php b/tests/Unit/Support/SchemaGeneratorTest.php deleted file mode 100644 index 6216f5a..0000000 --- a/tests/Unit/Support/SchemaGeneratorTest.php +++ /dev/null @@ -1,472 +0,0 @@ -docBlockParserMock = Mockery::mock(DocBlockParser::class); - $this->schemaGenerator = new SchemaGenerator($this->docBlockParserMock); -}); - -function setupDocBlockExpectations(Mockery\MockInterface $parserMock, ReflectionMethod $method): void -{ - $docComment = $method->getDocComment() ?: ''; - $realDocBlock = $docComment ? DocBlockFactory::createInstance()->create($docComment) : null; - $parserMock->shouldReceive('parseDocBlock')->once()->with($docComment ?: null)->andReturn($realDocBlock); - - $realParamTags = []; - if ($realDocBlock) { - foreach ($realDocBlock->getTagsByName('param') as $tag) { - if ($tag instanceof Param && $tag->getVariableName()) { - $realParamTags['$' . $tag->getVariableName()] = $tag; - } - } - } - $parserMock->shouldReceive('getParamTags')->once()->with($realDocBlock)->andReturn($realParamTags); - - // Set expectations for each parameter based on whether it has a real tag - foreach ($method->getParameters() as $rp) { - $paramName = $rp->getName(); - $tagName = '$' . $paramName; - $tag = $realParamTags[$tagName] ?? null; - - // Mock the calls the generator will make for this specific parameter - $expectedType = $tag ? (string) $tag->getType() : null; - $expectedDesc = $tag ? ($tag->getDescription() ? $tag->getDescription()->render() : null) : null; - - $parserMock->shouldReceive('getParamTypeString')->with($tag)->andReturn($expectedType); - $parserMock->shouldReceive('getParamDescription')->with($tag)->andReturn($expectedDesc); - } -} - - -test('generates empty schema for method with no parameters', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'noParams'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema)->toEqual(['type' => 'object', 'properties' => new \stdClass()]); - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for required simple types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'simpleRequired'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'String param']); - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Int param']); - expect($schema['properties']['p3'])->toEqual(['type' => 'boolean', 'description' => 'Bool param']); - expect($schema['properties']['p4'])->toEqual(['type' => 'number', 'description' => 'Float param']); - expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array param']); - expect($schema['properties']['p6'])->toEqual(['type' => 'object', 'description' => 'Object param']); - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']); -}); - -test('generates schema for optional simple types with defaults', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'simpleOptionalDefaults'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'String param', 'default' => 'default']); - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Int param', 'default' => 123]); - expect($schema['properties']['p3'])->toEqual(['type' => 'boolean', 'description' => 'Bool param', 'default' => true]); - expect($schema['properties']['p4'])->toEqual(['type' => 'number', 'description' => 'Float param', 'default' => 1.23]); - expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array param', 'default' => ['a', 'b']]); - expect($schema['properties']['p6'])->toEqual(['type' => ['null', 'object'], 'description' => 'Object param', 'default' => null]); // Nullable type from reflection - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for nullable types without explicit default', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'nullableWithoutDefault'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']); - expect($schema['properties']['p2'])->toEqual(['type' => ['integer', 'null'], 'description' => 'Nullable int shorthand']); - expect($schema['properties']['p3'])->toEqual(['type' => ['boolean', 'null'], 'description' => 'Nullable bool']); - - // Required because they don't have a default value - expect($schema)->toHaveKey('required'); -}); - -test('generates schema for nullable types with explicit null default', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'nullableWithNullDefault'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // These are optional because they have a default value (null), so not required. - expect($schema['properties']['p1'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string with default', 'default' => null]); - expect($schema['properties']['p2'])->toEqual(['type' => ['integer', 'null'], 'description' => 'Nullable int shorthand with default', 'default' => null]); - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for union types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'unionTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or Int']); // Sorted types - expect($schema['properties']['p2'])->toEqual(['type' => ['boolean', 'null', 'string'], 'description' => 'Bool, String or Null']); // Sorted types - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2']); // Neither has default -}); - -test('generates schema for array types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'arrayTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'array', 'description' => 'Generic array']); // No items info - expect($schema['properties']['p2'])->toEqual(['type' => 'array', 'description' => 'Array of strings (docblock)', 'items' => ['type' => 'string']]); - expect($schema['properties']['p3'])->toEqual(['type' => 'array', 'description' => 'Array of integers (docblock)', 'items' => ['type' => 'integer']]); - // expect($schema['properties']['p4'])->toEqual(['type' => 'array', 'description' => 'Generic array map (docblock)', 'items' => ['type' => 'string']]); // Infers value type - // expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array of enums (docblock)', 'items' => ['type' => 'string']]); // Enum maps to string backing type - expect($schema['properties']['p6'])->toEqual(['type' => 'array', 'description' => 'Array of nullable booleans (docblock)', 'items' => ['type' => 'boolean']]); // Item type bool, nullability on outer? - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']); -}); - -test('generates schema for enum types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'enumTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Backed String Enum - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]); - // Backed Int Enum - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]); - // Unit Enum - expect($schema['properties']['p3'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]); - // Nullable Backed String Enum (No default) - expect($schema['properties']['p4'])->toEqual(['type' => ['string', 'null'], 'description' => 'Nullable backed string enum', 'enum' => ['A', 'B']]); - // Optional Backed Int Enum (With default) - expect($schema['properties']['p5'])->toEqual(['type' => 'integer', 'description' => 'Optional backed int enum', 'enum' => [1, 2], 'default' => 1]); - // Optional Unit Enum (With null default) - expect($schema['properties']['p6'])->toEqual(['type' => ['string', 'null'], 'description' => 'Optional unit enum with null default', 'enum' => ['Yes', 'No'], 'default' => null]); - - // Check required fields (p4, p5, p6 are optional) - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4']); -})->skip(version_compare(PHP_VERSION, '8.1', '<'), 'Enums require PHP 8.1+'); - -test('generates schema for variadic parameters', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'variadicParam'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Variadic params are represented as arrays - expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]); - expect($schema)->not->toHaveKey('required'); // Variadic params are inherently optional -}); - -test('generates schema for mixed type', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'mixedType'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Mixed type results in no 'type' constraint in JSON Schema - expect($schema['properties']['p1'])->toEqual(['description' => 'Mixed type']); - expect($schema['properties']['p2'])->toEqual(['description' => 'Optional mixed type', 'default' => 'hello']); - expect($schema['required'])->toEqualCanonicalizing(['p1']); // p2 has default -}); - -test('generates schema using docblock type when no php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'docBlockOnly'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Only docblock type']); // Type from docblock - expect($schema['properties']['p2'])->toEqual(['description' => 'Only docblock description']); // No type info - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2']); -}); - -test('generates schema using docblock type overriding php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'docBlockOverrides'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Docblock type (@param string) overrides PHP type hint (int) - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Docblock overrides int']); - expect($schema['required'])->toEqualCanonicalizing(['p1']); -}); - -test('generates schema with string format constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'stringConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['email'])->toHaveKey('format') - ->and($schema['properties']['email']['format'])->toBe('email'); - - expect($schema['properties']['password'])->toHaveKey('minLength') - ->and($schema['properties']['password']['minLength'])->toBe(8); - expect($schema['properties']['password'])->toHaveKey('pattern') - ->and($schema['properties']['password']['pattern'])->toBe('^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'); - - // Regular parameter should not have format constraints - expect($schema['properties']['code'])->not->toHaveKey('format'); - expect($schema['properties']['code'])->not->toHaveKey('minLength'); -}); - -test('generates schema with numeric constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'numericConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['age'])->toHaveKey('minimum') - ->and($schema['properties']['age']['minimum'])->toBe(18); - expect($schema['properties']['age'])->toHaveKey('maximum') - ->and($schema['properties']['age']['maximum'])->toBe(120); - - expect($schema['properties']['rating'])->toHaveKey('minimum') - ->and($schema['properties']['rating']['minimum'])->toBe(0); - expect($schema['properties']['rating'])->toHaveKey('maximum') - ->and($schema['properties']['rating']['maximum'])->toBe(5); - expect($schema['properties']['rating'])->toHaveKey('exclusiveMaximum') - ->and($schema['properties']['rating']['exclusiveMaximum'])->toBeTrue(); - - expect($schema['properties']['count'])->toHaveKey('multipleOf') - ->and($schema['properties']['count']['multipleOf'])->toBe(10); -}); - -test('generates schema with array constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'arrayConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['tags'])->toHaveKey('uniqueItems') - ->and($schema['properties']['tags']['uniqueItems'])->toBeTrue(); - expect($schema['properties']['tags'])->toHaveKey('minItems') - ->and($schema['properties']['tags']['minItems'])->toBe(1); - - expect($schema['properties']['scores'])->toHaveKey('minItems') - ->and($schema['properties']['scores']['minItems'])->toBe(1); - expect($schema['properties']['scores'])->toHaveKey('maxItems') - ->and($schema['properties']['scores']['maxItems'])->toBe(5); - expect($schema['properties']['scores'])->toHaveKey('items') - ->and($schema['properties']['scores']['items'])->toHaveKey('minimum') - ->and($schema['properties']['scores']['items']['minimum'])->toBe(0); - expect($schema['properties']['scores']['items'])->toHaveKey('maximum') - ->and($schema['properties']['scores']['items']['maximum'])->toBe(100); - - // Regular array should not have constraints - expect($schema['properties']['mixed'])->not->toHaveKey('minItems'); - expect($schema['properties']['mixed'])->not->toHaveKey('uniqueItems'); -}); - -test('generates schema with object constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'objectConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check properties - expect($schema['properties']['user'])->toHaveKey('properties'); - $properties = $schema['properties']['user']['properties']; - - expect($properties)->toHaveKeys(['name', 'email', 'age']); - expect($properties['name'])->toHaveKey('minLength') - ->and($properties['name']['minLength'])->toBe(2); - expect($properties['email'])->toHaveKey('format') - ->and($properties['email']['format'])->toBe('email'); - expect($properties['age'])->toHaveKey('minimum') - ->and($properties['age']['minimum'])->toBe(18); - - // Check required - expect($schema['properties']['user'])->toHaveKey('required') - ->and($schema['properties']['user']['required'])->toContain('name') - ->and($schema['properties']['user']['required'])->toContain('email'); - - // Check additionalProperties - expect($schema['properties']['config'])->toHaveKey('additionalProperties') - ->and($schema['properties']['config']['additionalProperties'])->toBeTrue(); -}); - -test('generates schema with nested constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'nestedConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check top level properties exist - expect($schema['properties']['order'])->toHaveKey('properties'); - expect($schema['properties']['order']['properties'])->toHaveKeys(['customer', 'items']); - - // Check customer properties - $customer = $schema['properties']['order']['properties']['customer']; - expect($customer)->toHaveKey('properties'); - expect($customer['properties'])->toHaveKeys(['id', 'name']); - expect($customer['properties']['id'])->toHaveKey('pattern'); - expect($customer['required'])->toContain('id'); - - // Check items properties - $items = $schema['properties']['order']['properties']['items']; - expect($items)->toHaveKey('minItems') - ->and($items['minItems'])->toBe(1); - expect($items)->toHaveKey('items'); - - // Check items schema - $itemsSchema = $items['items']; - expect($itemsSchema)->toHaveKey('properties'); - expect($itemsSchema['properties'])->toHaveKeys(['product_id', 'quantity']); - expect($itemsSchema['required'])->toContain('product_id') - ->and($itemsSchema['required'])->toContain('quantity'); - expect($itemsSchema['properties']['product_id'])->toHaveKey('pattern'); - expect($itemsSchema['properties']['quantity'])->toHaveKey('minimum') - ->and($itemsSchema['properties']['quantity']['minimum'])->toBe(1); -}); - -test('respects precedence order between PHP type, DocBlock, and Schema attributes', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'typePrecedenceTest'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Test Case 1: DocBlock type (integer) should override PHP type (string) - // but keep string characteristics (not have integer constraints) - expect($schema['properties']['numericString'])->toHaveKey('type') - ->and($schema['properties']['numericString']['type'])->toBe('integer') - ->and($schema['properties']['numericString'])->not->toHaveKey('format'); // No string format since type is now integer - - // Test Case 2: Schema format should be applied even when type is from PHP/DocBlock - expect($schema['properties']['stringWithFormat'])->toHaveKey('type') - ->and($schema['properties']['stringWithFormat']['type'])->toBe('string') - ->and($schema['properties']['stringWithFormat'])->toHaveKey('format') - ->and($schema['properties']['stringWithFormat']['format'])->toBe('email'); - - // Test Case 3: Schema items constraints should override DocBlock array hint - expect($schema['properties']['arrayWithItems'])->toHaveKey('type') - ->and($schema['properties']['arrayWithItems']['type'])->toBe('array') - ->and($schema['properties']['arrayWithItems'])->toHaveKey('items') - ->and($schema['properties']['arrayWithItems']['items'])->toHaveKey('minimum') - ->and($schema['properties']['arrayWithItems']['items']['minimum'])->toBe(1) - ->and($schema['properties']['arrayWithItems']['items'])->toHaveKey('maximum') - ->and($schema['properties']['arrayWithItems']['items']['maximum'])->toBe(100); -}); - -test('parses simple array[] syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'simpleArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check each parameter type is correctly inferred - expect($schema['properties']['strings'])->toHaveKey('items') - ->and($schema['properties']['strings']['items']['type'])->toBe('string'); - - expect($schema['properties']['integers'])->toHaveKey('items') - ->and($schema['properties']['integers']['items']['type'])->toBe('integer'); - - expect($schema['properties']['booleans'])->toHaveKey('items') - ->and($schema['properties']['booleans']['items']['type'])->toBe('boolean'); - - expect($schema['properties']['floats'])->toHaveKey('items') - ->and($schema['properties']['floats']['items']['type'])->toBe('number'); - - expect($schema['properties']['objects'])->toHaveKey('items') - ->and($schema['properties']['objects']['items']['type'])->toBe('object'); - - expect($schema['properties']['dateTimeInstances'])->toHaveKey('items') - ->and($schema['properties']['dateTimeInstances']['items']['type'])->toBe('object'); -}); - -test('parses array generic syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'genericArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check each parameter type is correctly inferred - expect($schema['properties']['strings'])->toHaveKey('items') - ->and($schema['properties']['strings']['items']['type'])->toBe('string'); - - expect($schema['properties']['integers'])->toHaveKey('items') - ->and($schema['properties']['integers']['items']['type'])->toBe('integer'); - - expect($schema['properties']['booleans'])->toHaveKey('items') - ->and($schema['properties']['booleans']['items']['type'])->toBe('boolean'); - - expect($schema['properties']['floats'])->toHaveKey('items') - ->and($schema['properties']['floats']['items']['type'])->toBe('number'); - - expect($schema['properties']['objects'])->toHaveKey('items') - ->and($schema['properties']['objects']['items']['type'])->toBe('object'); - - expect($schema['properties']['dateTimeInstances'])->toHaveKey('items') - ->and($schema['properties']['dateTimeInstances']['items']['type'])->toBe('object'); -}); - -test('parses nested array syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'nestedArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check for nested arrays with array> syntax - expect($schema['properties']['nestedStringArrays'])->toHaveKey('items') - ->and($schema['properties']['nestedStringArrays']['items'])->toHaveKey('type') - ->and($schema['properties']['nestedStringArrays']['items']['type'])->toBe('array') - ->and($schema['properties']['nestedStringArrays']['items'])->toHaveKey('items') - ->and($schema['properties']['nestedStringArrays']['items']['items']['type'])->toBe('string'); - - // Check for nested arrays with array> syntax - expect($schema['properties']['nestedIntArrays'])->toHaveKey('items') - ->and($schema['properties']['nestedIntArrays']['items'])->toHaveKey('type') - ->and($schema['properties']['nestedIntArrays']['items']['type'])->toBe('array') - ->and($schema['properties']['nestedIntArrays']['items'])->toHaveKey('items') - ->and($schema['properties']['nestedIntArrays']['items']['items']['type'])->toBe('integer'); -}); - -test('parses object-like array syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'objectArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Simple object array - expect($schema['properties']['person'])->toHaveKey('type') - ->and($schema['properties']['person']['type'])->toBe('object'); - expect($schema['properties']['person'])->toHaveKey('properties') - ->and($schema['properties']['person']['properties'])->toHaveKeys(['name', 'age']); - expect($schema['properties']['person']['properties']['name']['type'])->toBe('string'); - expect($schema['properties']['person']['properties']['age']['type'])->toBe('integer'); - expect($schema['properties']['person'])->toHaveKey('required') - ->and($schema['properties']['person']['required'])->toContain('name') - ->and($schema['properties']['person']['required'])->toContain('age'); - - // Object with nested array property - expect($schema['properties']['article'])->toHaveKey('properties') - ->and($schema['properties']['article']['properties'])->toHaveKey('tags') - ->and($schema['properties']['article']['properties']['tags']['type'])->toBe('array') - ->and($schema['properties']['article']['properties']['tags']['items']['type'])->toBe('string'); - - // Complex object with nested object and array - expect($schema['properties']['order'])->toHaveKey('properties') - ->and($schema['properties']['order']['properties'])->toHaveKeys(['user', 'items']); - expect($schema['properties']['order']['properties']['user']['type'])->toBe('object'); - expect($schema['properties']['order']['properties']['user']['properties'])->toHaveKeys(['id', 'name']); - expect($schema['properties']['order']['properties']['items']['type'])->toBe('array') - ->and($schema['properties']['order']['properties']['items']['items']['type'])->toBe('integer'); -}); diff --git a/tests/Unit/Support/UriTemplateMatcherTest.php b/tests/Unit/Support/UriTemplateMatcherTest.php deleted file mode 100644 index d5bfd67..0000000 --- a/tests/Unit/Support/UriTemplateMatcherTest.php +++ /dev/null @@ -1,122 +0,0 @@ -match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - ['user://{userId}', 'user://12345', ['userId' => '12345']], - ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']], - ['user://{userId}', 'user://', null], // Missing variable part - ['user://{userId}', 'users://12345', null], // Wrong scheme/path start - ['item/{itemId}', 'item/xyz', ['itemId' => 'xyz']], // No scheme - ['item/{itemId}', 'item/', null], - ['{onlyVar}', 'anything', ['onlyVar' => 'anything']], - ['{onlyVar}', '', null], -]); - -test('matches multi-variable template', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - [ - 'item/{category}/{itemId}/details', - 'item/books/978-abc/details', - ['category' => 'books', 'itemId' => '978-abc'], - ], - [ - 'item/{category}/{itemId}/details', - 'item/books//details', // Empty itemId segment - null, // Currently matches [^/]+, so empty segment fails - ], - [ - 'item/{category}/{itemId}/details', - 'item/books/978-abc/summary', // Wrong literal end - null, - ], - [ - 'item/{category}/{itemId}', - 'item/tools/hammer', - ['category' => 'tools', 'itemId' => 'hammer'], - ], - [ - 'item/{category}/{itemId}', - 'item/tools/hammer/extra', // Extra path segment - null, - ], -]); - -test('matches template with literals and variables mixed', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - [ - 'user://{userId}/profile/pic_{picId}.jpg', - 'user://kp/profile/pic_main.jpg', - ['userId' => 'kp', 'picId' => 'main'], - ], - [ - 'user://{userId}/profile/pic_{picId}.jpg', - 'user://kp/profile/pic_main.png', // Wrong extension - null, - ], - [ - 'user://{userId}/profile/img_{picId}.jpg', // Wrong literal prefix - 'user://kp/profile/pic_main.jpg', - null, - ], -]); - -test('matches template with no variables', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - // Expect empty array on match, null otherwise - if ($expectedVariables !== null) { - expect($variables)->toBeArray()->toBeEmpty(); - } else { - expect($variables)->toBeNull(); - } - -})->with([ - ['config://settings/app', 'config://settings/app', []], - ['config://settings/app', 'config://settings/user', null], - ['/path/to/resource', '/path/to/resource', []], - ['/path/to/resource', '/path/to/other', null], -]); - -test('handles characters needing escaping in literals', function () { - // Characters like . ? * + ( ) [ ] | are escaped by preg_quote - $template = 'search/{query}/results.json?page={pageNo}'; - $matcher = new UriTemplateMatcher($template); - - $variables = $matcher->match('search/term.with.dots/results.json?page=2'); - expect($variables)->toEqual(['query' => 'term.with.dots', 'pageNo' => '2']); - - $noMatch = $matcher->match('search/term/results.xml?page=1'); // Wrong literal extension - expect($noMatch)->toBeNull(); -}); - -test('constructor compiles regex', function () { - $template = 'test/{id}/value'; - $matcher = new UriTemplateMatcher($template); - - // Use reflection to check the compiled regex (optional, implementation detail) - $reflection = new \ReflectionClass($matcher); - $regexProp = $reflection->getProperty('regex'); - $regexProp->setAccessible(true); - $compiledRegex = $regexProp->getValue($matcher); - - // Expected regex: starts with delimiter, ^, literals escaped, var replaced, $, delimiter - // Example: '#^test\/(?P[^/]+)\/value$#' - expect($compiledRegex)->toBeString()->toContain('^test/') - ->toContain('(?P[^/]+)') - ->toContain('/value$'); -}); diff --git a/tests/Unit/Traits/ResponseFormatterTest.php b/tests/Unit/Traits/ResponseFormatterTest.php deleted file mode 100644 index 84c38fc..0000000 --- a/tests/Unit/Traits/ResponseFormatterTest.php +++ /dev/null @@ -1,326 +0,0 @@ -formatter = new TestFormatterClass(); - /** @var \Mockery\MockInterface&\Psr\Log\LoggerInterface */ - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->formatter->logger = $this->loggerMock; - - // For SplFileInfo test - $this->tempFilePath = tempnam(sys_get_temp_dir(), 'resfmt_'); - file_put_contents($this->tempFilePath, 'splfile test content'); -}); - -afterEach(function () { - // Clean up temp file - if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) { - unlink($this->tempFilePath); - } -}); - -// --- formatToolResult Tests --- - -test('formatToolResult handles scalars', function ($input, $expectedText) { - $result = $this->formatter->formatToolResult($input); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedText); -})->with([ - ['hello world', 'hello world'], - [12345, '12345'], - [98.76, '98.76'], - [true, 'true'], - [false, 'false'], -]); - -test('formatToolResult handles null', function () { - $result = $this->formatter->formatToolResult(null); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe('(null)'); -}); - -test('formatToolResult handles array (JSON encodes)', function () { - $data = ['key' => 'value', 'list' => [1, null, true]]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $result = $this->formatter->formatToolResult($data); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedJson); -}); - -test('formatToolResult handles object (JSON encodes)', function () { - $data = new stdClass(); - $data->key = 'value'; - $data->list = [1, null, true]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $result = $this->formatter->formatToolResult($data); - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedJson); -}); - -test('formatToolResult handles single Content object', function () { - $content = new ImageContent('base64data', 'image/png'); - $result = $this->formatter->formatToolResult($content); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBe($content); -}); - -test('formatToolResult handles array of Content objects', function () { - $contentArray = [new TextContent('one'), new TextContent('two')]; - $result = $this->formatter->formatToolResult($contentArray); - - expect($result)->toBe($contentArray); -}); - -test('formatToolResult throws JsonException for unencodable value', function () { - $resource = fopen('php://memory', 'r'); - $this->formatter->formatToolResult($resource); - if (is_resource($resource)) { - fclose($resource); - } -})->throws(JsonException::class); - -// --- formatToolErrorResult Tests --- - -test('formatToolErrorResult creates correct TextContent', function () { - $exception = new \RuntimeException('Something went wrong'); - $result = $this->formatter->formatToolErrorResult($exception); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe('Tool execution failed: Something went wrong (Type: RuntimeException)'); -}); - -// --- formatResourceContents Tests --- - -test('formatResourceContents handles EmbeddedResource input', function () { - $resource = new EmbeddedResource('test/uri', 'text/plain', 'content'); - $result = $this->formatter->formatResourceContents($resource, 'test/uri', 'text/plain'); - - expect($result)->toBe([$resource]); -}); - -test('formatResourceContents handles ResourceContent input', function () { - $embedded = new EmbeddedResource('test/uri', 'text/plain', 'content'); - $resourceContent = new ResourceContent($embedded); - $result = $this->formatter->formatResourceContents($resourceContent, 'test/uri', 'text/plain'); - - expect($result)->toEqual([$embedded]); -}); - -test('formatResourceContents handles array of EmbeddedResource input', function () { - $resources = [ - new EmbeddedResource('test/uri1', 'text/plain', 'content1'), - new EmbeddedResource('test/uri2', 'image/png', null, 'blob2'), - ]; - $result = $this->formatter->formatResourceContents($resources, 'test/uri', 'text/plain'); - - expect($result)->toBe($resources); -}); - -test('formatResourceContents handles array of ResourceContent input', function () { - $embedded1 = new EmbeddedResource('test/uri1', 'text/plain', 'content1'); - $embedded2 = new EmbeddedResource('test/uri2', 'image/png', null, 'blob2'); - $resourceContents = [new ResourceContent($embedded1), new ResourceContent($embedded2)]; - $result = $this->formatter->formatResourceContents($resourceContents, 'test/uri', 'text/plain'); - - expect($result)->toEqual([$embedded1, $embedded2]); -}); - -test('formatResourceContents handles string input (guessing text mime)', function () { - $result = $this->formatter->formatResourceContents('Simple text', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/plain', 'Simple text')]); -}); - -test('formatResourceContents handles string input (guessing json mime)', function () { - $result = $this->formatter->formatResourceContents('{"key":"value"}', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', '{"key":"value"}')]); -}); - -test('formatResourceContents handles string input (guessing html mime)', function () { - $result = $this->formatter->formatResourceContents('Hi', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/html', 'Hi')]); -}); - -test('formatResourceContents handles string input (with default mime)', function () { - $result = $this->formatter->formatResourceContents('Specific content', 'test/uri', 'text/csv'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/csv', 'Specific content')]); -}); - -test('formatResourceContents handles stream input', function () { - $stream = fopen('php://memory', 'r+'); - fwrite($stream, 'stream content'); - rewind($stream); - - $result = $this->formatter->formatResourceContents($stream, 'test/uri', 'application/pdf'); - - // Stream should be closed after reading - // expect(is_resource($stream))->toBeFalse(); - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->getUri())->toBe('test/uri') - ->and($result[0]->getMimeType())->toBe('application/pdf') - ->and($result[0]->getText())->toBeNull() - ->and($result[0]->getBlob())->toBe(base64_encode('stream content')); -}); - -test('formatResourceContents handles array blob input', function () { - $data = ['blob' => base64_encode('binary'), 'mimeType' => 'image/jpeg']; - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'application/octet-stream'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'image/jpeg', null, $data['blob'])]); -}); - -test('formatResourceContents handles array text input', function () { - $data = ['text' => 'hello', 'mimeType' => 'text/markdown']; - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/markdown', 'hello')]); -}); - -test('formatResourceContents handles SplFileInfo input', function () { - $splFile = new SplFileInfo($this->tempFilePath); - $result = $this->formatter->formatResourceContents($splFile, 'test/uri', 'text/vnd.test'); - $result2 = $this->formatter->formatResourceContents($splFile, 'test/uri', 'image/png'); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->getUri())->toBe('test/uri') - ->and($result[0]->getMimeType())->toBe('text/vnd.test') - ->and($result[0]->getText())->toBe('splfile test content') - ->and($result[0]->getBlob())->toBeNull(); - - expect($result2)->toBeArray()->toHaveCount(1) - ->and($result2[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result2[0]->getUri())->toBe('test/uri') - ->and($result2[0]->getMimeType())->toBe('image/png') - ->and($result2[0]->getText())->toBeNull() - ->and($result2[0]->getBlob())->toBe(base64_encode('splfile test content')); -}); - -test('formatResourceContents handles array input (json mime)', function () { - $data = ['a' => 1]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'application/json'); - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', $expectedJson)]); -}); - -test('formatResourceContents handles array input (non-json mime, logs warning)', function () { - $data = ['b' => 2]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); - $this->loggerMock->shouldReceive('warning')->once()->with('MCP SDK: Automatically converted array to JSON for resource', Mockery::any()); - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); - // It should convert to JSON and use application/json mime type - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', $expectedJson)]); -}); - -test('formatResourceContents throws exception for unformattable type', function () { - $data = new stdClass(); // Simple object not handled directly - $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); -})->throws(\RuntimeException::class, 'Cannot format resource read result'); - -// --- formatPromptMessages Tests --- - -test('formatPromptMessages handles array of PromptMessage input', function () { - $messages = [PromptMessage::user('Hi'), PromptMessage::assistant('Hello')]; - $result = $this->formatter->formatPromptMessages($messages); - expect($result)->toBe($messages); -}); - -test('formatPromptMessages handles simple role=>text array', function () { - $input = ['user' => 'User input', 'assistant' => 'Assistant reply']; - $expected = [PromptMessage::user('User input'), PromptMessage::assistant('Assistant reply')]; - $result = $this->formatter->formatPromptMessages($input); - expect($result)->toEqual($expected); -}); - -test('formatPromptMessages handles list of [role, content] arrays', function () { - $input = [ - ['role' => 'user', 'content' => 'First turn'], - ['role' => 'assistant', 'content' => 'Okay'], - ['role' => 'user', 'content' => new TextContent('Use content obj')], - ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Use text obj']], - ['role' => 'user', 'content' => ['type' => 'image', 'mimeType' => 'image/png', 'data' => 'abc']], - ['role' => 'assistant', 'content' => ['type' => 'audio', 'mimeType' => 'audio/mpeg', 'data' => 'def']], - ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/1', 'text' => 'res text']]], - ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/2', 'blob' => 'ghi', 'mimeType' => 'app/bin']]], - ]; - $expected = [ - PromptMessage::user('First turn'), - PromptMessage::assistant('Okay'), - new PromptMessage('user', new TextContent('Use content obj')), - new PromptMessage('assistant', new TextContent('Use text obj')), - new PromptMessage('user', new ImageContent('abc', 'image/png')), - new PromptMessage('assistant', new AudioContent('def', 'audio/mpeg')), - new PromptMessage('user', new ResourceContent(new EmbeddedResource('res/1', 'text/plain', 'res text'))), - new PromptMessage('assistant', new ResourceContent(new EmbeddedResource('res/2', 'app/bin', null, 'ghi'))), - ]; - $result = $this->formatter->formatPromptMessages($input); - expect($result)->toEqual($expected); -}); - -test('formatPromptMessages throws for non-array input', function () { - $this->formatter->formatPromptMessages('not an array'); -})->throws(\RuntimeException::class, 'must return an array of messages'); - -test('formatPromptMessages throws for non-list array input', function () { - $this->formatter->formatPromptMessages(['a' => 'b']); // Assoc array -})->throws(\RuntimeException::class, 'must return a list (sequential array)'); - -test('formatPromptMessages throws for invalid message structure (missing role)', function () { - $this->formatter->formatPromptMessages([['content' => 'text']]); -})->throws(\RuntimeException::class, 'Expected a PromptMessage or an array with \'role\' and \'content\''); - -test('formatPromptMessages throws for invalid role', function () { - $this->formatter->formatPromptMessages([['role' => 'system', 'content' => 'text']]); -})->throws(\RuntimeException::class, 'Invalid role \'system\''); - -test('formatPromptMessages throws for invalid content type', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'video', 'url' => '...']]]); -})->throws(\RuntimeException::class, "Invalid content type 'video'"); - -test('formatPromptMessages throws for invalid resource content (missing uri)', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['text' => '...']]]]); -})->throws(\RuntimeException::class, "Missing or invalid 'uri'"); - -test('formatPromptMessages throws for invalid resource content (missing text/blob)', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/1']]]]); -})->throws(\RuntimeException::class, "Must contain 'text' or 'blob'"); diff --git a/tests/Unit/Transports/HttpServerTransportTest.php b/tests/Unit/Transports/HttpServerTransportTest.php deleted file mode 100644 index cd5333d..0000000 --- a/tests/Unit/Transports/HttpServerTransportTest.php +++ /dev/null @@ -1,418 +0,0 @@ -loop = Loop::get(); - /** @var LoggerInterface&MockInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->transport = new HttpServerTransport(HOST, PORT, PREFIX); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); - - // Extract the request handler logic for direct testing - $reflector = new \ReflectionClass($this->transport); - $method = $reflector->getMethod('createRequestHandler'); - $method->setAccessible(true); - $this->requestHandler = $method->invoke($this->transport); - - // Reset internal state relevant to tests - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, []); - - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - $listeningProp->setValue($this->transport, true); - - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - $closingProp->setValue($this->transport, false); - - $socketProp = $reflector->getProperty('socket'); - $socketProp->setAccessible(true); - $socketProp->setValue($this->transport, null); - - $httpProp = $reflector->getProperty('http'); - $httpProp->setAccessible(true); - $httpProp->setValue($this->transport, null); -}); - -// --- Teardown --- -afterEach(function () { - $reflector = new \ReflectionClass($this->transport); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - if (! $closingProp->getValue($this->transport)) { - $this->transport->close(); - } - Mockery::close(); -}); - -function createMockRequest( - string $method, - string $path, - array $queryParams = [], - string $bodyContent = '' -): MockInterface&ServerRequestInterface { - - $uriMock = Mockery::mock(UriInterface::class); - - $currentPath = $path; - $currentQuery = http_build_query($queryParams); - - $uriMock->shouldReceive('getPath')->andReturnUsing(function () use (&$currentPath) { - return $currentPath; - })->byDefault(); - - $uriMock->shouldReceive('getQuery')->andReturnUsing(function () use (&$currentQuery) { - return $currentQuery; - })->byDefault(); - - $uriMock->shouldReceive('withPath')->andReturnUsing( - function (string $newPath) use (&$currentPath, $uriMock) { - $currentPath = $newPath; - - return $uriMock; - } - ); - - $uriMock->shouldReceive('withQuery')->andReturnUsing( - function (string $newQuery) use (&$currentQuery, $uriMock) { - $currentQuery = $newQuery; - - return $uriMock; - } - ); - - $uriMock->shouldReceive('withFragment')->andReturnSelf()->byDefault(); - $uriMock->shouldReceive('__toString')->andReturnUsing( - function () use (&$currentPath, &$currentQuery) { - return BASE_URL.$currentPath.($currentQuery ? '?'.$currentQuery : ''); - } - )->byDefault(); - - // Mock Request object - $requestMock = Mockery::mock(ServerRequestInterface::class); - $requestMock->shouldReceive('getMethod')->andReturn($method); - $requestMock->shouldReceive('getUri')->andReturn($uriMock); - $requestMock->shouldReceive('getQueryParams')->andReturn($queryParams); - $requestMock->shouldReceive('getHeaderLine')->with('Content-Type')->andReturn('application/json')->byDefault(); - $requestMock->shouldReceive('getHeaderLine')->with('User-Agent')->andReturn('PHPUnit Test')->byDefault(); - $requestMock->shouldReceive('getServerParams')->withNoArgs()->andReturn(['REMOTE_ADDR' => '127.0.0.1'])->byDefault(); - - // Use BufferedBody for PSR-7 compatibility - $bodyStream = new BufferedBody($bodyContent); - $requestMock->shouldReceive('getBody')->withNoArgs()->andReturn($bodyStream)->byDefault(); - - return $requestMock; -} - -// --- Tests --- - -test('implements correct interfaces', function () { - expect($this->transport) - ->toBeInstanceOf(ServerTransportInterface::class) - ->toBeInstanceOf(LoggerAwareInterface::class) - ->toBeInstanceOf(LoopAwareInterface::class); -}); - -test('request handler returns 404 for unknown paths', function () { - $request = createMockRequest('GET', '/unknown/path'); - $response = ($this->requestHandler)($request); - - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(404); -}); - -// --- SSE Request Handling --- -test('handler handles GET SSE request, emits connected, returns stream response', function () { - $request = createMockRequest('GET', SSE_PATH); - $connectedClientId = null; - $this->transport->on('client_connected', function ($id) use (&$connectedClientId) { - $connectedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - - // Assert Response - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(200); - expect($response->getHeaderLine('Content-Type'))->toContain('text/event-stream'); - $body = $response->getBody(); - expect($body)->toBeInstanceOf(ReadableStreamInterface::class); - - // Assert internal state - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streams = $streamsProp->getValue($this->transport); - expect($streams)->toBeArray()->toHaveCount(1); - $actualClientId = array_key_first($streams); - expect($actualClientId)->toBeString()->toStartWith('sse_'); - expect($streams[$actualClientId])->toBeInstanceOf(ReadableStreamInterface::class); - - // Assert event emission and initial SSE event send (needs loop tick) - $endpointSent = false; - $streams[$actualClientId]->on('data', function ($chunk) use (&$endpointSent, $actualClientId) { - if (str_contains($chunk, 'event: endpoint') && str_contains($chunk, "clientId={$actualClientId}")) { - $endpointSent = true; - } - }); - - $this->loop->addTimer(0.1, fn () => $this->loop->stop()); - $this->loop->run(); - - expect($connectedClientId)->toBe($actualClientId); - expect($endpointSent)->toBeTrue(); - -})->group('usesLoop'); - -test('handler cleans up SSE resources on stream close', function () { - $request = createMockRequest('GET', SSE_PATH); - - $disconnectedClientId = null; - $this->transport->on('client_disconnected', function ($id) use (&$disconnectedClientId) { - $disconnectedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - /** @var ThroughStream $sseStream */ - $sseStream = $response->getBody(); - - // Get client ID - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $clientId = array_key_first($streamsProp->getValue($this->transport)); - expect($clientId)->toBeString(); // Ensure client ID exists - - // Simulate stream closing - $this->loop->addTimer(0.01, fn () => $sseStream->close()); - $this->loop->addTimer(0.02, fn () => $this->loop->stop()); - $this->loop->run(); - - // Assert - expect($disconnectedClientId)->toBe($clientId); - expect($streamsProp->getValue($this->transport))->toBeEmpty(); - -})->group('usesLoop'); - -// --- POST Request Handling --- -test('handler handles POST message, emits message, returns 202', function () { - $clientId = 'sse_client_for_post_ok'; - $messagePayload = '{"jsonrpc":"2.0","method":"test"}'; - - $mockSseStream = new ThroughStream(); - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $mockSseStream]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId], $messagePayload); - - $emittedMessage = null; - $emittedClientId = null; - $this->transport->on('message', function ($msg, $id) use (&$emittedMessage, &$emittedClientId) { - $emittedMessage = $msg; - $emittedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - - // Assert - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(202); - expect($emittedMessage)->toBe($messagePayload); - expect($emittedClientId)->toBe($clientId); - -})->group('usesLoop'); - -test('handler returns 400 for POST with missing clientId', function () { - $request = createMockRequest('POST', MSG_PATH); - $response = ($this->requestHandler)($request); // Call handler directly - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(400); - // Reading body requires async handling if it's a real stream - // expect($response->getBody()->getContents())->toContain('Missing or invalid clientId'); -}); - -test('handler returns 404 for POST with unknown clientId', function () { - $request = createMockRequest('POST', MSG_PATH, ['clientId' => 'unknown']); - $response = ($this->requestHandler)($request); - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(404); -}); - -test('handler returns 415 for POST with wrong Content-Type', function () { - $clientId = 'sse_client_wrong_ct'; - $mockSseStream = new ThroughStream(); // Simulate client connected - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $mockSseStream]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId]); - $request->shouldReceive('getHeaderLine')->with('Content-Type')->andReturn('text/plain'); - - $response = ($this->requestHandler)($request); - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(415); -}); - -test('handler returns 400 for POST with empty body', function () { - $clientId = 'sse_client_empty_body'; - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => new ThroughStream()]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId]); - - // Act - $response = ($this->requestHandler)($request); - - // Assert - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(400); - expect($response->getBody()->getContents())->toContain('Empty request body'); -})->group('usesLoop'); - -// --- sendToClientAsync Tests --- - -test('sendToClientAsync() writes SSE event correctly', function () { - $clientId = 'sse_send_test'; - $messageJson = '{"id":99,"result":"ok"}'; - $expectedSseFrame = "event: message\ndata: {\"id\":99,\"result\":\"ok\"}\n\n"; - - $sseStream = new ThroughStream(); // Use ThroughStream for testing - $receivedData = ''; - $sseStream->on('data', function ($chunk) use (&$receivedData) { - $receivedData .= $chunk; - }); - - // Inject the stream - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $sseStream]); - - // Act - $promise = $this->transport->sendToClientAsync($clientId, $messageJson."\n"); - - // Assert - await($promise); // Wait for promise (write is synchronous on ThroughStream if buffer allows) - expect($receivedData)->toBe($expectedSseFrame); - -})->group('usesLoop'); - -test('sendToClientAsync() rejects if client not found', function () { - $promise = $this->transport->sendToClientAsync('non_existent_sse', '{}'); - $rejected = false; - $promise->catch(function (TransportException $e) use (&$rejected) { - expect($e->getMessage())->toContain('Client \'non_existent_sse\' not connected'); - $rejected = true; - }); - // Need await or loop->run() if the rejection isn't immediate - await($promise); // Await handles loop - expect($rejected)->toBeTrue(); // Assert rejection happened -})->throws(TransportException::class); // Also assert exception type - -test('sendToClientAsync() rejects if stream not writable', function () { - $clientId = 'sse_closed_stream'; - $sseStream = new ThroughStream(); - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $sseStream]); - $sseStream->close(); // Close the stream - - $promise = $this->transport->sendToClientAsync($clientId, '{}'); - $rejected = false; - $promise->catch(function (TransportException $e) use (&$rejected) { - expect($e->getMessage())->toContain('not writable'); - $rejected = true; - }); - await($promise); // Await handles loop - expect($rejected)->toBeTrue(); // Assert rejection happened -})->throws(TransportException::class); - -// --- close() Test --- - -test('close() closes active streams and sets state', function () { - $sseStream1 = new ThroughStream(); - $sseStream2 = new ThroughStream(); - $s1Closed = false; - $s2Closed = false; - - $sseStream1->on('close', function () use (&$s1Closed) { - $s1Closed = true; - }); - $sseStream2->on('close', function () use (&$s2Closed) { - $s2Closed = true; - }); - - // Inject state, set socket to null as we are not mocking it - $reflector = new \ReflectionClass($this->transport); - - $socketProp = $reflector->getProperty('socket'); - $socketProp->setAccessible(true); - $socketProp->setValue($this->transport, null); - - $httpProp = $reflector->getProperty('http'); - $httpProp->setAccessible(true); - $httpProp->setValue($this->transport, null); - - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, ['c1' => $sseStream1, 'c2' => $sseStream2]); - - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - $listeningProp->setValue($this->transport, true); - - $closeEmitted = false; - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - // Act - $this->transport->close(); - - // Assert - expect($closeEmitted)->toBeTrue(); - expect($socketProp->getValue($this->transport))->toBeNull(); - expect($streamsProp->getValue($this->transport))->toBeEmpty(); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - expect($closingProp->getValue($this->transport))->toBeTrue(); - expect($listeningProp->getValue($this->transport))->toBeFalse(); - expect($s1Closed)->toBeTrue(); - expect($s2Closed)->toBeTrue(); - -})->group('usesLoop'); diff --git a/tests/Unit/Transports/StdioServerTransportTest.php b/tests/Unit/Transports/StdioServerTransportTest.php deleted file mode 100644 index 3fcd3c6..0000000 --- a/tests/Unit/Transports/StdioServerTransportTest.php +++ /dev/null @@ -1,254 +0,0 @@ -loop = Loop::get(); - /** @var LoggerInterface|MockInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->transport = new StdioServerTransport(); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); - - $this->inputStreamResource = fopen('php://memory', 'r+'); - $this->outputStreamResource = fopen('php://memory', 'r+'); - - $this->transport = new StdioServerTransport($this->inputStreamResource, $this->outputStreamResource); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); -}); - -// --- Teardown --- -afterEach(function () { - if (is_resource($this->inputStreamResource)) { - fclose($this->inputStreamResource); - } - if (is_resource($this->outputStreamResource)) { - fclose($this->outputStreamResource); - } - - $reflector = new \ReflectionClass($this->transport); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - if (! $closingProp->getValue($this->transport)) { - $this->transport->close(); - } - Mockery::close(); -}); - -// --- Tests --- - -test('implements correct interfaces', function () { - expect($this->transport) - ->toBeInstanceOf(ServerTransportInterface::class) - ->toBeInstanceOf(LoggerAwareInterface::class) - ->toBeInstanceOf(LoopAwareInterface::class); -}); - -test('listen() attaches listeners and emits ready/connected', function () { - $readyEmitted = false; - $connectedClientId = null; - - $this->transport->on('ready', function () use (&$readyEmitted) { - $readyEmitted = true; - }); - $this->transport->on('client_connected', function ($clientId) use (&$connectedClientId) { - $connectedClientId = $clientId; - }); - - // Act - $this->transport->listen(); - - // Assert internal state - $reflector = new \ReflectionClass($this->transport); - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - expect($listeningProp->getValue($this->transport))->toBeTrue(); - $stdinProp = $reflector->getProperty('stdin'); - $stdinProp->setAccessible(true); - expect($stdinProp->getValue($this->transport))->toBeInstanceOf(\React\Stream\ReadableResourceStream::class); - $stdoutProp = $reflector->getProperty('stdout'); - $stdoutProp->setAccessible(true); - expect($stdoutProp->getValue($this->transport))->toBeInstanceOf(\React\Stream\WritableResourceStream::class); - - // Assert events were emitted (these are synchronous in listen setup) - expect($readyEmitted)->toBeTrue(); - expect($connectedClientId)->toBe('stdio'); - - // Clean up the streams created by listen() if they haven't been closed by other means - $this->transport->close(); -}); - -test('listen() throws exception if already listening', function () { - $this->transport->listen(); - $this->transport->listen(); -})->throws(TransportException::class, 'Stdio transport is already listening.'); - -test('receiving data emits message event per line', function () { - $emittedMessages = []; - $this->transport->on('message', function ($message, $clientId) use (&$emittedMessages) { - $emittedMessages[] = ['message' => $message, 'clientId' => $clientId]; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - // Act - $line1 = '{"jsonrpc":"2.0", "id":1, "method":"ping"}'; - $line2 = '{"jsonrpc":"2.0", "method":"notify"}'; - $stdinStream->emit('data', [$line1."\n".$line2."\n"]); - - // Assert - expect($emittedMessages)->toHaveCount(2); - expect($emittedMessages[0]['message'])->toBe($line1); - expect($emittedMessages[0]['clientId'])->toBe('stdio'); - expect($emittedMessages[1]['message'])->toBe($line2); - expect($emittedMessages[1]['clientId'])->toBe('stdio'); -}); - -test('receiving partial data does not emit message', function () { - $messageEmitted = false; - $this->transport->on('message', function () use (&$messageEmitted) { - $messageEmitted = true; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - $stdinStream->emit('data', ['{"jsonrpc":"2.0", "id":1']); - - expect($messageEmitted)->toBeFalse(); -})->group('usesLoop'); - -test('receiving buffered data emits messages correctly', function () { - $emittedMessages = []; - $this->transport->on('message', function ($message, $clientId) use (&$emittedMessages) { - $emittedMessages[] = ['message' => $message, 'clientId' => $clientId]; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - // Write part 1 - $stdinStream->emit('data', ["{\"id\":1}\n{\"id\":2"]); - expect($emittedMessages)->toHaveCount(1); - expect($emittedMessages[0]['message'])->toBe('{"id":1}'); - - // Write part 2 - $stdinStream->emit('data', ["}\n{\"id\":3}\n"]); - expect($emittedMessages)->toHaveCount(3); - expect($emittedMessages[1]['message'])->toBe('{"id":2}'); - expect($emittedMessages[2]['message'])->toBe('{"id":3}'); - -})->group('usesLoop'); - -test('sendToClientAsync() rejects if closed', function () { - $this->transport->listen(); - $this->transport->close(); // Close it first - - $promise = $this->transport->sendToClientAsync('stdio', "{}\n"); - await($promise); - -})->throws(TransportException::class, 'Stdio transport is closed'); - -test('sendToClientAsync() rejects for invalid client ID', function () { - $this->transport->listen(); - $promise = $this->transport->sendToClientAsync('invalid_client', "{}\n"); - await($promise); - -})->throws(TransportException::class, 'Invalid clientId'); - -test('close() closes streams and emits close event', function () { - $this->transport->listen(); // Setup streams internally - - $closeEmitted = false; - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - // Get stream instances after listen() - $reflector = new \ReflectionClass($this->transport); - $stdinStream = $reflector->getProperty('stdin')->getValue($this->transport); - $stdoutStream = $reflector->getProperty('stdout')->getValue($this->transport); - - $stdinClosed = false; - $stdoutClosed = false; - $stdinStream->on('close', function () use (&$stdinClosed) { - $stdinClosed = true; - }); - $stdoutStream->on('close', function () use (&$stdoutClosed) { - $stdoutClosed = true; - }); - - // Act - $this->transport->close(); - - // Assert internal state - expect($reflector->getProperty('stdin')->getValue($this->transport))->toBeNull(); - expect($reflector->getProperty('stdout')->getValue($this->transport))->toBeNull(); - expect($reflector->getProperty('closing')->getValue($this->transport))->toBeTrue(); - expect($reflector->getProperty('listening')->getValue($this->transport))->toBeFalse(); - - // Assert event emission - expect($closeEmitted)->toBeTrue(); - - // Assert streams were closed (via events) - expect($stdinClosed)->toBeTrue(); - expect($stdoutClosed)->toBeTrue(); -}); - -test('stdin close event emits client_disconnected and closes transport', function () { - $disconnectedClientId = null; - $closeEmitted = false; - - $this->transport->on('client_disconnected', function ($clientId) use (&$disconnectedClientId) { - $disconnectedClientId = $clientId; - }); - - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStream = $reflector->getProperty('stdin')->getValue($this->transport); - - $stdinStream->close(); - - $this->loop->addTimer(0.01, fn () => $this->loop->stop()); - $this->loop->run(); - - // Assert - expect($disconnectedClientId)->toBe('stdio'); - expect($closeEmitted)->toBeTrue(); - - expect($reflector->getProperty('closing')->getValue($this->transport))->toBeTrue(); - -})->group('usesLoop'); diff --git a/tests/Unit/Support/DocBlockParserTest.php b/tests/Unit/Utils/DocBlockParserTest.php similarity index 75% rename from tests/Unit/Support/DocBlockParserTest.php rename to tests/Unit/Utils/DocBlockParserTest.php index dd81c12..da36d45 100644 --- a/tests/Unit/Support/DocBlockParserTest.php +++ b/tests/Unit/Utils/DocBlockParserTest.php @@ -1,63 +1,48 @@ containerMock = Mockery::mock(ContainerInterface::class); - $this->loggerMock = Mockery::mock(LoggerInterface::class); - - $this->parser = new DocBlockParser($this->loggerMock); + $this->parser = new DocBlockParser(); }); -// Helper function to get reflection method -function getStubMethod(string $methodName): ReflectionMethod -{ - return new ReflectionMethod(DocBlockTestStub::class, $methodName); -} - -// --- Test Cases --- - test('getSummary returns correct summary', function () { - $method = getStubMethod('methodWithSummaryOnly'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($this->parser->getSummary($docBlock))->toBe('Simple summary line.'); - $method2 = getStubMethod('methodWithSummaryAndDescription'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getSummary($docBlock2))->toBe('Summary line here.'); }); test('getDescription returns correct description', function () { - $method = getStubMethod('methodWithSummaryAndDescription'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; expect($this->parser->getDescription($docBlock))->toBe($expectedDesc); - // Test method with only summary returns summary only - $method2 = getStubMethod('methodWithSummaryOnly'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getDescription($docBlock2))->toBe('Simple summary line.'); }); test('getParamTags returns structured param info', function () { - $method = getStubMethod('methodWithParams'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $params = $this->parser->getParamTags($docBlock); @@ -65,7 +50,6 @@ function getStubMethod(string $methodName): ReflectionMethod expect($params)->toBeArray()->toHaveCount(6); expect($params)->toHaveKeys(['$param1', '$param2', '$param3', '$param4', '$param5', '$param6']); - // Check structure and content using parser methods expect($params['$param1'])->toBeInstanceOf(Param::class); expect($params['$param1']->getVariableName())->toBe('param1'); expect($this->parser->getParamTypeString($params['$param1']))->toBe('string'); @@ -79,11 +63,11 @@ function getStubMethod(string $methodName): ReflectionMethod expect($params['$param3'])->toBeInstanceOf(Param::class); expect($params['$param3']->getVariableName())->toBe('param3'); expect($this->parser->getParamTypeString($params['$param3']))->toBe('bool'); - expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); // No description provided + expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); expect($params['$param4'])->toBeInstanceOf(Param::class); expect($params['$param4']->getVariableName())->toBe('param4'); - expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); // No type provided + expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); expect($this->parser->getParamDescription($params['$param4']))->toBe('Missing type.'); expect($params['$param5'])->toBeInstanceOf(Param::class); @@ -98,7 +82,7 @@ function getStubMethod(string $methodName): ReflectionMethod }); test('getReturnTag returns structured return info', function () { - $method = getStubMethod('methodWithReturn'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithReturn'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $returnTag = $this->parser->getReturnTag($docBlock); @@ -107,46 +91,41 @@ function getStubMethod(string $methodName): ReflectionMethod expect($this->parser->getReturnTypeString($returnTag))->toBe('string'); expect($this->parser->getReturnDescription($returnTag))->toBe('The result of the operation.'); - // Test method without return tag - $method2 = getStubMethod('methodWithSummaryOnly'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getReturnTag($docBlock2))->toBeNull(); }); test('getTagsByName returns specific tags', function () { - $method = getStubMethod('methodWithMultipleTags'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($docBlock)->toBeInstanceOf(DocBlock::class); - // Test finding @throws using DocBlock object $throwsTags = $docBlock->getTagsByName('throws'); expect($throwsTags)->toBeArray()->toHaveCount(1); expect($throwsTags[0])->toBeInstanceOf(Throws::class); expect((string) $throwsTags[0]->getType())->toBe('\\RuntimeException'); expect($throwsTags[0]->getDescription()->render())->toBe('If processing fails.'); - // Test finding @deprecated $deprecatedTags = $docBlock->getTagsByName('deprecated'); expect($deprecatedTags)->toBeArray()->toHaveCount(1); expect($deprecatedTags[0])->toBeInstanceOf(Deprecated::class); expect($deprecatedTags[0]->getDescription()->render())->toBe('Use newMethod() instead.'); - // Test finding @see $seeTags = $docBlock->getTagsByName('see'); expect($seeTags)->toBeArray()->toHaveCount(1); expect($seeTags[0])->toBeInstanceOf(See::class); - expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestStub::newMethod()'); + expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestFixture::newMethod()'); - // Test finding non-existent tag $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); expect($nonExistentTags)->toBeArray()->toBeEmpty(); }); test('handles method with no docblock gracefully', function () { - $method = getStubMethod('methodWithNoDocBlock'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); @@ -157,12 +136,3 @@ function getStubMethod(string $methodName): ReflectionMethod expect($this->parser->getParamTags($docBlock))->toBeArray()->toBeEmpty(); expect($this->parser->getReturnTag($docBlock))->toBeNull(); }); - -// Optional: Test malformed docblock if specific behavior is expected -// test('handles malformed docblock', function () { -// $method = getStubMethod('methodWithMalformedDocBlock'); -// $docComment = $method->getDocComment() ?: null; -// // Add assertions based on expected outcome (parser should return null) -// $docBlock = $this->parser->parseDocBlock($docComment); -// expect($docBlock)->toBeNull(); -// }); diff --git a/tests/Unit/Support/HandlerResolverTest.php b/tests/Unit/Utils/HandlerResolverTest.php similarity index 76% rename from tests/Unit/Support/HandlerResolverTest.php rename to tests/Unit/Utils/HandlerResolverTest.php index ad080c5..c0b574c 100644 --- a/tests/Unit/Support/HandlerResolverTest.php +++ b/tests/Unit/Utils/HandlerResolverTest.php @@ -1,27 +1,43 @@ toBe(ValidHandlerClass::class); - expect($resolved['methodName'])->toBe('publicMethod'); - expect($resolved['reflectionMethod'])->toBeInstanceOf(ReflectionMethod::class); - expect($resolved['reflectionMethod']->getName())->toBe('publicMethod'); + expect($resolved)->toBeInstanceOf(ReflectionMethod::class); + expect($resolved->getName())->toBe('publicMethod'); + expect($resolved->getDeclaringClass()->getName())->toBe(ValidHandlerClass::class); }); it('resolves valid invokable class string handler', function () { $handler = ValidInvokableClass::class; $resolved = HandlerResolver::resolve($handler); - expect($resolved['className'])->toBe(ValidInvokableClass::class); - expect($resolved['methodName'])->toBe('__invoke'); - expect($resolved['reflectionMethod'])->toBeInstanceOf(ReflectionMethod::class); - expect($resolved['reflectionMethod']->getName())->toBe('__invoke'); + expect($resolved)->toBeInstanceOf(ReflectionMethod::class); + expect($resolved->getName())->toBe('__invoke'); + expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class); }); it('throws for invalid array handler format (count)', function () { diff --git a/tests/Unit/Support/SchemaValidatorTest.php b/tests/Unit/Utils/SchemaValidatorTest.php similarity index 93% rename from tests/Unit/Support/SchemaValidatorTest.php rename to tests/Unit/Utils/SchemaValidatorTest.php index 6d1bf1d..a32a50f 100644 --- a/tests/Unit/Support/SchemaValidatorTest.php +++ b/tests/Unit/Utils/SchemaValidatorTest.php @@ -1,9 +1,9 @@ toArray(); + $emailSchema = (new Schema(format: 'email'))->toArray(); // Valid email $validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema); @@ -318,9 +318,9 @@ function getValidData(): array test('validates schema with object constraints from Schema attribute', function () { $userSchema = (new Schema( properties: [ - new Property('name', minLength: 2), - new Property('email', format: Format::EMAIL), - new Property('age', minimum: 18) + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] ], required: ['name', 'email'] ))->toArray(); @@ -367,25 +367,25 @@ function getValidData(): array test('validates schema with nested constraints from Schema attribute', function () { $orderSchema = (new Schema( properties: [ - new Property( - 'customer', - properties: [ - new Property('id', pattern: '^CUS-[0-9]{6}$'), - new Property('name', minLength: 2) + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2] ], - required: ['id'] - ), - new Property( - 'items', - minItems: 1, - items: new ArrayItems( - properties: [ - new Property('product_id', pattern: '^PRD-[0-9]{4}$'), - new Property('quantity', minimum: 1) + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1] ], - required: ['product_id', 'quantity'] - ) - ) + 'required' => ['product_id', 'quantity'] + ] + ] ], required: ['customer', 'items'] ))->toArray();